feat: migrate ori-basic rendering improvements into SPA bot (#2383)

Port all core architectural and rendering upgrades from ori-basic into
the setup-spa skill, bringing it to full parity.

## helpers.ts
- Replace JSON state (loadState/saveState/slack-issues.json) with SQLite
  (openDb/findThread/upsertThread/updateThread) using WAL mode and
  busy_timeout; add migrateFromJson() for legacy data
- Add full rich_text rendering pipeline: parseInlineMarkdown(),
  parseMarkdownBlock(), markdownToRichTextBlocks() — renders bold, italic,
  code, links, strikethrough, bullet/ordered lists, blockquotes, headers,
  fenced code blocks without Slack "See more" collapse
- Add extractMarkdownTables() + markdownTableToSlackBlock() for native
  Slack table blocks
- Add plainTextFallback() for push notification text
- Add PR_URL_REGEX constant
- Add flattenToolResultContent() for web_search_tool_result array content
- Update extractToolHint to handle query and url fields
- Update formatToolHistory to use emoji format:  *Name* `hint`
- Add tableBlocks field to SlackSegment interface

## main.ts
- Remove SLACK_CHANNEL_ID restriction — bot now responds in any channel + DMs
- Replace JSON state with SQLite throughout
- Add pendingQueues Map for FIFO concurrent message handling (no more dropped messages)
- Add buildPlanBlock() — structured task display with in_progress/complete
  status for all tools, interleaved with text via commitSegment()
- Replace mrkdwn section blocks with rich_text blocks via markdownToRichTextBlocks()
- Add overflow posting: when >47 blocks, extra content posts as follow-up messages
- Add firePrButtonIfNew() + buildPrButtonBlock() for immediate PR buttons during streaming
- Add cancel button (ActionsBlock) + cancel_run action handler + SIGTERM on process
- Add DM event handler (message.im channel_type)
- Track userId for thread state; pass SLACK_USER_ID to Claude subprocess env
- End-of-run: await prButtonPromise, delete mid-stream button, repost push-to-latest

## spa.test.ts
- Add SQLite tests (openDb, upsertThread, findThread, idempotency)
- Add parseInlineMarkdown tests (bold, code, link, italic, strikethrough, mixed)
- Add parseMarkdownBlock tests (paragraph, bullet list, ordered list, blockquote, header)
- Add markdownToRichTextBlocks tests (empty, plain, code fences, multiple fences)
- Add plainTextFallback tests
- Add extractMarkdownTables + markdownTableToSlackBlock tests
- Add web_search_tool_result handling test
- Update formatToolHistory + extractToolHint tests for new format
- Total: 94 tests, 0 fail

## package.json
- Add @slack/types and @slack/web-api dependencies (needed for Block types)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
L 2026-03-09 13:24:31 -04:00 committed by GitHub
parent 7bab1c3289
commit 5a86c4fc28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1631 additions and 275 deletions

View file

@ -1,8 +1,10 @@
// SPA helpers — pure functions for parsing Claude Code stream events,
// Slack formatting, state management, and file download/cleanup.
// Slack formatting, state management (SQLite), and file download/cleanup.
import type { Block } from "@slack/bolt";
import type { Result } from "../../../packages/cli/src/shared/result";
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { slackifyMarkdown } from "slackify-markdown";
@ -10,61 +12,230 @@ import * as v from "valibot";
import { Err, Ok } from "../../../packages/cli/src/shared/result";
import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards";
// #region State
// #region State — SQLite
const STATE_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
/** Path to the SQLite DB. Derived from DB_PATH env, or alongside a STATE_PATH json, or default. */
const DB_PATH =
process.env.DB_PATH ??
(process.env.STATE_PATH ? process.env.STATE_PATH.replace(/\.json$/, ".db") : undefined) ??
`${process.env.HOME ?? "/root"}/.config/spawn/state.db`;
const MappingSchema = v.object({
channel: v.string(),
threadTs: v.string(),
sessionId: v.string(),
createdAt: v.string(),
});
/** Legacy JSON path — used only for one-time migration. */
const LEGACY_JSON_PATH = process.env.STATE_PATH ?? `${process.env.HOME ?? "/root"}/.config/spawn/slack-issues.json`;
const StateSchema = v.object({
mappings: v.array(MappingSchema),
});
/** A thread SPA has been involved in. Rows are deleted (not flagged) when concluded. */
export interface ThreadRow {
channel: string;
threadTs: string;
sessionId: string;
createdAt: string;
userId?: string;
lastActivityAt?: string;
/** GitHub PR URLs SPA posted in this thread. */
prUrls?: string[];
}
export type Mapping = v.InferOutput<typeof MappingSchema>;
export type State = v.InferOutput<typeof StateSchema>;
/** Raw SQLite row shape (snake_case, JSON-encoded arrays). */
interface RawThread {
channel: string;
thread_ts: string;
session_id: string;
created_at: string;
user_id: string | null;
last_activity_at: string | null;
pr_urls: string | null;
}
export function loadState(): Result<State> {
function rowToThread(r: RawThread): ThreadRow {
return {
channel: r.channel,
threadTs: r.thread_ts,
sessionId: r.session_id,
createdAt: r.created_at,
userId: r.user_id ?? undefined,
lastActivityAt: r.last_activity_at ?? undefined,
prUrls: r.pr_urls
? Array.isArray(JSON.parse(r.pr_urls))
? JSON.parse(r.pr_urls).filter(isString)
: undefined
: undefined,
};
}
/** Migrate legacy slack-issues.json → SQLite on first open. */
function migrateFromJson(db: Database): void {
if (!existsSync(LEGACY_JSON_PATH)) {
return;
}
const count = db
.query<
{
n: number;
},
[]
>("SELECT COUNT(*) AS n FROM threads")
.get();
if (count && count.n > 0) {
return;
}
try {
if (!existsSync(STATE_PATH)) {
return Ok({
mappings: [],
});
const raw = readFileSync(LEGACY_JSON_PATH, "utf-8");
const json = toRecord(JSON.parse(raw)) ?? {};
const mappings = Array.isArray(json.mappings) ? json.mappings : [];
const insertThread = db.prepare<
void,
[
string,
string,
string,
string,
string | null,
string | null,
string | null,
]
>(
`INSERT OR IGNORE INTO threads
(channel, thread_ts, session_id, created_at, user_id, last_activity_at, pr_urls)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
let migrated = 0;
for (const m of mappings) {
const rec = toRecord(m);
if (!rec) {
continue;
}
insertThread.run(
isString(rec.channel) ? rec.channel : "",
isString(rec.threadTs) ? rec.threadTs : "",
isString(rec.sessionId) ? rec.sessionId : "",
isString(rec.createdAt) ? rec.createdAt : new Date().toISOString(),
null,
null,
null,
);
migrated++;
}
const raw = readFileSync(STATE_PATH, "utf-8");
const parsed = v.parse(StateSchema, JSON.parse(raw));
return Ok(parsed);
console.log(`[spa] Migrated slack-issues.json → state.db (${migrated} threads)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Err(new Error(`Failed to load state: ${msg}`));
console.error(`[spa] slack-issues.json migration failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
export function saveState(s: State): Result<void> {
try {
const dir = dirname(STATE_PATH);
mkdirSync(dir, {
/**
* Open (or create) the SQLite database, run schema migrations, and return the handle.
* Pass `:memory:` as `path` in tests to get a fresh in-memory DB with no migration.
*/
export function openDb(path?: string): Database {
const dbPath = path ?? DB_PATH;
if (dbPath !== ":memory:") {
mkdirSync(dirname(dbPath), {
recursive: true,
});
writeFileSync(STATE_PATH, `${JSON.stringify(s, null, 2)}\n`);
return Ok(undefined);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Err(new Error(`Failed to save state: ${msg}`));
}
const db = new Database(dbPath);
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA busy_timeout = 5000");
db.run(`
CREATE TABLE IF NOT EXISTS threads (
channel TEXT NOT NULL,
thread_ts TEXT NOT NULL,
session_id TEXT NOT NULL,
created_at TEXT NOT NULL,
user_id TEXT,
last_activity_at TEXT,
pr_urls TEXT,
PRIMARY KEY (channel, thread_ts)
)
`);
if (!path) {
migrateFromJson(db);
}
return db;
}
export function findMapping(s: State, channel: string, threadTs: string): Mapping | undefined {
return s.mappings.find((m) => m.channel === channel && m.threadTs === threadTs);
/** Look up a thread by its Slack coordinates. Returns undefined if not found. */
export function findThread(db: Database, channel: string, threadTs: string): ThreadRow | undefined {
const row = db
.query<
RawThread,
[
string,
string,
]
>("SELECT * FROM threads WHERE channel = ? AND thread_ts = ?")
.get(channel, threadTs);
return row ? rowToThread(row) : undefined;
}
export function addMapping(s: State, mapping: Mapping): Result<void> {
s.mappings.push(mapping);
return saveState(s);
/** Insert or update a thread record. On conflict, updates session/activity fields. */
export function upsertThread(db: Database, thread: ThreadRow): void {
db.run(
`INSERT INTO threads (channel, thread_ts, session_id, created_at, user_id, last_activity_at, pr_urls)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (channel, thread_ts) DO UPDATE SET
session_id = excluded.session_id,
user_id = COALESCE(excluded.user_id, user_id),
last_activity_at = excluded.last_activity_at,
pr_urls = CASE WHEN excluded.pr_urls IS NOT NULL THEN excluded.pr_urls ELSE pr_urls END`,
[
thread.channel,
thread.threadTs,
thread.sessionId,
thread.createdAt,
thread.userId ?? null,
thread.lastActivityAt ?? null,
thread.prUrls ? JSON.stringify(thread.prUrls) : null,
],
);
}
/**
* Update activity fields on an existing thread.
* Merges prUrls with the existing set (deduped). No-ops if the row doesn't exist.
*/
export function updateThread(
db: Database,
channel: string,
threadTs: string,
opts: {
sessionId?: string;
userId?: string;
lastActivityAt?: string;
prUrls?: string[];
},
): void {
const current = findThread(db, channel, threadTs);
if (!current) {
return;
}
const mergedPrUrls =
opts.prUrls && opts.prUrls.length > 0
? [
...new Set([
...(current.prUrls ?? []),
...opts.prUrls,
]),
]
: current.prUrls;
db.run(
`UPDATE threads SET
session_id = ?,
user_id = ?,
last_activity_at = ?,
pr_urls = ?
WHERE channel = ? AND thread_ts = ?`,
[
opts.sessionId ?? current.sessionId,
current.userId ?? opts.userId ?? null,
opts.lastActivityAt ?? current.lastActivityAt ?? null,
mergedPrUrls ? JSON.stringify(mergedPrUrls) : null,
channel,
threadTs,
],
);
}
// #endregion
@ -82,6 +253,7 @@ export interface SlackSegment {
toolName?: string; // set for tool_use
toolHint?: string; // set for tool_use — truncated command/pattern/path
isError?: boolean; // set for tool_result
tableBlocks?: object[]; // set for text — Slack table block objects extracted from markdown tables
}
/** Tracked tool call for history and stats. */
@ -100,7 +272,9 @@ export function extractToolHint(block: Record<string, unknown>): string {
const hint =
(isString(input.command) ? input.command : null) ??
(isString(input.pattern) ? input.pattern : null) ??
(isString(input.file_path) ? input.file_path : null);
(isString(input.file_path) ? input.file_path : null) ??
(isString(input.query) ? input.query : null) ??
(isString(input.url) ? input.url : null);
if (!hint) {
return "";
}
@ -119,17 +293,17 @@ function formatToolHint(block: Record<string, unknown>): string {
/** Format tool counts into a compact stats string: "1× Bash, 4× Read, 5× Grep". */
export function formatToolStats(counts: ReadonlyMap<string, number>): string {
return Array.from(counts.entries())
.map(([name, count]) => `${count}× ${name}`)
.map(([name, count]) => `${count}\u00d7 ${name}`)
.join(", ");
}
/** Format the full ordered tool history into a numbered list for the expandable attachment. */
/** Format the full ordered tool history into a Slack-formatted list for the expandable attachment. */
export function formatToolHistory(history: readonly ToolCall[]): string {
return history
.map((t, i) => {
const icon = t.errored ? "✗" : "✓";
const hint = t.hint ? ` ${t.hint}` : "";
return `${i + 1}. ${icon} ${t.name}${hint}`;
.map((t) => {
const icon = t.errored ? ":x:" : ":white_check_mark:";
const hint = t.hint ? ` \`${t.hint}\`` : "";
return `${icon} *${t.name}*${hint}`;
})
.join("\n");
}
@ -143,6 +317,7 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
const content = Array.isArray(msg.content) ? msg.content : [];
const textParts: string[] = [];
const tableBlocksList: object[] = [];
const toolParts: string[] = [];
let firstToolName: string | undefined;
let firstToolHint: string | undefined;
@ -154,7 +329,17 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
}
if (block.type === "text" && isString(block.text)) {
textParts.push(markdownToSlack(block.text));
// Extract markdown tables before conversion so they render as native Slack table blocks.
const { clean, tables } = extractMarkdownTables(block.text);
if (clean.trim()) {
textParts.push(clean);
}
for (const table of tables) {
const tb = markdownTableToSlackBlock(table);
if (tb) {
tableBlocksList.push(tb);
}
}
}
if (block.type === "tool_use" && isString(block.name)) {
@ -175,15 +360,47 @@ function parseAssistantEvent(event: Record<string, unknown>): SlackSegment | nul
toolHint: firstToolHint,
};
}
if (textParts.length > 0) {
if (textParts.length > 0 || tableBlocksList.length > 0) {
return {
kind: "text",
text: textParts.join(""),
tableBlocks: tableBlocksList.length > 0 ? tableBlocksList : undefined,
};
}
return null;
}
/**
* Flatten tool_result `content` into a plain string.
*
* Claude Code emits two shapes for the content field:
* - string regular tool results (Bash, Read, Grep, )
* - array of `web_search_result` objects WebSearch results
*
* Returns a flat "[N] Title URL" list for web search results.
*/
function flattenToolResultContent(content: unknown): string {
if (isString(content)) {
return content;
}
if (!Array.isArray(content)) {
return "";
}
const lines: string[] = [];
for (let i = 0; i < content.length; i++) {
const item = toRecord(content[i]);
if (!item) {
continue;
}
const title = isString(item.title) ? item.title : "";
const url = isString(item.url) ? item.url : "";
if (url) {
lines.push(title ? `[${i + 1}] ${title} ${url}` : `[${i + 1}] ${url}`);
}
}
return lines.join("\n");
}
/** Parse a user-type event (tool results) into a SlackSegment. */
function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
const msg = toRecord(event.message);
@ -197,7 +414,8 @@ function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
for (const rawBlock of content) {
const block = toRecord(rawBlock);
if (!block || block.type !== "tool_result") {
// Handle both regular tool_result and web_search_tool_result blocks.
if (!block || (block.type !== "tool_result" && block.type !== "web_search_tool_result")) {
continue;
}
@ -207,7 +425,7 @@ function parseUserEvent(event: Record<string, unknown>): SlackSegment | null {
}
const prefix = isError ? ":x: Error" : ":white_check_mark: Result";
const resultText = isString(block.content) ? block.content : "";
const resultText = flattenToolResultContent(block.content);
const truncated = resultText.length > 500 ? `${resultText.slice(0, 500)}...` : resultText;
if (!truncated) {
parts.push(`${prefix}: (empty)`);
@ -259,6 +477,74 @@ export function markdownToSlack(text: string): string {
return slackifyMarkdown(text);
}
/**
* Regex that matches a complete markdown table:
* header row \n separator row \n zero-or-more data rows
*/
export const MARKDOWN_TABLE_RE = /\|.+\|\n\|[-: |]+\|\n(?:\|.+\|\n?)*/g;
/**
* Extract all markdown tables from raw text.
* Returns the cleaned text (each table replaced with a blank line) and
* an array of raw table strings for `markdownTableToSlackBlock`.
*/
export function extractMarkdownTables(raw: string): {
clean: string;
tables: string[];
} {
const tables: string[] = [];
MARKDOWN_TABLE_RE.lastIndex = 0;
const clean = raw.replace(MARKDOWN_TABLE_RE, (match) => {
tables.push(match.trim());
return "\n\n";
});
return {
clean: clean.trim(),
tables,
};
}
/**
* Convert a raw markdown table string into a Slack table block object.
* Returns null if the input cannot be parsed into a valid table.
*/
export function markdownTableToSlackBlock(tableMarkdown: string): object | null {
const allLines = tableMarkdown
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const lines = allLines.filter((l) => !/^\|[-: |]+\|$/.test(l));
if (lines.length < 1) {
return null;
}
const parseRow = (line: string): string[] =>
line
.split("|")
.slice(1, -1)
.map((c) => c.trim());
const rows = lines.map(parseRow);
const colCount = Math.max(...rows.map((r) => r.length));
if (colCount < 1) {
return null;
}
return {
type: "table",
rows: rows.map((row) => {
const padded = row.slice();
while (padded.length < colCount) {
padded.push("");
}
return padded.map((cell) => ({
type: "raw_text",
text: cell,
}));
}),
};
}
// #endregion
// #region File downloads
@ -267,7 +553,6 @@ const DOWNLOADS_DIR = "/tmp/spa-downloads";
/** Check if a buffer starts with an HTML doctype or tag (indicates auth redirect, not a real file). */
export function looksLikeHtml(buf: Buffer): boolean {
// Check the first 256 bytes for HTML signatures
const head = buf.subarray(0, 256).toString("utf-8").trimStart().toLowerCase();
return head.startsWith("<!doctype html") || head.startsWith("<html");
}
@ -394,3 +679,354 @@ export function runCleanupIfDue(): void {
}
// #endregion
// #region Slack Block Kit — rich_text rendering
/**
* Convert raw markdown to a plain-text string suitable for Slack notification
* fallback text (push notifications, sidebar previews, screen readers).
*/
export function plainTextFallback(md: string): string {
return md
.replace(/^```[a-zA-Z0-9]*\n[\s\S]*?^```[ \t]*$/gm, "[code]")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/\*([^*\n]+)\*/g, "$1")
.replace(/~~([^~\n]+)~~/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^>\s*/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Build a `rich_text` Block from an array of rich_text elements. */
function mkRichText(elements: object[]): Block {
return Object.assign(
{
type: "rich_text",
},
{
elements,
},
);
}
/** Build a `rich_text_preformatted` element wrapping a single text string. */
function mkPreformatted(code: string): object {
return Object.assign(
{
type: "rich_text_preformatted",
},
{
elements: [
{
type: "text",
text: code,
},
],
},
);
}
/**
* Parse inline markdown into Slack rich_text inline element objects.
*
* Handles (in priority order):
* - Inline code: `code`
* - Links: [text](url)
* - Bold: **text**
* - Strikethrough: ~~text~~
* - Italic: *text*
* - Plain text: everything else
*/
export function parseInlineMarkdown(text: string): object[] {
const result: object[] = [];
const TOKEN_RE = /`([^`\n]+)`|\[([^\]]*)\]\(([^)]*)\)|\*\*([^*\n]+)\*\*|~~([^~\n]+)~~|\*([^*\n]+)\*/g;
let lastIndex = 0;
for (const match of text.matchAll(TOKEN_RE)) {
const matchIndex = match.index;
if (matchIndex > lastIndex) {
const plain = text.slice(lastIndex, matchIndex);
if (plain) {
result.push({
type: "text",
text: plain,
});
}
}
if (match[1] !== undefined) {
result.push({
type: "text",
text: match[1],
style: {
code: true,
},
});
} else if (match[2] !== undefined) {
const linkText = match[2];
const url = match[3] ?? "";
if (linkText) {
result.push({
type: "link",
url,
text: linkText,
});
} else {
result.push({
type: "link",
url,
});
}
} else if (match[4] !== undefined) {
result.push({
type: "text",
text: match[4],
style: {
bold: true,
},
});
} else if (match[5] !== undefined) {
result.push({
type: "text",
text: match[5],
style: {
strike: true,
},
});
} else if (match[6] !== undefined) {
result.push({
type: "text",
text: match[6],
style: {
italic: true,
},
});
}
lastIndex = matchIndex + match[0].length;
}
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex);
if (remaining) {
result.push({
type: "text",
text: remaining,
});
}
}
return result;
}
/**
* Parse a non-code markdown text block into Slack rich_text element objects.
*
* Handles: bullet lists, ordered lists, blockquotes, ATX headers (#), and
* regular paragraphs.
*/
export function parseMarkdownBlock(text: string): object[] {
const elements: object[] = [];
const lines = text.split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
const bulletMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
if (bulletMatch) {
const listItems: object[] = [];
while (i < lines.length) {
const bm = lines[i].match(/^(\s*)([-*+])\s+(.*)/);
if (!bm) {
break;
}
listItems.push({
type: "rich_text_section",
elements: parseInlineMarkdown(bm[3]),
});
i++;
}
elements.push({
type: "rich_text_list",
style: "bullet",
elements: listItems,
});
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const listItems: object[] = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
const itemText = lines[i].replace(/^\s*\d+\.\s+/, "");
listItems.push({
type: "rich_text_section",
elements: parseInlineMarkdown(itemText),
});
i++;
}
elements.push({
type: "rich_text_list",
style: "ordered",
elements: listItems,
});
continue;
}
const headerMatch = line.match(/^#{1,6}\s+(.*)/);
if (headerMatch) {
elements.push({
type: "rich_text_section",
elements: [
{
type: "text",
text: headerMatch[1],
style: {
bold: true,
},
},
],
});
i++;
continue;
}
if (/^> ?/.test(line)) {
const quoteLines: string[] = [];
while (i < lines.length && /^> ?/.test(lines[i])) {
quoteLines.push(lines[i].replace(/^> ?/, ""));
i++;
}
elements.push({
type: "rich_text_quote",
elements: parseInlineMarkdown(quoteLines.join("\n")),
});
continue;
}
const paraLines: string[] = [];
while (i < lines.length) {
const l = lines[i];
if (!l.trim()) {
break;
}
if (/^(\s*)([-*+])\s+/.test(l)) {
break;
}
if (/^\s*\d+\.\s+/.test(l)) {
break;
}
if (/^#{1,6}\s+/.test(l)) {
break;
}
if (/^> ?/.test(l)) {
break;
}
paraLines.push(l);
i++;
}
if (paraLines.length > 0) {
const inlineElms = parseInlineMarkdown(paraLines.join("\n"));
if (inlineElms.length > 0) {
elements.push({
type: "rich_text_section",
elements: inlineElms,
});
}
}
}
return elements;
}
/**
* Convert raw markdown text to an array of Slack `rich_text` Block objects.
*
* Why rich_text instead of section+mrkdwn?
* - `rich_text_preformatted` renders code fences at full message width and never
* triggers Slack's "See more" collapse, regardless of line count.
* - `rich_text_section` also uses the full message width.
*
* Strategy:
* 1. Split on fenced code blocks (``````).
* 2. Each code fence one `rich_text` block containing a `rich_text_preformatted` element.
* 3. Each surrounding text segment one `rich_text` block with section/list/quote elements.
* 4. Unclosed code fences (mid-stream) treated as preformatted content.
*/
export function markdownToRichTextBlocks(text: string): Block[] {
if (!text.trim()) {
return [];
}
const blocks: Block[] = [];
const FENCE_RE = /^```([a-zA-Z0-9]*)\n([\s\S]*?)^```[ \t]*$/gm;
let lastIndex = 0;
for (const match of text.matchAll(FENCE_RE)) {
const matchIndex = match.index;
if (matchIndex > lastIndex) {
const before = text.slice(lastIndex, matchIndex).trim();
if (before) {
const elms = parseMarkdownBlock(before);
if (elms.length > 0) {
blocks.push(mkRichText(elms));
}
}
}
const codeContent = match[2].replace(/\n$/, "");
if (codeContent) {
blocks.push(
mkRichText([
mkPreformatted(codeContent),
]),
);
}
lastIndex = matchIndex + match[0].length;
}
const remaining = text.slice(lastIndex);
if (remaining.trim()) {
const unclosedIdx = remaining.search(/^```[a-zA-Z0-9]*\n/m);
if (unclosedIdx !== -1) {
const beforeFence = remaining.slice(0, unclosedIdx).trim();
if (beforeFence) {
const elms = parseMarkdownBlock(beforeFence);
if (elms.length > 0) {
blocks.push(mkRichText(elms));
}
}
const fenceNewline = remaining.indexOf("\n", unclosedIdx);
const unclosedCode = fenceNewline !== -1 ? remaining.slice(fenceNewline + 1) : "";
if (unclosedCode.trim()) {
blocks.push(
mkRichText([
mkPreformatted(unclosedCode),
]),
);
}
} else {
const elms = parseMarkdownBlock(remaining.trim());
if (elms.length > 0) {
blocks.push(mkRichText(elms));
}
}
}
return blocks;
}
// #endregion
// Exclude `|` so we don't span across Slack mrkdwn `<url|label>` links.
export const PR_URL_REGEX = /https:\/\/github\.com\/[^\s<>)|]+\/pull\/\d+/g;

View file

@ -1,24 +1,27 @@
// SPA (Spawn's Personal Agent) — Slack bot entry point.
// Pipes Slack threads into Claude Code sessions and streams responses back.
import type { ContextBlock, KnownBlock, SectionBlock } from "@slack/bolt";
import type { State, ToolCall } from "./helpers";
import type { ActionsBlock, ContextBlock, KnownBlock, SectionBlock } from "@slack/bolt";
import type { Block } from "@slack/types";
import type { ToolCall } from "./helpers";
import { App } from "@slack/bolt";
import * as v from "valibot";
import { isString, toRecord } from "../../../packages/cli/src/shared/type-guards";
import {
addMapping,
downloadSlackFile,
findMapping,
formatToolHistory,
findThread,
formatToolStats,
loadState,
markdownToRichTextBlocks,
openDb,
PR_URL_REGEX,
parseStreamEvent,
plainTextFallback,
ResultSchema,
runCleanupIfDue,
saveState,
stripMention,
updateThread,
upsertThread,
} from "./helpers";
type SlackClient = InstanceType<typeof App>["client"];
@ -27,13 +30,11 @@ type SlackClient = InstanceType<typeof App>["client"];
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN ?? "";
const SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN ?? "";
const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID ?? "";
const GITHUB_REPO = process.env.GITHUB_REPO ?? "OpenRouterTeam/spawn";
for (const [name, value] of Object.entries({
SLACK_BOT_TOKEN,
SLACK_APP_TOKEN,
SLACK_CHANNEL_ID,
})) {
if (!value) {
console.error(`ERROR: ${name} env var is required`);
@ -51,15 +52,7 @@ let BOT_USER_ID = "";
// #region State
const stateResult = loadState();
const state: State = stateResult.ok
? stateResult.data
: {
mappings: [],
};
if (!stateResult.ok) {
console.warn(`[spa] ${stateResult.error.message}, starting fresh`);
}
const db = openDb();
// Active Claude Code processes — keyed by threadTs
const activeRuns = new Map<
@ -67,9 +60,21 @@ const activeRuns = new Map<
{
proc: ReturnType<typeof Bun.spawn>;
startedAt: number;
cancelled?: boolean;
}
>();
// Pending messages queued while a run is active — keyed by threadTs
// Each entry is a FIFO list of { channel, eventTs, userId? } waiting to be processed
const pendingQueues = new Map<
string,
Array<{
channel: string;
eventTs: string;
userId?: string;
}>
>();
// #endregion
// #region Claude Code helpers
@ -102,16 +107,12 @@ When creating issues, include a footer: "_Filed from Slack by SPA_"
Below is the full Slack thread. The most recent message is the one you should respond to. Prior messages are context.`;
/** Slack attachment shape (secondary content below blocks). */
interface SlackAttachment {
color?: string;
text: string;
mrkdwn_in?: string[];
}
/**
* Post a new message or update an existing one. Returns the message timestamp.
* Optional `attachments` adds expandable secondary content below blocks.
*
* `tableAttachments` is an optional list of Slack attachment objects each wrapping
* a `{ type: "table", ... }` block Slack only allows one table per message;
* pass multiple elements to post extra tables separately.
*/
async function postOrUpdate(
client: SlackClient,
@ -119,29 +120,45 @@ async function postOrUpdate(
threadTs: string,
existingTs: string | undefined,
fallback: string,
blocks: KnownBlock[],
attachments?: SlackAttachment[],
blocks: (KnownBlock | Block)[],
tableAttachments?: Record<string, unknown>[],
): Promise<string | undefined> {
if (!existingTs) {
const msg = await client.chat
.postMessage({
channel,
thread_ts: threadTs,
text: fallback,
blocks,
attachments,
})
.postMessage(
Object.assign(
{
channel,
thread_ts: threadTs,
text: fallback,
blocks,
},
tableAttachments?.length
? {
attachments: tableAttachments,
}
: {},
),
)
.catch(() => null);
return msg?.ts;
}
await client.chat
.update({
channel,
ts: existingTs,
text: fallback,
blocks,
attachments: attachments ?? [],
})
.update(
Object.assign(
{
channel,
ts: existingTs,
text: fallback,
blocks,
},
tableAttachments?.length
? {
attachments: tableAttachments,
}
: {},
),
)
.catch(() => {});
return existingTs;
}
@ -221,117 +238,107 @@ async function buildThreadPrompt(client: SlackClient, channel: string, threadTs:
return lines.join("\n\n");
}
// ─── Block Kit message builder ─────────────────────────────────────────────
const MAX_SECTION_LEN = 2900; // Slack section block text limit is 3000
interface BuildBlocksInput {
mainText: string;
/** Rich-text blocks for the main response text (0 or more). */
textBlocks: Block[];
currentTool: ToolCall | null;
toolCounts: ReadonlyMap<string, number>;
toolHistory: readonly ToolCall[];
loading: boolean;
}
interface BuildBlocksResult {
blocks: KnownBlock[];
attachments: SlackAttachment[];
/**
* Build a Slack "plan" block from the tool call history.
* - Completed tools status: "complete"
* - Active (loading) tool status: "in_progress"
*/
function buildPlanBlock(toolHistory: readonly ToolCall[], currentTool: ToolCall | null, loading: boolean): Block {
const tasks = toolHistory.map((tool, i) => {
const isActive = loading && tool === currentTool;
const status = isActive ? "in_progress" : "complete";
const taskTitle = tool.name;
const detailText = tool.hint || tool.name;
return Object.assign(
{
task_id: `task_${i}`,
title: taskTitle,
status,
},
{
details: {
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [
{
type: "text",
text: detailText,
},
],
},
],
},
},
);
});
const planTitle = loading && currentTool ? currentTool.name : "Tool Calls";
return Object.assign(
{
type: "plan",
},
{
plan_id: "tool_calls",
title: planTitle,
tasks,
},
);
}
/**
* Build Block Kit blocks with redesigned tool footer:
* 1. Section: main response text
* 2. Context: latest tool call (swapped, not appended)
* 3. Context: compact stats line (1× Bash, 4× Read, ...)
* 4. Attachment: full ordered tool history (expandable in Slack)
* Build Block Kit blocks for a single Slack message:
* 1. Rich-text blocks supplied by caller
* 2. Plan: all tool calls as tasks (complete / in_progress)
* 3. Context: `:openrouter-loading:` + compact stats line combined
*/
function buildBlocks(input: BuildBlocksInput): BuildBlocksResult {
const { mainText, currentTool, toolCounts, toolHistory, loading } = input;
const blocks: KnownBlock[] = [];
const attachments: SlackAttachment[] = [];
function buildBlocks(input: BuildBlocksInput): (KnownBlock | Block)[] {
const { textBlocks, currentTool, toolCounts, toolHistory, loading } = input;
const blocks: (KnownBlock | Block)[] = [];
// 1. Main text section
if (mainText) {
const display = mainText.length > MAX_SECTION_LEN ? `...${mainText.slice(-MAX_SECTION_LEN)}` : mainText;
const section: SectionBlock = {
type: "section",
text: {
type: "mrkdwn",
text: display,
},
};
blocks.push(section);
blocks.push(...textBlocks);
if (toolHistory.length > 0) {
blocks.push(buildPlanBlock(toolHistory, currentTool, loading));
}
// 2. Current tool detail — shows only the LATEST tool (swapped each update)
if (currentTool) {
const icon = currentTool.errored ? ":x:" : ":hammer_and_wrench:";
let toolLine = `${icon} *${currentTool.name}*`;
if (currentTool.hint) {
toolLine += ` \`${currentTool.hint}\``;
}
if (loading) {
toolLine += " :openrouter-loading:";
const hasStats = toolCounts.size > 0;
if (loading || hasStats) {
let footerText = loading ? ":openrouter-loading:" : "";
if (hasStats) {
const stats = formatToolStats(toolCounts);
footerText = footerText ? `${footerText} ${stats}` : stats;
}
const ctx: ContextBlock = {
type: "context",
elements: [
{
type: "mrkdwn",
text: toolLine,
},
],
};
blocks.push(ctx);
} else if (loading && !mainText) {
const ctx: ContextBlock = {
type: "context",
elements: [
{
type: "mrkdwn",
text: ":openrouter-loading:",
text: footerText,
},
],
};
blocks.push(ctx);
}
// 3. Stats line — compact tool usage counts
if (toolCounts.size > 0) {
const stats = formatToolStats(toolCounts);
const ctx: ContextBlock = {
type: "context",
elements: [
{
type: "mrkdwn",
text: stats,
},
],
};
blocks.push(ctx);
}
// 4. Expandable tool history — Slack auto-collapses long attachment text
if (!loading && toolHistory.length > 1) {
const historyText = formatToolHistory(toolHistory);
attachments.push({
color: "#808080",
text: historyText,
mrkdwn_in: [
"text",
],
});
}
return {
blocks,
attachments,
};
return blocks;
}
/**
* Run `claude -p` with stream-json output.
* Text -> main section block. Tools -> compact context footer.
* Text -> rich_text blocks. Tools -> plan block. Footer -> loading + stats.
*/
async function runClaudeAndStream(
client: SlackClient,
@ -339,7 +346,11 @@ async function runClaudeAndStream(
threadTs: string,
prompt: string,
sessionId: string | undefined,
): Promise<string | null> {
userId?: string,
): Promise<{
sessionId: string;
prUrls: string[];
} | null> {
const args = [
"claude",
"-p",
@ -365,6 +376,16 @@ async function runClaudeAndStream(
stderr: "pipe",
stdin: "pipe",
cwd: process.env.REPO_ROOT ?? process.cwd(),
env: {
...process.env,
SLACK_CHANNEL_ID: channel,
SLACK_THREAD_TS: threadTs,
...(userId
? {
SLACK_USER_ID: userId,
}
: {}),
},
});
proc.stdin.write(prompt);
@ -375,10 +396,61 @@ async function runClaudeAndStream(
startedAt: Date.now(),
});
// ─── Streaming state ─────────────────────────────────────────────────
let mainText = "";
// --- Streaming state ---
let currentSegmentText = ""; // text for the current in-progress Slack message
let fullText = ""; // accumulates all text output across the entire run (for PR URL detection)
const currentTableBlocks: object[] = []; // Slack table blocks extracted from markdown tables
const toolHistory: ToolCall[] = [];
const toolCounts = new Map<string, number>();
// --- Immediate PR button ---
const attemptedPrUrls = new Set<string>();
let prBtnTs: string | undefined;
let prButtonPromise: Promise<void> = Promise.resolve();
/** Build a Slack actions block with buttons for the given PR URLs. */
const buildPrButtonBlock = (urls: string[]): ActionsBlock => ({
type: "actions",
elements: urls.slice(0, 5).map((url, i) => ({
type: "button",
text: {
type: "plain_text",
text: `🔗 View PR${urls.length > 1 ? ` #${i + 1}` : ""}`,
emoji: true,
},
url,
action_id: `view_pr_${i}`,
})),
});
/**
* Fire-and-forget: if `fullText` contains PR URLs not yet attempted, immediately
* post (or update) a button block so the team gets the link without waiting.
*/
const firePrButtonIfNew = (): void => {
PR_URL_REGEX.lastIndex = 0;
const detected = new Set(fullText.match(PR_URL_REGEX) ?? []);
const newUrls = [
...detected,
].filter((u) => !attemptedPrUrls.has(u));
if (newUrls.length === 0) {
return;
}
for (const u of newUrls) {
attemptedPrUrls.add(u);
}
const allUrls = [
...attemptedPrUrls,
];
prButtonPromise = postOrUpdate(client, channel, threadTs, prBtnTs, allUrls[0] ?? "PR ready for review", [
buildPrButtonBlock(allUrls),
]).then((ts) => {
if (ts) {
prBtnTs = ts;
}
});
};
let currentTool: ToolCall | null = null;
let msgTs: string | undefined;
let returnedSessionId: string | null = null;
@ -386,11 +458,110 @@ async function runClaudeAndStream(
let lastUpdateTime = 0;
const UPDATE_INTERVAL_MS = 2000;
let dirty = false;
let wasCancelled = false;
// Slack hard-caps messages at 50 blocks total. Reserve 3 slots for plan + context + actions.
const MAX_TEXT_BLOCKS = 47;
/**
* Finalize the current text segment as a standalone Slack message (no footer/tools).
* Resets currentSegmentText, currentTableBlocks, and msgTs so the next tools/text start fresh.
*
* When tools are in-flight at commit time, the plan block is finalized first as its own
* standalone message (via updateMessage(false)), then plan state is reset so the next
* batch of tool calls gets a fresh plan block. This produces interleaved messages:
* [plan] [text] [plan]
*/
async function commitSegment(): Promise<void> {
if (!currentSegmentText && currentTableBlocks.length === 0) {
return;
}
if (toolHistory.length > 0) {
const savedText = currentSegmentText;
const savedTables = currentTableBlocks.splice(0);
currentSegmentText = "";
await updateMessage(false);
toolHistory.length = 0;
currentTool = null;
toolCounts.clear();
msgTs = undefined;
currentSegmentText = savedText;
currentTableBlocks.push(...savedTables);
}
const allBlocks = markdownToRichTextBlocks(currentSegmentText);
const blocks = allBlocks.slice(0, MAX_TEXT_BLOCKS);
const overflowBlocks = allBlocks.slice(MAX_TEXT_BLOCKS);
const [firstTable, ...extraTables] = currentTableBlocks;
const tableAtts = firstTable
? [
{
blocks: [
firstTable,
],
},
]
: undefined;
const fallbackText = plainTextFallback(currentSegmentText);
const ts = await postOrUpdate(client, channel, threadTs, msgTs, fallbackText, blocks, tableAtts);
if (ts) {
hasOutput = true;
}
const overflowFallback = fallbackText.slice(0, 150);
for (const block of overflowBlocks) {
await postOrUpdate(client, channel, threadTs, undefined, overflowFallback, [
block,
]);
}
for (const tb of extraTables) {
await postOrUpdate(
client,
channel,
threadTs,
undefined,
"",
[],
[
{
blocks: [
tb,
],
},
],
);
}
msgTs = undefined;
currentSegmentText = "";
currentTableBlocks.length = 0;
}
/** Post or update the Slack message with current blocks. */
async function updateMessage(loading: boolean): Promise<void> {
const { blocks, attachments } = buildBlocks({
mainText,
const allTextBlocks = currentSegmentText ? markdownToRichTextBlocks(currentSegmentText) : [];
const hasTools = toolHistory.length > 0;
const primaryTextBlocks = loading
? allTextBlocks.slice(0, MAX_TEXT_BLOCKS)
: hasTools
? []
: allTextBlocks.slice(0, 1);
const overflowTextBlocks = loading
? allTextBlocks.slice(MAX_TEXT_BLOCKS)
: hasTools
? allTextBlocks
: allTextBlocks.slice(1);
const blocks = buildBlocks({
textBlocks: primaryTextBlocks,
currentTool,
toolCounts,
toolHistory,
@ -399,22 +570,80 @@ async function runClaudeAndStream(
if (blocks.length === 0) {
return;
}
if (loading) {
const cancelBtn: ActionsBlock = {
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "⛔ Cancel",
emoji: true,
},
style: "danger",
action_id: "cancel_run",
value: JSON.stringify({
channel,
threadTs,
}),
},
],
};
blocks.push(cancelBtn);
}
if (!loading && wasCancelled) {
const cancelledCtx: ContextBlock = {
type: "context",
elements: [
{
type: "mrkdwn",
text: ":octagonal_sign: _Cancelled_",
},
],
};
blocks.push(cancelledCtx);
}
const totalTools = toolHistory.length;
const fallback = mainText || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`;
const fallback =
plainTextFallback(currentSegmentText) || `Working... (${totalTools} tool${totalTools === 1 ? "" : "s"})`;
hasOutput = true;
msgTs = await postOrUpdate(
client,
channel,
threadTs,
msgTs,
fallback,
blocks,
attachments.length > 0 ? attachments : undefined,
);
msgTs = await postOrUpdate(client, channel, threadTs, msgTs, fallback, blocks);
dirty = false;
const overflowFallback = plainTextFallback(currentSegmentText).slice(0, 150);
for (const block of overflowTextBlocks) {
await postOrUpdate(client, channel, threadTs, undefined, overflowFallback, [
block,
]);
}
if (!loading && currentTableBlocks.length > 0) {
for (const tb of currentTableBlocks) {
await postOrUpdate(
client,
channel,
threadTs,
undefined,
"",
[],
[
{
blocks: [
tb,
],
},
],
);
}
currentTableBlocks.length = 0;
}
}
// ─── Stream processing ────────────────────────────────────────────────
// --- Stream processing ---
const decoder = new TextDecoder();
const reader = proc.stdout.getReader();
let buffer = "";
@ -462,9 +691,23 @@ async function runClaudeAndStream(
}
if (segment.kind === "text") {
mainText += segment.text;
currentSegmentText += segment.text;
fullText += segment.text;
firePrButtonIfNew(); // post button immediately if a new PR URL just appeared
if (segment.tableBlocks) {
currentTableBlocks.push(...segment.tableBlocks);
}
dirty = true;
} else if (segment.kind === "tool_use" && segment.toolName) {
// Between tool batches: commit the previous text segment so the thread reads
// [plan₁] → [text] → [plan₂] instead of one ever-growing plan block.
// Before the first tool: keep text in currentSegmentText so it stays part of
// the live tool message — avoids posting a seemingly-final answer while tools
// are still running.
if (currentSegmentText && toolHistory.length > 0) {
await commitSegment();
lastUpdateTime = 0;
}
const tool: ToolCall = {
name: segment.toolName,
hint: segment.toolHint ?? "",
@ -487,15 +730,16 @@ async function runClaudeAndStream(
}
}
} finally {
wasCancelled = activeRuns.get(threadTs)?.cancelled ?? false;
activeRuns.delete(threadTs);
}
// ─── Final update ─────────────────────────────────────────────────────
// --- Final update ---
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0 && !hasOutput && !mainText) {
if (exitCode !== 0 && !hasOutput && !currentSegmentText) {
console.error(`[spa] claude exited ${exitCode}: ${stderr}`);
const errSection: SectionBlock = {
type: "section",
@ -514,7 +758,7 @@ async function runClaudeAndStream(
// Final update — remove loading indicator
await updateMessage(false);
if (!hasOutput && !mainText) {
if (!hasOutput && !currentSegmentText) {
const doneCtx: ContextBlock = {
type: "context",
elements: [
@ -530,16 +774,50 @@ async function runClaudeAndStream(
msgTs = await postOrUpdate(client, channel, threadTs, msgTs, "Done", doneBlocks);
}
// --- PR button: push to latest position ---
await prButtonPromise;
PR_URL_REGEX.lastIndex = 0;
const prUrls = [
...new Set(fullText.match(PR_URL_REGEX) ?? []),
];
if (prUrls.length > 0) {
if (prBtnTs) {
await client.chat
.delete({
channel,
ts: prBtnTs,
})
.catch(() => {});
}
await postOrUpdate(client, channel, threadTs, undefined, prUrls[0] ?? "PR ready for review", [
buildPrButtonBlock(prUrls),
]);
}
if (!returnedSessionId) {
return null;
}
console.log(`[spa] Claude done (thread=${threadTs}, session=${returnedSessionId})`);
return returnedSessionId;
return {
sessionId: returnedSessionId,
prUrls,
};
}
// #endregion
// #region Core handler
async function handleThread(client: SlackClient, channel: string, threadTs: string, eventTs: string): Promise<void> {
// Prevent concurrent runs on the same thread
async function handleThread(
client: SlackClient,
channel: string,
threadTs: string,
eventTs: string,
userId?: string,
): Promise<void> {
// If a run is already active on this thread, enqueue the message instead of dropping it
if (activeRuns.has(threadTs)) {
await client.reactions
.add({
@ -548,6 +826,15 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
name: "hourglass_flowing_sand",
})
.catch(() => {});
const queue = pendingQueues.get(threadTs) ?? [];
queue.push({
channel,
eventTs,
userId,
});
pendingQueues.set(threadTs, queue);
console.log(`[spa] Queued message ${eventTs} for thread ${threadTs} (queue depth: ${queue.length})`);
return;
}
@ -556,7 +843,7 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
return;
}
const existing = findMapping(state, channel, threadTs);
const existing = findThread(db, channel, threadTs);
await client.reactions
.add({
@ -566,25 +853,46 @@ async function handleThread(client: SlackClient, channel: string, threadTs: stri
})
.catch(() => {});
const newSessionId = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId);
const result = await runClaudeAndStream(client, channel, threadTs, prompt, existing?.sessionId, userId);
// Save session mapping
if (newSessionId && !existing) {
const r = addMapping(state, {
// Persist session mapping
if (result && !existing) {
upsertThread(db, {
channel,
threadTs,
sessionId: newSessionId,
sessionId: result.sessionId,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
userId,
prUrls: result.prUrls.length > 0 ? result.prUrls : undefined,
});
if (!r.ok) {
console.error(`[spa] ${r.error.message}`);
}
} else if (newSessionId && existing) {
existing.sessionId = newSessionId;
const r = saveState(state);
if (!r.ok) {
console.error(`[spa] ${r.error.message}`);
} else if (result && existing) {
updateThread(db, channel, threadTs, {
sessionId: result.sessionId,
userId,
lastActivityAt: new Date().toISOString(),
prUrls: result.prUrls.length > 0 ? result.prUrls : undefined,
});
}
// Drain the queue: process any messages that arrived while this run was active
const queue = pendingQueues.get(threadTs);
if (queue && queue.length > 0) {
const next = queue.shift()!;
if (queue.length === 0) {
pendingQueues.delete(threadTs);
}
await client.reactions
.remove({
channel: next.channel,
timestamp: next.eventTs,
name: "hourglass_flowing_sand",
})
.catch(() => {});
console.log(`[spa] Draining queue for thread ${threadTs}, processing ${next.eventTs}`);
await handleThread(client, next.channel, threadTs, next.eventTs, next.userId);
}
}
@ -599,13 +907,64 @@ const app = new App({
logLevel: "INFO",
});
// --- app_mention: @Spawnis triggers a Claude run on this thread ---
// --- app_mention: @spa in any channel triggers a Claude run ---
app.event("app_mention", async ({ event, client }) => {
if (event.channel !== SLACK_CHANNEL_ID) {
const threadTs = event.thread_ts ?? event.ts;
await handleThread(client, event.channel, threadTs, event.ts, event.user);
});
// --- message.im: direct messages to the bot ---
app.event("message", async ({ event, client }) => {
const msg = toRecord(event);
if (!msg) {
return;
}
const threadTs = event.thread_ts ?? event.ts;
await handleThread(client, event.channel, threadTs, event.ts);
if (msg.channel_type !== "im") {
return;
}
if (msg.bot_id || msg.subtype) {
return;
}
if (msg.user === BOT_USER_ID) {
return;
}
const ts = isString(msg.ts) ? msg.ts : undefined;
if (!ts) {
return;
}
const channel = isString(msg.channel) ? msg.channel : undefined;
if (!channel) {
return;
}
const threadTs = isString(msg.thread_ts) ? msg.thread_ts : ts;
const userId = isString(msg.user) ? msg.user : undefined;
await handleThread(client, channel, threadTs, ts, userId);
});
// --- cancel_run: "⛔ Cancel" button pressed during an active run ---
app.action("cancel_run", async ({ ack, payload }) => {
await ack();
const value = "value" in payload ? String(payload.value) : null;
if (!value) {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return;
}
const obj = toRecord(parsed);
const threadTs = obj && isString(obj.threadTs) ? obj.threadTs : null;
if (!threadTs) {
return;
}
const run = activeRuns.get(threadTs);
if (run) {
run.cancelled = true;
run.proc.kill("SIGTERM");
console.log(`[spa] Cancelled run for thread ${threadTs}`);
}
});
// #endregion
@ -618,10 +977,7 @@ function shutdown(signal: string): void {
console.log(`[spa] Killing active run for thread ${threadTs}`);
run.proc.kill("SIGTERM");
}
const r = saveState(state);
if (!r.ok) {
console.error(`[spa] ${r.error.message}`);
}
db.close();
process.exit(0);
}
@ -646,6 +1002,7 @@ process.on("SIGINT", () => shutdown("SIGINT"));
}
await app.start();
console.log(`[spa] Running (channel=${SLACK_CHANNEL_ID}, repo=${GITHUB_REPO})`);
console.log(`[spa] Running (any channel + DMs, repo=${GITHUB_REPO})`);
})();
// #endregion

View file

@ -7,6 +7,8 @@
},
"dependencies": {
"@slack/bolt": "4.6.0",
"@slack/types": "^2.14.0",
"@slack/web-api": "^7.14.1",
"slackify-markdown": "^5.0.0",
"valibot": "1.2.0"
}

View file

@ -5,15 +5,23 @@ import streamEvents from "../../../fixtures/claude-code/stream-events.json";
import { toRecord } from "../../../packages/cli/src/shared/type-guards";
import {
downloadSlackFile,
extractMarkdownTables,
extractToolHint,
findThread,
formatToolHistory,
formatToolStats,
loadState,
looksLikeHtml,
MARKDOWN_TABLE_RE,
markdownTableToSlackBlock,
markdownToRichTextBlocks,
markdownToSlack,
openDb,
parseInlineMarkdown,
parseMarkdownBlock,
parseStreamEvent,
saveState,
plainTextFallback,
stripMention,
upsertThread,
} from "./helpers";
// Helper: extract a fixture event by index and cast to Record<string, unknown>
@ -78,15 +86,11 @@ describe("parseStreamEvent", () => {
expect(result?.text).toContain("Permission denied");
});
it("parses final assistant text from fixture with markdown→slack conversion", () => {
// fixture[7]: assistant with summary text containing **bold**
it("parses final assistant text from fixture", () => {
// fixture[7]: assistant with summary text
const result = parseStreamEvent(fixture(7));
expect(result?.kind).toBe("text");
// **#1234** → *#1234* (Slack bold)
expect(result?.text).toContain("*#1234*");
expect(result?.text).not.toContain("**#1234**");
// inline code preserved
expect(result?.text).toContain("`--json`");
expect(result?.text).toContain("#1234");
expect(result?.text).toContain("Would you like me to create a new issue");
});
@ -233,6 +237,30 @@ describe("parseStreamEvent", () => {
const result = parseStreamEvent(event);
expect(result?.text).toContain("...");
});
it("handles web_search_tool_result blocks", () => {
const event: Record<string, unknown> = {
type: "user",
message: {
content: [
{
type: "web_search_tool_result",
content: [
{
type: "web_search_result",
url: "https://example.com",
title: "Example",
},
],
},
],
},
};
const result = parseStreamEvent(event);
expect(result?.kind).toBe("tool_result");
expect(result?.text).toContain("https://example.com");
expect(result?.text).toContain("Example");
});
});
describe("stripMention", () => {
@ -288,16 +316,6 @@ describe("markdownToSlack", () => {
expect(result).toContain("*bold*");
});
it("handles the real SPA output pattern", () => {
const input =
"1. **[#1859 — Agent processes die](https://github.com/OpenRouterTeam/spawn/issues/1859)** — covers the root cause\n\n" +
"The SIGTERM is the **smoking gun**.";
const result = markdownToSlack(input);
expect(result).toContain("<https://github.com/OpenRouterTeam/spawn/issues/1859|#1859");
expect(result).toContain("*smoking gun*");
expect(result).not.toContain("](");
});
it("returns plain text unchanged", () => {
expect(markdownToSlack("no markdown here")).toContain("no markdown here");
});
@ -307,29 +325,6 @@ describe("markdownToSlack", () => {
});
});
describe("loadState", () => {
it("returns a Result object", () => {
// STATE_PATH is captured at module load time; the default path likely
// doesn't exist in CI, so loadState returns Ok({ mappings: [] })
const result = loadState();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.mappings).toBeInstanceOf(Array);
}
});
});
describe("saveState", () => {
it("returns a Result object", () => {
// Write to a temp file by using the module's STATE_PATH (default).
// If the default dir is writable, we get Ok; if not, Err. Either way it's a Result.
const result = saveState({
mappings: [],
});
expect(typeof result.ok).toBe("boolean");
});
});
describe("extractToolHint", () => {
it("extracts command from input", () => {
const block: Record<string, unknown> = {
@ -358,6 +353,24 @@ describe("extractToolHint", () => {
expect(extractToolHint(block)).toBe("/home/user/spawn/index.ts");
});
it("extracts query from input (WebSearch)", () => {
const block: Record<string, unknown> = {
input: {
query: "spawn deploy fix",
},
};
expect(extractToolHint(block)).toBe("spawn deploy fix");
});
it("extracts url from input (WebFetch)", () => {
const block: Record<string, unknown> = {
input: {
url: "https://example.com/docs",
},
};
expect(extractToolHint(block)).toBe("https://example.com/docs");
});
it("prefers command over pattern and file_path", () => {
const block: Record<string, unknown> = {
input: {
@ -388,7 +401,7 @@ describe("extractToolHint", () => {
it("returns empty string for input without recognized keys", () => {
const block: Record<string, unknown> = {
input: {
query: "search term",
unknown_key: "value",
},
};
expect(extractToolHint(block)).toBe("");
@ -434,17 +447,17 @@ describe("formatToolStats", () => {
});
describe("formatToolHistory", () => {
it("formats a single tool call", () => {
it("formats a single tool call with Slack emoji icons", () => {
const history: ToolCall[] = [
{
name: "Bash",
hint: "echo hi",
},
];
expect(formatToolHistory(history)).toBe("1. ✓ Bash — echo hi");
expect(formatToolHistory(history)).toBe(":white_check_mark: *Bash* `echo hi`");
});
it("formats multiple tool calls with numbering", () => {
it("formats multiple tool calls", () => {
const history: ToolCall[] = [
{
name: "Bash",
@ -454,16 +467,13 @@ describe("formatToolHistory", () => {
name: "Glob",
hint: "**/*.ts",
},
{
name: "Read",
hint: "/home/user/index.ts",
},
];
const result = formatToolHistory(history);
expect(result).toBe("1. ✓ Bash — gh issue list\n2. ✓ Glob — **/*.ts\n3. ✓ Read — /home/user/index.ts");
expect(result).toContain(":white_check_mark: *Bash* `gh issue list`");
expect(result).toContain(":white_check_mark: *Glob* `**/*.ts`");
});
it("marks errored tools with ", () => {
it("marks errored tools with :x: emoji", () => {
const history: ToolCall[] = [
{
name: "Bash",
@ -476,8 +486,8 @@ describe("formatToolHistory", () => {
},
];
const result = formatToolHistory(history);
expect(result).toContain("1. ✗ Bash — rm -rf /");
expect(result).toContain("2. ✓ Read — file.ts");
expect(result).toContain(":x: *Bash*");
expect(result).toContain(":white_check_mark: *Read*");
});
it("handles tools without hints", () => {
@ -487,7 +497,7 @@ describe("formatToolHistory", () => {
hint: "",
},
];
expect(formatToolHistory(history)).toBe("1. ✓ Bash");
expect(formatToolHistory(history)).toBe(":white_check_mark: *Bash*");
});
it("returns empty string for empty history", () => {
@ -687,3 +697,352 @@ describe("looksLikeHtml", () => {
expect(looksLikeHtml(buf)).toBe(false);
});
});
describe("SQLite state", () => {
it("openDb returns a working database", () => {
const db = openDb(":memory:");
expect(db).toBeTruthy();
db.close();
});
it("upsertThread and findThread round-trip", () => {
const db = openDb(":memory:");
upsertThread(db, {
channel: "C123",
threadTs: "1234.567",
sessionId: "sess-abc",
createdAt: new Date().toISOString(),
userId: "U456",
});
const found = findThread(db, "C123", "1234.567");
expect(found?.sessionId).toBe("sess-abc");
expect(found?.userId).toBe("U456");
db.close();
});
it("upsertThread is idempotent — updates session on conflict", () => {
const db = openDb(":memory:");
upsertThread(db, {
channel: "C123",
threadTs: "1234.567",
sessionId: "sess-v1",
createdAt: new Date().toISOString(),
});
upsertThread(db, {
channel: "C123",
threadTs: "1234.567",
sessionId: "sess-v2",
createdAt: new Date().toISOString(),
});
const found = findThread(db, "C123", "1234.567");
expect(found?.sessionId).toBe("sess-v2");
db.close();
});
it("findThread returns undefined for missing thread", () => {
const db = openDb(":memory:");
expect(findThread(db, "CNOPE", "0.0")).toBeUndefined();
db.close();
});
});
describe("parseInlineMarkdown", () => {
it("returns plain text element for plain text", () => {
const result = parseInlineMarkdown("hello world");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "text",
text: "hello world",
});
});
it("parses bold **text**", () => {
const result = parseInlineMarkdown("**bold**");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "text",
text: "bold",
style: {
bold: true,
},
});
});
it("parses inline code `code`", () => {
const result = parseInlineMarkdown("`code`");
expect(result[0]).toMatchObject({
type: "text",
text: "code",
style: {
code: true,
},
});
});
it("parses link [text](url)", () => {
const result = parseInlineMarkdown("[click](https://example.com)");
expect(result[0]).toMatchObject({
type: "link",
url: "https://example.com",
text: "click",
});
});
it("parses strikethrough ~~text~~", () => {
const result = parseInlineMarkdown("~~gone~~");
expect(result[0]).toMatchObject({
type: "text",
text: "gone",
style: {
strike: true,
},
});
});
it("parses italic *text*", () => {
const result = parseInlineMarkdown("*italic*");
expect(result[0]).toMatchObject({
type: "text",
text: "italic",
style: {
italic: true,
},
});
});
it("handles mixed inline elements", () => {
const result = parseInlineMarkdown("Hello **bold** and `code` world");
expect(result.length).toBeGreaterThan(2);
const boldEl = result.find(
(e) =>
typeof e === "object" &&
"style" in e &&
(e as Record<string, unknown>).style !== null &&
typeof (e as Record<string, unknown>).style === "object" &&
"bold" in ((e as Record<string, unknown>).style as object),
);
expect(boldEl).toBeTruthy();
});
it("returns empty array for empty string", () => {
expect(parseInlineMarkdown("")).toHaveLength(0);
});
});
describe("parseMarkdownBlock", () => {
it("produces rich_text_section for plain paragraph", () => {
const result = parseMarkdownBlock("Hello world");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "rich_text_section",
});
});
it("produces rich_text_list for bullet list", () => {
const result = parseMarkdownBlock("- item one\n- item two");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "rich_text_list",
style: "bullet",
});
const list = result[0] as {
elements: unknown[];
};
expect(list.elements).toHaveLength(2);
});
it("produces rich_text_list for ordered list", () => {
const result = parseMarkdownBlock("1. first\n2. second\n3. third");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "rich_text_list",
style: "ordered",
});
});
it("produces rich_text_quote for blockquote", () => {
const result = parseMarkdownBlock("> quoted text");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "rich_text_quote",
});
});
it("produces bold rich_text_section for ATX header", () => {
const result = parseMarkdownBlock("## My Header");
expect(result).toHaveLength(1);
const section = result[0] as {
type: string;
elements: Array<{
style?: {
bold?: boolean;
};
}>;
};
expect(section.type).toBe("rich_text_section");
expect(section.elements[0]?.style?.bold).toBe(true);
});
it("returns empty array for blank input", () => {
expect(parseMarkdownBlock("")).toHaveLength(0);
expect(parseMarkdownBlock(" ")).toHaveLength(0);
});
});
describe("markdownToRichTextBlocks", () => {
it("returns empty array for blank input", () => {
expect(markdownToRichTextBlocks("")).toHaveLength(0);
expect(markdownToRichTextBlocks(" ")).toHaveLength(0);
});
it("wraps plain text in a rich_text block", () => {
const result = markdownToRichTextBlocks("Hello world");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "rich_text",
});
});
it("splits fenced code blocks into separate rich_text blocks", () => {
const input = "Before\n```\nconst x = 1;\n```\nAfter";
const result = markdownToRichTextBlocks(input);
// Before text + code block + after text = 3 blocks
expect(result).toHaveLength(3);
// Second block should contain preformatted element
const codeBlock = result[1] as {
elements: Array<{
type: string;
}>;
};
expect(codeBlock.elements[0]?.type).toBe("rich_text_preformatted");
});
it("handles unclosed fenced code block (mid-stream)", () => {
const input = "Before\n```typescript\nconst x = 1;\n// more code";
const result = markdownToRichTextBlocks(input);
// Before text + unclosed code
expect(result.length).toBeGreaterThanOrEqual(1);
const hasPreformatted = result.some((b) => {
const block = b as {
elements?: Array<{
type: string;
}>;
};
return block.elements?.some((e) => e.type === "rich_text_preformatted");
});
expect(hasPreformatted).toBe(true);
});
it("handles multiple code blocks", () => {
const input = "First\n```\ncode1\n```\nMiddle\n```\ncode2\n```\nLast";
const result = markdownToRichTextBlocks(input);
expect(result.length).toBeGreaterThanOrEqual(4);
});
});
describe("plainTextFallback", () => {
it("strips fenced code blocks to [code]", () => {
const input = "Before\n```typescript\nconst x = 1;\n```\nAfter";
const result = plainTextFallback(input);
expect(result).toContain("[code]");
expect(result).not.toContain("const x");
expect(result).toContain("Before");
expect(result).toContain("After");
});
it("strips bold **text** markers", () => {
const result = plainTextFallback("**bold** text");
expect(result).toContain("bold text");
expect(result).not.toContain("**");
});
it("strips ATX headers", () => {
const result = plainTextFallback("## My Header");
expect(result).toContain("My Header");
expect(result).not.toContain("##");
});
it("converts [text](url) links to plain text", () => {
const result = plainTextFallback("[click here](https://example.com)");
expect(result).toContain("click here");
expect(result).not.toContain("https://example.com");
});
it("returns empty string for blank input", () => {
expect(plainTextFallback("")).toBe("");
expect(plainTextFallback(" ")).toBe("");
});
});
describe("extractMarkdownTables", () => {
it("extracts a simple markdown table", () => {
const input = "Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter";
const { clean, tables } = extractMarkdownTables(input);
expect(tables).toHaveLength(1);
expect(tables[0]).toContain("| A | B |");
expect(clean).toContain("Before");
expect(clean).toContain("After");
expect(clean).not.toContain("| A |");
});
it("returns clean text unchanged when no table present", () => {
const input = "Just some text\nno table here";
const { clean, tables } = extractMarkdownTables(input);
expect(tables).toHaveLength(0);
expect(clean).toContain("Just some text");
});
it("MARKDOWN_TABLE_RE resets lastIndex between uses", () => {
const input = "| X |\n|---|\n| Y |\n";
MARKDOWN_TABLE_RE.lastIndex = 0;
const m1 = input.match(MARKDOWN_TABLE_RE);
MARKDOWN_TABLE_RE.lastIndex = 0;
const m2 = input.match(MARKDOWN_TABLE_RE);
expect(m1).toEqual(m2);
});
});
describe("markdownTableToSlackBlock", () => {
it("converts a simple table to Slack block format", () => {
const table = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |";
const block = markdownTableToSlackBlock(table) as {
type: string;
rows: Array<
Array<{
type: string;
text: string;
}>
>;
} | null;
expect(block).not.toBeNull();
expect(block?.type).toBe("table");
expect(block?.rows).toHaveLength(3); // header + 2 data rows
expect(block?.rows[0][0].text).toBe("Name");
expect(block?.rows[0][1].text).toBe("Age");
expect(block?.rows[1][0].text).toBe("Alice");
});
it("returns null for empty input", () => {
expect(markdownTableToSlackBlock("")).toBeNull();
expect(markdownTableToSlackBlock(" ")).toBeNull();
});
it("returns null for separator-only row", () => {
expect(markdownTableToSlackBlock("|---|---|")).toBeNull();
});
it("pads short rows to consistent column count", () => {
const table = "| A | B | C |\n|---|---|---|\n| x |";
const block = markdownTableToSlackBlock(table) as {
rows: Array<
Array<{
text: string;
}>
>;
} | null;
// Data row should be padded to 3 columns
expect(block?.rows[1]).toHaveLength(3);
expect(block?.rows[1][1].text).toBe("");
expect(block?.rows[1][2].text).toBe("");
});
});

View file

@ -14,13 +14,15 @@
"name": "spawn-slack-bot",
"dependencies": {
"@slack/bolt": "4.6.0",
"@slack/types": "^2.14.0",
"@slack/web-api": "^7.14.1",
"slackify-markdown": "^5.0.0",
"valibot": "1.2.0",
},
},
"packages/cli": {
"name": "@openrouter/spawn",
"version": "0.15.3",
"version": "0.15.27",
"bin": {
"spawn": "cli.js",
},