From a1413a011e867c0ee47d1a0261a1d4f410357e2e Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sat, 24 Jan 2026 02:00:51 +0800 Subject: [PATCH 01/24] feat(telegram): convert markdown tables to bullet points (#1495) Tables render poorly in Telegram (pipes stripped, whitespace collapses). This adds a 'tableMode' option to markdownToIR that converts tables to nested bullet points, which render cleanly on mobile. - Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions - Track table state during token rendering - Render tables as bullet points with first column as row labels - Apply bold styling to row labels for visual hierarchy - Enable tableMode: 'bullets' for Telegram formatter Closes #TBD --- src/markdown/ir.table-bullets.test.ts | 91 ++++++++++++ src/markdown/ir.ts | 194 ++++++++++++++++++++++++-- src/telegram/format.ts | 2 + 3 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 src/markdown/ir.table-bullets.test.ts diff --git a/src/markdown/ir.table-bullets.test.ts b/src/markdown/ir.table-bullets.test.ts new file mode 100644 index 000000000..841c922fe --- /dev/null +++ b/src/markdown/ir.table-bullets.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { markdownToIR } from "./ir.js"; + +describe("markdownToIR tableMode bullets", () => { + it("converts simple table to bullets", () => { + const md = ` +| Name | Value | +|------|-------| +| A | 1 | +| B | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should contain bullet points with header:value format + expect(ir.text).toContain("• Value: 1"); + expect(ir.text).toContain("• Value: 2"); + // Should use first column as labels + expect(ir.text).toContain("A"); + expect(ir.text).toContain("B"); + }); + + it("handles table with multiple columns", () => { + const md = ` +| Feature | SQLite | Postgres | +|---------|--------|----------| +| Speed | Fast | Medium | +| Scale | Small | Large | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // First column becomes row label + expect(ir.text).toContain("Speed"); + expect(ir.text).toContain("Scale"); + // Other columns become bullet points + expect(ir.text).toContain("• SQLite: Fast"); + expect(ir.text).toContain("• Postgres: Medium"); + expect(ir.text).toContain("• SQLite: Small"); + expect(ir.text).toContain("• Postgres: Large"); + }); + + it("preserves flat mode as default", () => { + const md = ` +| A | B | +|---|---| +| 1 | 2 | +`.trim(); + + const ir = markdownToIR(md); // default is flat + + // Flat mode uses tabs + expect(ir.text).toContain("A"); + expect(ir.text).toContain("B"); + expect(ir.text).toContain("1"); + expect(ir.text).toContain("2"); + // Should not have bullet formatting + expect(ir.text).not.toContain("•"); + }); + + it("handles empty cells gracefully", () => { + const md = ` +| Name | Value | +|------|-------| +| A | | +| B | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should handle empty cell without crashing + expect(ir.text).toContain("B"); + expect(ir.text).toContain("• Value: 2"); + }); + + it("bolds row labels in bullets mode", () => { + const md = ` +| Name | Value | +|------|-------| +| Row1 | Data1 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should have bold style for row label + const hasRowLabelBold = ir.styles.some( + (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1" + ); + expect(hasRowLabelBold).toBe(true); + }); +}); diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index c823381d8..5351fa32c 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -12,6 +12,21 @@ type LinkState = { labelStart: number; }; +type TableCell = { + content: string; + isHeader: boolean; +}; + +type TableRow = TableCell[]; + +type TableState = { + headers: string[]; + rows: TableRow[]; + currentRow: TableCell[]; + currentCell: string; + inHeader: boolean; +}; + type RenderEnv = { listStack: ListState[]; linkStack: LinkState[]; @@ -50,6 +65,8 @@ type OpenStyle = { start: number; }; +export type TableRenderMode = "flat" | "bullets"; + type RenderState = { text: string; styles: MarkdownStyleSpan[]; @@ -59,6 +76,8 @@ type RenderState = { headingStyle: "none" | "bold"; blockquotePrefix: string; enableSpoilers: boolean; + tableMode: TableRenderMode; + table: TableState | null; }; export type MarkdownParseOptions = { @@ -67,6 +86,8 @@ export type MarkdownParseOptions = { headingStyle?: "none" | "bold"; blockquotePrefix?: string; autolink?: boolean; + /** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */ + tableMode?: TableRenderMode; }; function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { @@ -77,6 +98,7 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { typographer: false, }); md.enable("strikethrough"); + md.enable("table"); if (options.autolink === false) { md.disable("autolink"); } @@ -146,6 +168,11 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { function appendText(state: RenderState, value: string) { if (!value) return; + // If we're inside a table cell in bullets mode, collect into cell buffer + if (state.table && state.tableMode === "bullets") { + state.table.currentCell += value; + return; + } state.text += value; } @@ -169,7 +196,8 @@ function closeStyle(state: RenderState, style: MarkdownStyle) { function appendParagraphSeparator(state: RenderState) { if (state.env.listStack.length > 0) return; - appendText(state, "\n\n"); + if (state.table) return; // Don't add paragraph separators inside tables + state.text += "\n\n"; } function appendListPrefix(state: RenderState) { @@ -179,13 +207,18 @@ function appendListPrefix(state: RenderState) { top.index += 1; const indent = " ".repeat(Math.max(0, stack.length - 1)); const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; - appendText(state, `${indent}${prefix}`); + state.text += `${indent}${prefix}`; } function renderInlineCode(state: RenderState, content: string) { if (!content) return; + // In bullets mode inside table, just add text without styling + if (state.table && state.tableMode === "bullets") { + state.table.currentCell += content; + return; + } const start = state.text.length; - appendText(state, content); + state.text += content; state.styles.push({ start, end: start + content.length, style: "code" }); } @@ -193,10 +226,10 @@ function renderCodeBlock(state: RenderState, content: string) { let code = content ?? ""; if (!code.endsWith("\n")) code = `${code}\n`; const start = state.text.length; - appendText(state, code); + state.text += code; state.styles.push({ start, end: start + code.length, style: "code_block" }); if (state.env.listStack.length === 0) { - appendText(state, "\n"); + state.text += "\n"; } } @@ -214,6 +247,89 @@ function handleLinkClose(state: RenderState) { state.links.push({ start, end, href }); } +function initTableState(): TableState { + return { + headers: [], + rows: [], + currentRow: [], + currentCell: "", + inHeader: false, + }; +} + +function renderTableAsBullets(state: RenderState) { + if (!state.table) return; + const { headers, rows } = state.table; + + // If no headers or rows, skip + if (headers.length === 0 && rows.length === 0) return; + + // Determine if first column should be used as row labels + // (common pattern: first column is category/feature name) + const useFirstColAsLabel = headers.length > 1 && rows.length > 0; + + if (useFirstColAsLabel) { + // Format: each row becomes a section with header as row[0], then key:value pairs + for (const row of rows) { + if (row.length === 0) continue; + + const rowLabel = row[0]?.content?.trim() || ""; + if (rowLabel) { + // Bold the row label + const start = state.text.length; + state.text += rowLabel; + state.styles.push({ start, end: state.text.length, style: "bold" }); + state.text += "\n"; + } + + // Add each column as a bullet point + for (let i = 1; i < row.length; i++) { + const header = headers[i]?.trim() || `Column ${i}`; + const value = row[i]?.content?.trim() || ""; + if (value) { + state.text += `• ${header}: ${value}\n`; + } + } + state.text += "\n"; + } + } else { + // Simple table: just list headers and values + for (const row of rows) { + for (let i = 0; i < row.length; i++) { + const header = headers[i]?.trim() || ""; + const value = row[i]?.content?.trim() || ""; + if (header && value) { + state.text += `• ${header}: ${value}\n`; + } else if (value) { + state.text += `• ${value}\n`; + } + } + state.text += "\n"; + } + } +} + +function renderTableAsFlat(state: RenderState) { + if (!state.table) return; + const { headers, rows } = state.table; + + // Render headers + for (const header of headers) { + state.text += header.trim() + "\t"; + } + if (headers.length > 0) { + state.text = state.text.trimEnd() + "\n"; + } + + // Render rows + for (const row of rows) { + for (const cell of row) { + state.text += cell.content.trim() + "\t"; + } + state.text = state.text.trimEnd() + "\n"; + } +} + function renderTokens(tokens: MarkdownToken[], state: RenderState): void { for (const token of tokens) { switch (token.type) { @@ -276,10 +392,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { appendParagraphSeparator(state); break; case "blockquote_open": - if (state.blockquotePrefix) appendText(state, state.blockquotePrefix); + if (state.blockquotePrefix) state.text += state.blockquotePrefix; break; case "blockquote_close": - appendText(state, "\n"); + state.text += "\n"; break; case "bullet_list_open": state.env.listStack.push({ type: "bullet", index: 0 }); @@ -299,7 +415,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { appendListPrefix(state); break; case "list_item_close": - appendText(state, "\n"); + state.text += "\n"; break; case "code_block": case "fence": @@ -309,22 +425,74 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "html_inline": appendText(state, token.content ?? ""); break; + + // Table handling case "table_open": + if (state.tableMode === "bullets") { + state.table = initTableState(); + } + break; case "table_close": + if (state.tableMode === "bullets" && state.table) { + renderTableAsBullets(state); + } else if (state.tableMode === "flat" && state.table) { + renderTableAsFlat(state); + } + state.table = null; + break; case "thead_open": + if (state.table) { + state.table.inHeader = true; + } + break; case "thead_close": + if (state.table) { + state.table.inHeader = false; + } + break; case "tbody_open": case "tbody_close": break; + case "tr_open": + if (state.table) { + state.table.currentRow = []; + } + break; case "tr_close": - appendText(state, "\n"); + if (state.table) { + if (state.table.inHeader) { + state.table.headers = state.table.currentRow.map((c) => c.content); + } else { + state.table.rows.push(state.table.currentRow); + } + state.table.currentRow = []; + } else if (state.tableMode === "flat") { + // Legacy flat mode without table state + state.text += "\n"; + } + break; + case "th_open": + case "td_open": + if (state.table) { + state.table.currentCell = ""; + } break; case "th_close": case "td_close": - appendText(state, "\t"); + if (state.table) { + state.table.currentRow.push({ + content: state.table.currentCell, + isHeader: token.type === "th_close", + }); + state.table.currentCell = ""; + } else if (state.tableMode === "flat") { + // Legacy flat mode without table state + state.text += "\t"; + } break; + case "hr": - appendText(state, "\n"); + state.text += "\n"; break; default: if (token.children) renderTokens(token.children, state); @@ -433,6 +601,8 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = { applySpoilerTokens(tokens as MarkdownToken[]); } + const tableMode = options.tableMode ?? "flat"; + const state: RenderState = { text: "", styles: [], @@ -442,6 +612,8 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = { headingStyle: options.headingStyle ?? "none", blockquotePrefix: options.blockquotePrefix ?? "", enableSpoilers: options.enableSpoilers ?? false, + tableMode, + table: null, }; renderTokens(tokens as MarkdownToken[], state); diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 7894d67f0..8b08a35f0 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -51,6 +51,7 @@ export function markdownToTelegramHtml(markdown: string): string { linkify: true, headingStyle: "none", blockquotePrefix: "", + tableMode: "bullets", }); return renderTelegramHtml(ir); } @@ -63,6 +64,7 @@ export function markdownToTelegramChunks( linkify: true, headingStyle: "none", blockquotePrefix: "", + tableMode: "bullets", }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => ({ From fdc50a0feb9fd67cb1edc5035356f90b5f84cfdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:30:11 +0000 Subject: [PATCH 02/24] fix: normalize session lock path --- src/agents/session-write-lock.test.ts | 34 +++++++++++++++++++++++++++ src/agents/session-write-lock.ts | 26 +++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 src/agents/session-write-lock.test.ts diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts new file mode 100644 index 000000000..8f93bface --- /dev/null +++ b/src/agents/session-write-lock.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { acquireSessionWriteLock } from "./session-write-lock.js"; + +describe("acquireSessionWriteLock", () => { + it("reuses locks across symlinked session paths", async () => { + if (process.platform === "win32") { + expect(true).toBe(true); + return; + } + + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + try { + const realDir = path.join(root, "real"); + const linkDir = path.join(root, "link"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, linkDir); + + const sessionReal = path.join(realDir, "sessions.json"); + const sessionLink = path.join(linkDir, "sessions.json"); + + const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 }); + const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 }); + + await lockB.release(); + await lockA.release(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 99478c2cd..54e61d965 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -45,20 +45,28 @@ export async function acquireSessionWriteLock(params: { }> { const timeoutMs = params.timeoutMs ?? 10_000; const staleMs = params.staleMs ?? 30 * 60 * 1000; - const sessionFile = params.sessionFile; - const lockPath = `${sessionFile}.lock`; - await fs.mkdir(path.dirname(lockPath), { recursive: true }); + const sessionFile = path.resolve(params.sessionFile); + const sessionDir = path.dirname(sessionFile); + await fs.mkdir(sessionDir, { recursive: true }); + let normalizedDir = sessionDir; + try { + normalizedDir = await fs.realpath(sessionDir); + } catch { + // Fall back to the resolved path if realpath fails (permissions, transient FS). + } + const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile)); + const lockPath = `${normalizedSessionFile}.lock`; - const held = HELD_LOCKS.get(sessionFile); + const held = HELD_LOCKS.get(normalizedSessionFile); if (held) { held.count += 1; return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); }, @@ -75,14 +83,14 @@ export async function acquireSessionWriteLock(params: { JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), "utf8", ); - HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath }); + HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath }); return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); }, From 29353e2e81abbb5fff65b15feb5d95a17421ef20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:31:33 +0000 Subject: [PATCH 03/24] test: speed up default test env --- src/auto-reply/reply/get-reply.ts | 17 ++++++++++------- test/global-setup.ts | 6 ++++++ test/setup.ts | 5 ----- test/test-env.ts | 2 ++ vitest.config.ts | 2 ++ vitest.e2e.config.ts | 3 ++- 6 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 test/global-setup.ts diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index e9903fdf1..20887c340 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -28,6 +28,7 @@ export async function getReplyFromConfig( opts?: GetReplyOptions, configOverride?: ClawdbotConfig, ): Promise { + const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; const cfg = configOverride ?? loadConfig(); const targetSessionKey = ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined; @@ -62,7 +63,7 @@ export async function getReplyFromConfig( const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: !agentCfg?.skipBootstrap, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); @@ -81,12 +82,14 @@ export async function getReplyFromConfig( const finalized = finalizeInboundContext(ctx); - await applyMediaUnderstanding({ - ctx: finalized, - cfg, - agentDir, - activeModel: { provider, model }, - }); + if (!isFastTestEnv) { + await applyMediaUnderstanding({ + ctx: finalized, + cfg, + agentDir, + activeModel: { provider, model }, + }); + } const commandAuthorized = finalized.CommandAuthorized; resolveCommandAuthorization({ diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 000000000..289fd877b --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,6 @@ +import { installTestEnv } from "./test-env"; + +export default async () => { + const { cleanup } = installTestEnv(); + return () => cleanup(); +}; diff --git a/test/setup.ts b/test/setup.ts index 6c532b0c2..971fa4731 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -9,11 +9,6 @@ import type { ClawdbotConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { setActivePluginRegistry } from "../src/plugins/runtime.js"; import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; -import { installTestEnv } from "./test-env"; - -const { cleanup } = installTestEnv(); -process.on("exit", cleanup); - const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": diff --git a/test/test-env.ts b/test/test-env.ts index deda32178..815fe93d7 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -54,6 +54,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { } const restore: RestoreEntry[] = [ + { key: "CLAWDBOT_TEST_FAST", value: process.env.CLAWDBOT_TEST_FAST }, { key: "HOME", value: process.env.HOME }, { key: "USERPROFILE", value: process.env.USERPROFILE }, { key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME }, @@ -84,6 +85,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; process.env.CLAWDBOT_TEST_HOME = tempHome; + process.env.CLAWDBOT_TEST_FAST = "1"; // Ensure test runs never touch the developer's real config/state, even if they have overrides set. delete process.env.CLAWDBOT_CONFIG_PATH; diff --git a/vitest.config.ts b/vitest.config.ts index 6628e33f8..8a783236c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "test/format-error.test.ts", ], setupFiles: ["test/setup.ts"], + globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**", @@ -34,6 +35,7 @@ export default defineConfig({ "**/vendor/**", "dist/Clawdbot.app/**", "**/*.live.test.ts", + "**/*.e2e.test.ts", ], coverage: { provider: "v8", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 3531e7fe5..a33d324bd 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -9,8 +9,9 @@ export default defineConfig({ test: { pool: "forks", maxWorkers: e2eWorkers, - include: ["test/**/*.e2e.test.ts"], + include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts"], setupFiles: ["test/setup.ts"], + globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**", From c9d73469c3534e58106995936c557b1904c780d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:31:47 +0000 Subject: [PATCH 04/24] test: stub heavy tools in agent tests --- src/agents/clawdbot-gateway-tool.test.ts | 1 + src/agents/clawdbot-tools.agents.test.ts | 1 + src/agents/clawdbot-tools.camera.test.ts | 1 + .../clawdbot-tools.session-status.test.ts | 1 + src/agents/clawdbot-tools.sessions.test.ts | 4 +-- ...ws-cross-agent-spawning-configured.test.ts | 1 + ...ounces-agent-wait-lifecycle-events.test.ts | 1 + ...-spawn-applies-model-child-session.test.ts | 1 + ...n-normalizes-allowlisted-agent-ids.test.ts | 1 + ...n-prefers-per-agent-subagent-model.test.ts | 1 + ...resolves-main-announce-target-from.test.ts | 1 + ...aliases-schemas-without-dropping-b.test.ts | 1 + ...aliases-schemas-without-dropping-d.test.ts | 1 + ...aliases-schemas-without-dropping-f.test.ts | 1 + ...e-aliases-schemas-without-dropping.test.ts | 1 + src/agents/test-helpers/fast-coding-tools.ts | 22 ++++++++++++++ src/agents/test-helpers/fast-core-tools.ts | 30 +++++++++++++++++++ 17 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/agents/test-helpers/fast-coding-tools.ts create mode 100644 src/agents/test-helpers/fast-core-tools.ts diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index a3dfa8309..0a283198c 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; vi.mock("./tools/gateway.js", () => ({ diff --git a/src/agents/clawdbot-tools.agents.test.ts b/src/agents/clawdbot-tools.agents.test.ts index 5936c196c..0ae300bfb 100644 --- a/src/agents/clawdbot-tools.agents.test.ts +++ b/src/agents/clawdbot-tools.agents.test.ts @@ -16,6 +16,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; describe("agents_list", () => { diff --git a/src/agents/clawdbot-tools.camera.test.ts b/src/agents/clawdbot-tools.camera.test.ts index 4347bacfa..c652e60d3 100644 --- a/src/agents/clawdbot-tools.camera.test.ts +++ b/src/agents/clawdbot-tools.camera.test.ts @@ -10,6 +10,7 @@ vi.mock("../media/image-ops.js", () => ({ resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")), })); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; describe("nodes camera_snap", () => { diff --git a/src/agents/clawdbot-tools.session-status.test.ts b/src/agents/clawdbot-tools.session-status.test.ts index c361f59d6..94ee3e8b4 100644 --- a/src/agents/clawdbot-tools.session-status.test.ts +++ b/src/agents/clawdbot-tools.session-status.test.ts @@ -75,6 +75,7 @@ vi.mock("../infra/provider-usage.js", () => ({ formatUsageSummaryLine: () => null, })); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; describe("session_status tool", () => { diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index bf57c73cf..c7964b75b 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -4,9 +4,6 @@ const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -vi.mock("../plugins/tools.js", () => ({ - resolvePluginTools: () => [], -})); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -23,6 +20,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => { diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts index 3733348d9..740e987ea 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts @@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts index 814e021d8..27aff8c47 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts @@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts index 2eea23bf0..f9bd6a499 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts @@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts index b1b5b413b..3dbfb02b4 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts @@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }); import { emitAgentEvent } from "../infra/agent-events.js"; +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts index c1afd211b..653384675 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts @@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts index 8a094fb6d..18f5ab26b 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts @@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }); import { emitAgentEvent } from "../infra/agent-events.js"; +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts index e440ecaeb..e332c13eb 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; +import "./test-helpers/fast-coding-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; const defaultTools = createClawdbotCodingTools(); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index f493164cd..bd13aa25c 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { describe, expect, it } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; const defaultTools = createClawdbotCodingTools(); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts index 35549a4d3..ed557b922 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js"; describe("createClawdbotCodingTools", () => { diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 8cb3a3522..221222338 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { describe, expect, it, vi } from "vitest"; +import "./test-helpers/fast-coding-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { createSandboxedReadTool } from "./pi-tools.read.js"; diff --git a/src/agents/test-helpers/fast-coding-tools.ts b/src/agents/test-helpers/fast-coding-tools.ts new file mode 100644 index 000000000..99b4ab351 --- /dev/null +++ b/src/agents/test-helpers/fast-coding-tools.ts @@ -0,0 +1,22 @@ +import { vi } from "vitest"; + +const stubTool = (name: string) => ({ + name, + description: `${name} stub`, + parameters: { type: "object", properties: {} }, + execute: vi.fn(), +}); + +vi.mock("../tools/image-tool.js", () => ({ + createImageTool: () => stubTool("image"), +})); + +vi.mock("../tools/web-tools.js", () => ({ + createWebSearchTool: () => null, + createWebFetchTool: () => null, +})); + +vi.mock("../../plugins/tools.js", () => ({ + resolvePluginTools: () => [], + getPluginToolMeta: () => undefined, +})); diff --git a/src/agents/test-helpers/fast-core-tools.ts b/src/agents/test-helpers/fast-core-tools.ts new file mode 100644 index 000000000..d459c8276 --- /dev/null +++ b/src/agents/test-helpers/fast-core-tools.ts @@ -0,0 +1,30 @@ +import { vi } from "vitest"; + +const stubTool = (name: string) => ({ + name, + description: `${name} stub`, + parameters: { type: "object", properties: {} }, + execute: vi.fn(), +}); + +vi.mock("../tools/browser-tool.js", () => ({ + createBrowserTool: () => stubTool("browser"), +})); + +vi.mock("../tools/canvas-tool.js", () => ({ + createCanvasTool: () => stubTool("canvas"), +})); + +vi.mock("../tools/image-tool.js", () => ({ + createImageTool: () => stubTool("image"), +})); + +vi.mock("../tools/web-tools.js", () => ({ + createWebSearchTool: () => null, + createWebFetchTool: () => null, +})); + +vi.mock("../../plugins/tools.js", () => ({ + resolvePluginTools: () => [], + getPluginToolMeta: () => undefined, +})); From 6d2a1ce217f390d0760a1cf169ed92fe4744473e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:31:53 +0000 Subject: [PATCH 05/24] test: trim async waits in webhook tests --- extensions/bluebubbles/src/monitor.test.ts | 78 ++++++++++++---------- src/media/server.test.ts | 16 ++++- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 0dcccbef8..96e85e84b 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -220,6 +220,12 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb return res; } +const flushAsync = async () => { + for (let i = 0; i < 2; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + describe("BlueBubbles webhook monitor", () => { let unregister: () => void; @@ -506,7 +512,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(resolveChatGuidForTarget).toHaveBeenCalledWith( expect.objectContaining({ @@ -554,7 +560,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); expect(sendMessageBlueBubbles).toHaveBeenCalledWith( @@ -601,7 +607,7 @@ describe("BlueBubbles webhook monitor", () => { await handleBlueBubblesWebhookRequest(req, res); // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); @@ -640,7 +646,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); @@ -681,7 +687,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); @@ -724,7 +730,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); // Should not send pairing reply since created=false @@ -765,7 +771,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -802,7 +808,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -842,7 +848,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -880,7 +886,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -919,7 +925,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -958,7 +964,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -999,7 +1005,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1040,7 +1046,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -1078,7 +1084,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -1121,7 +1127,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1167,7 +1173,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1213,7 +1219,7 @@ describe("BlueBubbles webhook monitor", () => { const originalRes = createMockResponse(); await handleBlueBubblesWebhookRequest(originalReq, originalRes); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Only assert the reply message behavior below. mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); @@ -1237,7 +1243,7 @@ describe("BlueBubbles webhook monitor", () => { const replyRes = createMockResponse(); await handleBlueBubblesWebhookRequest(replyReq, replyRes); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1283,7 +1289,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1331,7 +1337,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( expect.objectContaining({ @@ -1384,7 +1390,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Should process even without mention because it's an authorized control command expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); @@ -1427,7 +1433,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -1470,7 +1476,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(markBlueBubblesChatRead).toHaveBeenCalled(); }); @@ -1511,7 +1517,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); }); @@ -1554,7 +1560,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Should call typing start when reply flow triggers it. expect(sendBlueBubblesTyping).toHaveBeenCalledWith( @@ -1604,7 +1610,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), @@ -1649,7 +1655,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), @@ -1697,7 +1703,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( @@ -1742,7 +1748,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("reaction added"), @@ -1782,7 +1788,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("reaction removed"), @@ -1822,7 +1828,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); @@ -1860,7 +1866,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("👍"), @@ -1901,7 +1907,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1941,7 +1947,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // The short ID "1" should resolve back to the full UUID expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345"); @@ -1993,7 +1999,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); diff --git a/src/media/server.test.ts b/src/media/server.test.ts index 875088cbf..693ba5940 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -14,6 +14,19 @@ vi.mock("./store.js", () => ({ const { startMediaServer } = await import("./server.js"); +const waitForFileRemoval = async (file: string, timeoutMs = 200) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fs.stat(file); + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`timed out waiting for ${file} removal`); +}; + describe("media server", () => { beforeAll(async () => { await fs.rm(MEDIA_DIR, { recursive: true, force: true }); @@ -32,8 +45,7 @@ describe("media server", () => { const res = await fetch(`http://localhost:${port}/media/file1`); expect(res.status).toBe(200); expect(await res.text()).toBe("hello"); - await new Promise((r) => setTimeout(r, 600)); - await expect(fs.stat(file)).rejects.toThrow(); + await waitForFileRemoval(file); await new Promise((r) => server.close(r)); }); From ace6a42ea617bdbe3adabd6e4a3316d12eca3ff0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:31:56 +0000 Subject: [PATCH 06/24] test: dedupe CLI onboard auth cases --- src/cli/program.smoke.test.ts | 180 ++++++++++------------------------ 1 file changed, 50 insertions(+), 130 deletions(-) diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 10ebc9188..3dc01fcb2 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -122,142 +122,62 @@ describe("cli program (smoke)", () => { expect(setupCommand).not.toHaveBeenCalled(); }); - it("passes opencode-zen api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "opencode-zen", - "--opencode-zen-api-key", - "sk-opencode-zen-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + it("passes auth api keys to onboard", async () => { + const cases = [ + { authChoice: "opencode-zen", - opencodeZenApiKey: "sk-opencode-zen-test", - }), - runtime, - ); - }); - - it("passes openrouter api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "openrouter-api-key", - "--openrouter-api-key", - "sk-openrouter-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + flag: "--opencode-zen-api-key", + key: "sk-opencode-zen-test", + field: "opencodeZenApiKey", + }, + { authChoice: "openrouter-api-key", - openrouterApiKey: "sk-openrouter-test", - }), - runtime, - ); - }); - - it("passes moonshot api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "moonshot-api-key", - "--moonshot-api-key", - "sk-moonshot-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + flag: "--openrouter-api-key", + key: "sk-openrouter-test", + field: "openrouterApiKey", + }, + { authChoice: "moonshot-api-key", - moonshotApiKey: "sk-moonshot-test", - }), - runtime, - ); - }); - - it("passes kimi code api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "kimi-code-api-key", - "--kimi-code-api-key", - "sk-kimi-code-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + flag: "--moonshot-api-key", + key: "sk-moonshot-test", + field: "moonshotApiKey", + }, + { authChoice: "kimi-code-api-key", - kimiCodeApiKey: "sk-kimi-code-test", - }), - runtime, - ); - }); - - it("passes synthetic api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "synthetic-api-key", - "--synthetic-api-key", - "sk-synthetic-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + flag: "--kimi-code-api-key", + key: "sk-kimi-code-test", + field: "kimiCodeApiKey", + }, + { authChoice: "synthetic-api-key", - syntheticApiKey: "sk-synthetic-test", - }), - runtime, - ); - }); - - it("passes zai api key to onboard", async () => { - const program = buildProgram(); - await program.parseAsync( - [ - "onboard", - "--non-interactive", - "--auth-choice", - "zai-api-key", - "--zai-api-key", - "sk-zai-test", - ], - { from: "user" }, - ); - expect(onboardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - nonInteractive: true, + flag: "--synthetic-api-key", + key: "sk-synthetic-test", + field: "syntheticApiKey", + }, + { authChoice: "zai-api-key", - zaiApiKey: "sk-zai-test", - }), - runtime, - ); + flag: "--zai-api-key", + key: "sk-zai-test", + field: "zaiApiKey", + }, + ] as const; + + for (const entry of cases) { + const program = buildProgram(); + await program.parseAsync( + ["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key], + { from: "user" }, + ); + expect(onboardCommand).toHaveBeenCalledWith( + expect.objectContaining({ + nonInteractive: true, + authChoice: entry.authChoice, + [entry.field]: entry.key, + }), + runtime, + ); + onboardCommand.mockClear(); + } }); it("runs channels login", async () => { From 0d336272f97a0ae5df2cbe44fad44cd3ffea2263 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:32:32 +0000 Subject: [PATCH 07/24] test: consolidate auto-reply unit coverage --- src/auto-reply/command-auth.test.ts | 122 ------ ...ection.test.ts => command-control.test.ts} | 125 +++++- src/auto-reply/commands-registry.args.test.ts | 156 ------- src/auto-reply/commands-registry.test.ts | 152 +++++++ src/auto-reply/inbound-debounce.test.ts | 48 --- src/auto-reply/inbound.test.ts | 402 ++++++++++++++++++ ...bound-media-into-sandbox-workspace.test.ts | 179 ++------ src/auto-reply/reply/audio-tags.test.ts | 25 -- .../reply/block-reply-coalescer.test.ts | 71 ---- .../reply/commands-allowlist.test.ts | 139 ------ .../reply/commands-config-writes.test.ts | 57 --- src/auto-reply/reply/commands-parsing.test.ts | 125 ++++++ ...models.test.ts => commands-policy.test.ts} | 102 ++++- .../reply/commands-subagents.test.ts | 23 - src/auto-reply/reply/config-commands.test.ts | 30 -- src/auto-reply/reply/debug-commands.test.ts | 21 - .../directive-handling.model.chat-ux.test.ts | 62 --- ...st.ts => directive-handling.model.test.ts} | 56 ++- .../reply/followup-runner.compaction.test.ts | 105 ----- ...-tools.test.ts => followup-runner.test.ts} | 77 ++++ src/auto-reply/reply/formatting.test.ts | 185 ++++++++ src/auto-reply/reply/groups.test.ts | 61 --- src/auto-reply/reply/inbound-context.test.ts | 38 -- src/auto-reply/reply/inbound-dedupe.test.ts | 66 --- .../reply/inbound-sender-meta.test.ts | 56 --- src/auto-reply/reply/inbound-text.test.ts | 18 - src/auto-reply/reply/mentions.test.ts | 47 -- src/auto-reply/reply/reply-reference.test.ts | 56 --- ...spatcher.test.ts => reply-routing.test.ts} | 96 +++++ src/auto-reply/reply/reply-threading.test.ts | 97 ----- .../reply/session-reset-model.test.ts | 107 ----- ...t-group.test.ts => session-resets.test.ts} | 144 ++++++- src/auto-reply/reply/session-updates.test.ts | 31 -- .../reply/session.sender-meta.test.ts | 51 --- .../reply/streaming-directives.test.ts | 37 -- src/auto-reply/reply/typing-mode.test.ts | 193 --------- src/auto-reply/reply/typing.test.ts | 191 +++++++++ src/auto-reply/templating.test.ts | 32 -- 38 files changed, 1680 insertions(+), 1903 deletions(-) delete mode 100644 src/auto-reply/command-auth.test.ts rename src/auto-reply/{command-detection.test.ts => command-control.test.ts} (56%) delete mode 100644 src/auto-reply/commands-registry.args.test.ts delete mode 100644 src/auto-reply/inbound-debounce.test.ts create mode 100644 src/auto-reply/inbound.test.ts delete mode 100644 src/auto-reply/reply/audio-tags.test.ts delete mode 100644 src/auto-reply/reply/block-reply-coalescer.test.ts delete mode 100644 src/auto-reply/reply/commands-allowlist.test.ts delete mode 100644 src/auto-reply/reply/commands-config-writes.test.ts create mode 100644 src/auto-reply/reply/commands-parsing.test.ts rename src/auto-reply/reply/{commands-models.test.ts => commands-policy.test.ts} (59%) delete mode 100644 src/auto-reply/reply/commands-subagents.test.ts delete mode 100644 src/auto-reply/reply/config-commands.test.ts delete mode 100644 src/auto-reply/reply/debug-commands.test.ts delete mode 100644 src/auto-reply/reply/directive-handling.model.chat-ux.test.ts rename src/auto-reply/reply/{directive-handling.impl.model-persist.test.ts => directive-handling.model.test.ts} (66%) delete mode 100644 src/auto-reply/reply/followup-runner.compaction.test.ts rename src/auto-reply/reply/{followup-runner.messaging-tools.test.ts => followup-runner.test.ts} (60%) create mode 100644 src/auto-reply/reply/formatting.test.ts delete mode 100644 src/auto-reply/reply/groups.test.ts delete mode 100644 src/auto-reply/reply/inbound-context.test.ts delete mode 100644 src/auto-reply/reply/inbound-dedupe.test.ts delete mode 100644 src/auto-reply/reply/inbound-sender-meta.test.ts delete mode 100644 src/auto-reply/reply/inbound-text.test.ts delete mode 100644 src/auto-reply/reply/mentions.test.ts delete mode 100644 src/auto-reply/reply/reply-reference.test.ts rename src/auto-reply/reply/{reply-dispatcher.test.ts => reply-routing.test.ts} (60%) delete mode 100644 src/auto-reply/reply/reply-threading.test.ts delete mode 100644 src/auto-reply/reply/session-reset-model.test.ts rename src/auto-reply/reply/{session-reset-group.test.ts => session-resets.test.ts} (62%) delete mode 100644 src/auto-reply/reply/session-updates.test.ts delete mode 100644 src/auto-reply/reply/session.sender-meta.test.ts delete mode 100644 src/auto-reply/reply/streaming-directives.test.ts delete mode 100644 src/auto-reply/reply/typing-mode.test.ts delete mode 100644 src/auto-reply/templating.test.ts diff --git a/src/auto-reply/command-auth.test.ts b/src/auto-reply/command-auth.test.ts deleted file mode 100644 index 0b6cf0826..000000000 --- a/src/auto-reply/command-auth.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../config/config.js"; -import { resolveCommandAuthorization } from "./command-auth.js"; -import type { MsgContext } from "./templating.js"; - -describe("resolveCommandAuthorization", () => { - it("falls back from empty SenderId to SenderE164", () => { - const cfg = { - channels: { whatsapp: { allowFrom: ["+123"] } }, - } as ClawdbotConfig; - - const ctx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:+999", - SenderId: "", - SenderE164: "+123", - } as MsgContext; - - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: true, - }); - - expect(auth.senderId).toBe("+123"); - expect(auth.isAuthorizedSender).toBe(true); - }); - - it("falls back from whitespace SenderId to SenderE164", () => { - const cfg = { - channels: { whatsapp: { allowFrom: ["+123"] } }, - } as ClawdbotConfig; - - const ctx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:+999", - SenderId: " ", - SenderE164: "+123", - } as MsgContext; - - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: true, - }); - - expect(auth.senderId).toBe("+123"); - expect(auth.isAuthorizedSender).toBe(true); - }); - - it("falls back to From when SenderId and SenderE164 are whitespace", () => { - const cfg = { - channels: { whatsapp: { allowFrom: ["+999"] } }, - } as ClawdbotConfig; - - const ctx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:+999", - SenderId: " ", - SenderE164: " ", - } as MsgContext; - - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: true, - }); - - expect(auth.senderId).toBe("+999"); - expect(auth.isAuthorizedSender).toBe(true); - }); - - it("falls back from un-normalizable SenderId to SenderE164", () => { - const cfg = { - channels: { whatsapp: { allowFrom: ["+123"] } }, - } as ClawdbotConfig; - - const ctx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:+999", - SenderId: "wat", - SenderE164: "+123", - } as MsgContext; - - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: true, - }); - - expect(auth.senderId).toBe("+123"); - expect(auth.isAuthorizedSender).toBe(true); - }); - - it("prefers SenderE164 when SenderId does not match allowFrom", () => { - const cfg = { - channels: { whatsapp: { allowFrom: ["+41796666864"] } }, - } as ClawdbotConfig; - - const ctx = { - Provider: "whatsapp", - Surface: "whatsapp", - From: "whatsapp:120363401234567890@g.us", - SenderId: "123@lid", - SenderE164: "+41796666864", - } as MsgContext; - - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: true, - }); - - expect(auth.senderId).toBe("+41796666864"); - expect(auth.isAuthorizedSender).toBe(true); - }); -}); diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-control.test.ts similarity index 56% rename from src/auto-reply/command-detection.test.ts rename to src/auto-reply/command-control.test.ts index 66f9d15c7..cb65e60ef 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js"; import { listChatCommands } from "./commands-registry.js"; import { parseActivationCommand } from "./group-activation.js"; import { parseSendPolicyCommand } from "./send-policy.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import type { MsgContext } from "./templating.js"; beforeEach(() => { setActivePluginRegistry(createTestRegistry([])); @@ -14,6 +18,123 @@ afterEach(() => { setActivePluginRegistry(createTestRegistry([])); }); +describe("resolveCommandAuthorization", () => { + it("falls back from empty SenderId to SenderE164", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+123"] } }, + } as ClawdbotConfig; + + const ctx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+999", + SenderId: "", + SenderE164: "+123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderId).toBe("+123"); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("falls back from whitespace SenderId to SenderE164", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+123"] } }, + } as ClawdbotConfig; + + const ctx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+999", + SenderId: " ", + SenderE164: "+123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderId).toBe("+123"); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("falls back to From when SenderId and SenderE164 are whitespace", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+999"] } }, + } as ClawdbotConfig; + + const ctx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+999", + SenderId: " ", + SenderE164: " ", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderId).toBe("+999"); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("falls back from un-normalizable SenderId to SenderE164", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+123"] } }, + } as ClawdbotConfig; + + const ctx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:+999", + SenderId: "wat", + SenderE164: "+123", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderId).toBe("+123"); + expect(auth.isAuthorizedSender).toBe(true); + }); + + it("prefers SenderE164 when SenderId does not match allowFrom", () => { + const cfg = { + channels: { whatsapp: { allowFrom: ["+41796666864"] } }, + } as ClawdbotConfig; + + const ctx = { + Provider: "whatsapp", + Surface: "whatsapp", + From: "whatsapp:120363401234567890@g.us", + SenderId: "123@lid", + SenderE164: "+41796666864", + } as MsgContext; + + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: true, + }); + + expect(auth.senderId).toBe("+41796666864"); + expect(auth.isAuthorizedSender).toBe(true); + }); +}); + describe("control command parsing", () => { it("requires slash for send policy", () => { expect(parseSendPolicyCommand("/send on")).toEqual({ diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts deleted file mode 100644 index cee8cf5f3..000000000 --- a/src/auto-reply/commands-registry.args.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildCommandTextFromArgs, - parseCommandArgs, - resolveCommandArgMenu, - serializeCommandArgs, -} from "./commands-registry.js"; -import type { ChatCommandDefinition } from "./commands-registry.types.js"; - -describe("commands registry args", () => { - it("parses positional args and captureRemaining", () => { - const command: ChatCommandDefinition = { - key: "debug", - description: "debug", - textAliases: [], - scope: "both", - argsParsing: "positional", - args: [ - { name: "action", description: "action", type: "string" }, - { name: "path", description: "path", type: "string" }, - { name: "value", description: "value", type: "string", captureRemaining: true }, - ], - }; - - const args = parseCommandArgs(command, "set foo bar baz"); - expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" }); - }); - - it("serializes args via raw first, then values", () => { - const command: ChatCommandDefinition = { - key: "model", - description: "model", - textAliases: [], - scope: "both", - argsParsing: "positional", - args: [{ name: "model", description: "model", type: "string", captureRemaining: true }], - }; - - expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex"); - expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( - "gpt-5.2-codex", - ); - expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( - "/model gpt-5.2-codex", - ); - }); - - it("resolves auto arg menus when missing a choice arg", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "positional", - args: [ - { - name: "mode", - description: "mode", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; - - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("mode"); - expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]); - }); - - it("does not show menus when arg already provided", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "positional", - args: [ - { - name: "mode", - description: "mode", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; - - const menu = resolveCommandArgMenu({ - command, - args: { values: { mode: "tokens" } }, - cfg: {} as never, - }); - expect(menu).toBeNull(); - }); - - it("resolves function-based choices with a default provider/model context", () => { - let seen: { provider: string; model: string; commandKey: string; argName: string } | null = - null; - - const command: ChatCommandDefinition = { - key: "think", - description: "think", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "positional", - args: [ - { - name: "level", - description: "level", - type: "string", - choices: ({ provider, model, command, arg }) => { - seen = { provider, model, commandKey: command.key, argName: arg.name }; - return ["low", "high"]; - }, - }, - ], - }; - - const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); - expect(menu?.arg.name).toBe("level"); - expect(menu?.choices).toEqual(["low", "high"]); - expect(seen?.commandKey).toBe("think"); - expect(seen?.argName).toBe("level"); - expect(seen?.provider).toBeTruthy(); - expect(seen?.model).toBeTruthy(); - }); - - it("does not show menus when args were provided as raw text only", () => { - const command: ChatCommandDefinition = { - key: "usage", - description: "usage", - textAliases: [], - scope: "both", - argsMenu: "auto", - argsParsing: "none", - args: [ - { - name: "mode", - description: "on or off", - type: "string", - choices: ["off", "tokens", "full", "cost"], - }, - ], - }; - - const menu = resolveCommandArgMenu({ - command, - args: { raw: "on" }, - cfg: {} as never, - }); - expect(menu).toBeNull(); - }); -}); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 4296e06cd..e1192c9cd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildCommandText, + buildCommandTextFromArgs, getCommandDetection, listChatCommands, listChatCommandsForConfig, listNativeCommandSpecs, listNativeCommandSpecsForConfig, normalizeCommandBody, + parseCommandArgs, + resolveCommandArgMenu, + serializeCommandArgs, shouldHandleTextCommands, } from "./commands-registry.js"; +import type { ChatCommandDefinition } from "./commands-registry.types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -154,3 +159,150 @@ describe("commands registry", () => { expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram"); }); }); + +describe("commands registry args", () => { + it("parses positional args and captureRemaining", () => { + const command: ChatCommandDefinition = { + key: "debug", + description: "debug", + textAliases: [], + scope: "both", + argsParsing: "positional", + args: [ + { name: "action", description: "action", type: "string" }, + { name: "path", description: "path", type: "string" }, + { name: "value", description: "value", type: "string", captureRemaining: true }, + ], + }; + + const args = parseCommandArgs(command, "set foo bar baz"); + expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" }); + }); + + it("serializes args via raw first, then values", () => { + const command: ChatCommandDefinition = { + key: "model", + description: "model", + textAliases: [], + scope: "both", + argsParsing: "positional", + args: [{ name: "model", description: "model", type: "string", captureRemaining: true }], + }; + + expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex"); + expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( + "gpt-5.2-codex", + ); + expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe( + "/model gpt-5.2-codex", + ); + }); + + it("resolves auto arg menus when missing a choice arg", () => { + const command: ChatCommandDefinition = { + key: "usage", + description: "usage", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "positional", + args: [ + { + name: "mode", + description: "mode", + type: "string", + choices: ["off", "tokens", "full", "cost"], + }, + ], + }; + + const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu?.arg.name).toBe("mode"); + expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]); + }); + + it("does not show menus when arg already provided", () => { + const command: ChatCommandDefinition = { + key: "usage", + description: "usage", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "positional", + args: [ + { + name: "mode", + description: "mode", + type: "string", + choices: ["off", "tokens", "full", "cost"], + }, + ], + }; + + const menu = resolveCommandArgMenu({ + command, + args: { values: { mode: "tokens" } }, + cfg: {} as never, + }); + expect(menu).toBeNull(); + }); + + it("resolves function-based choices with a default provider/model context", () => { + let seen: { provider: string; model: string; commandKey: string; argName: string } | null = + null; + + const command: ChatCommandDefinition = { + key: "think", + description: "think", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "positional", + args: [ + { + name: "level", + description: "level", + type: "string", + choices: ({ provider, model, command, arg }) => { + seen = { provider, model, commandKey: command.key, argName: arg.name }; + return ["low", "high"]; + }, + }, + ], + }; + + const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); + expect(menu?.arg.name).toBe("level"); + expect(menu?.choices).toEqual(["low", "high"]); + expect(seen?.commandKey).toBe("think"); + expect(seen?.argName).toBe("level"); + expect(seen?.provider).toBeTruthy(); + expect(seen?.model).toBeTruthy(); + }); + + it("does not show menus when args were provided as raw text only", () => { + const command: ChatCommandDefinition = { + key: "usage", + description: "usage", + textAliases: [], + scope: "both", + argsMenu: "auto", + argsParsing: "none", + args: [ + { + name: "mode", + description: "on or off", + type: "string", + choices: ["off", "tokens", "full", "cost"], + }, + ], + }; + + const menu = resolveCommandArgMenu({ + command, + args: { raw: "on" }, + cfg: {} as never, + }); + expect(menu).toBeNull(); + }); +}); diff --git a/src/auto-reply/inbound-debounce.test.ts b/src/auto-reply/inbound-debounce.test.ts deleted file mode 100644 index a50d403b4..000000000 --- a/src/auto-reply/inbound-debounce.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createInboundDebouncer } from "./inbound-debounce.js"; - -describe("createInboundDebouncer", () => { - it("debounces and combines items", async () => { - vi.useFakeTimers(); - const calls: Array = []; - - const debouncer = createInboundDebouncer<{ key: string; id: string }>({ - debounceMs: 10, - buildKey: (item) => item.key, - onFlush: async (items) => { - calls.push(items.map((entry) => entry.id)); - }, - }); - - await debouncer.enqueue({ key: "a", id: "1" }); - await debouncer.enqueue({ key: "a", id: "2" }); - - expect(calls).toEqual([]); - await vi.advanceTimersByTimeAsync(10); - expect(calls).toEqual([["1", "2"]]); - - vi.useRealTimers(); - }); - - it("flushes buffered items before non-debounced item", async () => { - vi.useFakeTimers(); - const calls: Array = []; - - const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({ - debounceMs: 50, - buildKey: (item) => item.key, - shouldDebounce: (item) => item.debounce, - onFlush: async (items) => { - calls.push(items.map((entry) => entry.id)); - }, - }); - - await debouncer.enqueue({ key: "a", id: "1", debounce: true }); - await debouncer.enqueue({ key: "a", id: "2", debounce: false }); - - expect(calls).toEqual([["1"], ["2"]]); - - vi.useRealTimers(); - }); -}); diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts new file mode 100644 index 000000000..c58b98e54 --- /dev/null +++ b/src/auto-reply/inbound.test.ts @@ -0,0 +1,402 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import type { GroupKeyResolution } from "../config/sessions.js"; +import { createInboundDebouncer } from "./inbound-debounce.js"; +import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js"; +import { finalizeInboundContext } from "./reply/inbound-context.js"; +import { + buildInboundDedupeKey, + resetInboundDedupe, + shouldSkipDuplicateInbound, +} from "./reply/inbound-dedupe.js"; +import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js"; +import { normalizeInboundTextNewlines } from "./reply/inbound-text.js"; +import { resolveGroupRequireMention } from "./reply/groups.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, + normalizeMentionText, +} from "./reply/mentions.js"; +import { initSessionState } from "./reply/session.js"; + +describe("applyTemplate", () => { + it("renders primitive values", () => { + const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext; + const overrides = ctx as Record; + overrides.MessageSid = 42; + overrides.IsNewSession = true; + + expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true"); + }); + + it("renders arrays of primitives", () => { + const ctx = { MediaPaths: ["a"] } as TemplateContext; + (ctx as Record).MediaPaths = ["a", 2, true, null, { ok: false }]; + + expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true"); + }); + + it("drops object values", () => { + const ctx: TemplateContext = { CommandArgs: { raw: "go" } }; + + expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args="); + }); + + it("renders missing placeholders as empty", () => { + const ctx: TemplateContext = {}; + + expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing="); + }); +}); + +describe("normalizeInboundTextNewlines", () => { + it("keeps real newlines", () => { + expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb"); + }); + + it("normalizes CRLF/CR to LF", () => { + expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb"); + expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb"); + }); + + it("decodes literal \\n to newlines when no real newlines exist", () => { + expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb"); + }); +}); + +describe("finalizeInboundContext", () => { + it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => { + const ctx: MsgContext = { + Body: "a\\nb\r\nc", + RawBody: "raw\\nline", + ChatType: "channel", + From: "whatsapp:group:123@g.us", + GroupSubject: "Test", + }; + + const out = finalizeInboundContext(ctx); + expect(out.Body).toBe("a\nb\nc"); + expect(out.RawBody).toBe("raw\nline"); + expect(out.BodyForAgent).toBe("a\nb\nc"); + expect(out.BodyForCommands).toBe("raw\nline"); + expect(out.CommandAuthorized).toBe(false); + expect(out.ChatType).toBe("channel"); + expect(out.ConversationLabel).toContain("Test"); + }); + + it("can force BodyForCommands to follow updated CommandBody", () => { + const ctx: MsgContext = { + Body: "base", + BodyForCommands: "", + CommandBody: "say hi", + From: "signal:+15550001111", + ChatType: "direct", + }; + + finalizeInboundContext(ctx, { forceBodyForCommands: true }); + expect(ctx.BodyForCommands).toBe("say hi"); + }); +}); + +describe("formatInboundBodyWithSenderMeta", () => { + it("does nothing for direct messages", () => { + const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi"); + }); + + it("appends a sender meta line for non-direct messages", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe( + "[X] hi\n[from: Alice (A1)]", + ); + }); + + it("prefers SenderE164 in the label when present", () => { + const ctx: MsgContext = { + ChatType: "group", + SenderName: "Bob", + SenderId: "bob@s.whatsapp.net", + SenderE164: "+222", + }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe( + "[X] hi\n[from: Bob (+222)]", + ); + }); + + it("appends with a real newline even if the body contains literal \\n", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe( + "[X] one\\n[X] two\n[from: Bob (+222)]", + ); + }); + + it("does not duplicate a sender meta line when one is already present", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe( + "[X] hi\n[from: Alice (A1)]", + ); + }); + + it("does not append when the body already includes a sender prefix", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi"); + }); + + it("does not append when the sender prefix follows an envelope header", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe( + "[Signal Group] Alice (A1): hi", + ); + }); +}); + +describe("inbound dedupe", () => { + it("builds a stable key when MessageSid is present", () => { + const ctx: MsgContext = { + Provider: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:123", + MessageSid: "42", + }; + expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42"); + }); + + it("skips duplicates with the same key", () => { + resetInboundDedupe(); + const ctx: MsgContext = { + Provider: "whatsapp", + OriginatingChannel: "whatsapp", + OriginatingTo: "whatsapp:+1555", + MessageSid: "msg-1", + }; + expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false); + expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true); + }); + + it("does not dedupe when the peer changes", () => { + resetInboundDedupe(); + const base: MsgContext = { + Provider: "whatsapp", + OriginatingChannel: "whatsapp", + MessageSid: "msg-1", + }; + expect( + shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }), + ).toBe(false); + expect( + shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }), + ).toBe(false); + }); + + it("does not dedupe across session keys", () => { + resetInboundDedupe(); + const base: MsgContext = { + Provider: "whatsapp", + OriginatingChannel: "whatsapp", + OriginatingTo: "whatsapp:+1555", + MessageSid: "msg-1", + }; + expect( + shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }), + ).toBe(false); + expect( + shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }), + ).toBe(false); + expect( + shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }), + ).toBe(true); + }); +}); + +describe("createInboundDebouncer", () => { + it("debounces and combines items", async () => { + vi.useFakeTimers(); + const calls: Array = []; + + const debouncer = createInboundDebouncer<{ key: string; id: string }>({ + debounceMs: 10, + buildKey: (item) => item.key, + onFlush: async (items) => { + calls.push(items.map((entry) => entry.id)); + }, + }); + + await debouncer.enqueue({ key: "a", id: "1" }); + await debouncer.enqueue({ key: "a", id: "2" }); + + expect(calls).toEqual([]); + await vi.advanceTimersByTimeAsync(10); + expect(calls).toEqual([["1", "2"]]); + + vi.useRealTimers(); + }); + + it("flushes buffered items before non-debounced item", async () => { + vi.useFakeTimers(); + const calls: Array = []; + + const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({ + debounceMs: 50, + buildKey: (item) => item.key, + shouldDebounce: (item) => item.debounce, + onFlush: async (items) => { + calls.push(items.map((entry) => entry.id)); + }, + }); + + await debouncer.enqueue({ key: "a", id: "1", debounce: true }); + await debouncer.enqueue({ key: "a", id: "2", debounce: false }); + + expect(calls).toEqual([["1"], ["2"]]); + + vi.useRealTimers(); + }); +}); + +describe("initSessionState sender meta", () => { + it("injects sender meta into BodyStripped for group chats", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { session: { store: storePath } } as ClawdbotConfig; + + const result = await initSessionState({ + ctx: { + Body: "[WhatsApp 123@g.us] ping", + ChatType: "group", + SenderName: "Bob", + SenderE164: "+222", + SenderId: "222@s.whatsapp.net", + SessionKey: "agent:main:whatsapp:group:123@g.us", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]"); + }); + + it("does not inject sender meta for direct chats", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-")); + const storePath = path.join(root, "sessions.json"); + const cfg = { session: { store: storePath } } as ClawdbotConfig; + + const result = await initSessionState({ + ctx: { + Body: "[WhatsApp +1] ping", + ChatType: "direct", + SenderName: "Bob", + SenderE164: "+222", + SessionKey: "agent:main:whatsapp:dm:+222", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping"); + }); +}); + +describe("mention helpers", () => { + it("builds regexes and skips invalid patterns", () => { + const regexes = buildMentionRegexes({ + messages: { + groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, + }, + }); + expect(regexes).toHaveLength(1); + expect(regexes[0]?.test("clawd")).toBe(true); + }); + + it("normalizes zero-width characters", () => { + expect(normalizeMentionText("cl\u200bawd")).toBe("clawd"); + }); + + it("matches patterns case-insensitively", () => { + const regexes = buildMentionRegexes({ + messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, + }); + expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); + }); + + it("uses per-agent mention patterns when configured", () => { + const regexes = buildMentionRegexes( + { + messages: { + groupChat: { mentionPatterns: ["\\bglobal\\b"] }, + }, + agents: { + list: [ + { + id: "work", + groupChat: { mentionPatterns: ["\\bworkbot\\b"] }, + }, + ], + }, + }, + "work", + ); + expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true); + expect(matchesMentionPatterns("global: hi", regexes)).toBe(false); + }); +}); + +describe("resolveGroupRequireMention", () => { + it("respects Discord guild/channel requireMention settings", () => { + const cfg: ClawdbotConfig = { + channels: { + discord: { + guilds: { + "145": { + requireMention: false, + channels: { + general: { allow: true }, + }, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "discord", + From: "discord:group:123", + GroupChannel: "#general", + GroupSpace: "145", + }; + const groupResolution: GroupKeyResolution = { + channel: "discord", + id: "123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); + + it("respects Slack channel requireMention settings", () => { + const cfg: ClawdbotConfig = { + channels: { + slack: { + channels: { + C123: { requireMention: false }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "slack", + From: "slack:channel:C123", + GroupSubject: "#general", + }; + const groupResolution: GroupKeyResolution = { + channel: "slack", + id: "C123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); +}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index 23e66f1a5..798ddb28b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -2,174 +2,79 @@ import fs from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import type { MsgContext, TemplateContext } from "./templating.js"; -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - compactEmbeddedPiSession: vi.fn(), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), +const sandboxMocks = vi.hoisted(() => ({ + ensureSandboxWorkspaceForSession: vi.fn(), })); -const usageMocks = vi.hoisted(() => ({ - loadProviderUsageSummary: vi.fn().mockResolvedValue({ - updatedAt: 0, - providers: [], - }), - formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), - resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), -})); +vi.mock("../agents/sandbox.js", () => sandboxMocks); -vi.mock("../infra/provider-usage.js", () => usageMocks); - -const modelCatalogMocks = vi.hoisted(() => ({ - loadModelCatalog: vi.fn().mockResolvedValue([ - { - provider: "anthropic", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - contextWindow: 200000, - }, - { - provider: "openrouter", - id: "anthropic/claude-opus-4-5", - name: "Claude Opus 4.5 (OpenRouter)", - contextWindow: 200000, - }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, - { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, - { provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" }, - { provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" }, - ]), - resetModelCatalogCacheForTest: vi.fn(), -})); - -vi.mock("../agents/model-catalog.js", () => modelCatalogMocks); - -import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; -import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js"; -import { getReplyFromConfig } from "./reply.js"; - -const _MAIN_SESSION_KEY = "agent:main:main"; - -const webMocks = vi.hoisted(() => ({ - webAuthExists: vi.fn().mockResolvedValue(true), - getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), - readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), -})); - -vi.mock("../web/session.js", () => webMocks); +import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase( - async (home) => { - vi.mocked(runEmbeddedPiAgent).mockClear(); - vi.mocked(abortEmbeddedPiRun).mockClear(); - return await fn(home); - }, - { prefix: "clawdbot-triggers-" }, - ); -} - -function _makeCfg(home: string) { - return { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), - }, - }, - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - session: { store: join(home, "sessions.json") }, - }; + return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" }); } afterEach(() => { vi.restoreAllMocks(); }); -describe("trigger handling", () => { - it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => { +describe("stageSandboxMedia", () => { + it("stages inbound media into the sandbox workspace", async () => { await withTempHome(async (home) => { const inboundDir = join(home, ".clawdbot", "media", "inbound"); await fs.mkdir(inboundDir, { recursive: true }); const mediaPath = join(inboundDir, "photo.jpg"); await fs.writeFile(mediaPath, "test"); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + const sandboxDir = join(home, "sandboxes", "session"); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir: sandboxDir, + containerWorkdir: "/work", }); - const cfg = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: join(home, "clawd"), - sandbox: { - mode: "non-main" as const, - workspaceRoot: join(home, "sandboxes"), - }, - }, - }, - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - session: { - store: join(home, "sessions.json"), - }, - }; - - const ctx = { + const ctx: MsgContext = { Body: "hi", From: "whatsapp:group:demo", To: "+2000", - ChatType: "group" as const, - Provider: "whatsapp" as const, + ChatType: "group", + Provider: "whatsapp", MediaPath: mediaPath, MediaType: "image/jpeg", MediaUrl: mediaPath, }; + const sessionCtx: TemplateContext = { ...ctx }; - const res = await getReplyFromConfig(ctx, {}, cfg); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - - const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; - const stagedPath = `media/inbound/${basename(mediaPath)}`; - expect(prompt).toContain(stagedPath); - expect(prompt).not.toContain(mediaPath); - - const sessionKey = resolveSessionKey( - cfg.session?.scope ?? "per-sender", + await stageSandboxMedia({ ctx, - cfg.session?.mainKey, - ); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const sandbox = await ensureSandboxWorkspaceForSession({ - config: cfg, - sessionKey, - workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + sessionCtx, + cfg: { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + sandbox: { + mode: "non-main", + workspaceRoot: join(home, "sandboxes"), + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: join(home, "sessions.json") }, + }, + sessionKey: "agent:main:main", + workspaceDir: join(home, "clawd"), }); - expect(sandbox).not.toBeNull(); - if (!sandbox) { - throw new Error("Expected sandbox to be set"); - } - const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath)); + + const stagedPath = `media/inbound/${basename(mediaPath)}`; + expect(ctx.MediaPath).toBe(stagedPath); + expect(sessionCtx.MediaPath).toBe(stagedPath); + expect(ctx.MediaUrl).toBe(stagedPath); + expect(sessionCtx.MediaUrl).toBe(stagedPath); + + const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath)); await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); }); }); diff --git a/src/auto-reply/reply/audio-tags.test.ts b/src/auto-reply/reply/audio-tags.test.ts deleted file mode 100644 index 48d952c15..000000000 --- a/src/auto-reply/reply/audio-tags.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseAudioTag } from "./audio-tags.js"; - -describe("parseAudioTag", () => { - it("detects audio_as_voice and strips the tag", () => { - const result = parseAudioTag("Hello [[audio_as_voice]] world"); - expect(result.audioAsVoice).toBe(true); - expect(result.hadTag).toBe(true); - expect(result.text).toBe("Hello world"); - }); - - it("returns empty output for missing text", () => { - const result = parseAudioTag(undefined); - expect(result.audioAsVoice).toBe(false); - expect(result.hadTag).toBe(false); - expect(result.text).toBe(""); - }); - - it("removes tag-only messages", () => { - const result = parseAudioTag("[[audio_as_voice]]"); - expect(result.audioAsVoice).toBe(true); - expect(result.text).toBe(""); - }); -}); diff --git a/src/auto-reply/reply/block-reply-coalescer.test.ts b/src/auto-reply/reply/block-reply-coalescer.test.ts deleted file mode 100644 index 06f7e42cc..000000000 --- a/src/auto-reply/reply/block-reply-coalescer.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; - -describe("block reply coalescer", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("coalesces chunks within the idle window", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["Hello world"]); - coalescer.stop(); - }); - - it("waits until minChars before idle flush", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "short" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual([]); - - coalescer.enqueue({ text: "message" }); - await vi.advanceTimersByTimeAsync(50); - expect(flushes).toEqual(["short message"]); - coalescer.stop(); - }); - - it("flushes buffered text before media payloads", () => { - const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - }, - }); - - coalescer.enqueue({ text: "Hello" }); - coalescer.enqueue({ text: "world" }); - coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); - void coalescer.flush({ force: true }); - - expect(flushes[0].text).toBe("Hello world"); - expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); - coalescer.stop(); - }); -}); diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts deleted file mode 100644 index 60c6fdecd..000000000 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import type { MsgContext } from "../templating.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; - -const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../config/config.js", async () => { - const actual = - await vi.importActual("../../config/config.js"); - return { - ...actual, - readConfigFileSnapshot: readConfigFileSnapshotMock, - validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, - }; -}); - -const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); -const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", async () => { - const actual = await vi.importActual( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - -function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { - const ctx = { - Body: commandBody, - CommandBody: commandBody, - CommandSource: "text", - CommandAuthorized: true, - Provider: "telegram", - Surface: "telegram", - ...ctxOverrides, - } as MsgContext; - - const command = buildCommandContext({ - ctx, - cfg, - isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), - commandAuthorized: true, - }); - - return { - ctx, - cfg, - command, - directives: parseInlineDirectives(commandBody), - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off" as const, - resolvedReasoningLevel: "off" as const, - resolveDefaultThinkingLevel: async () => undefined, - provider: "telegram", - model: "test-model", - contextTokens: 0, - isGroup: false, - }; -} - -describe("handleCommands /allowlist", () => { - it("lists config + store allowFrom entries", async () => { - readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); - - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["123", "@Alice"] } }, - } as ClawdbotConfig; - const params = buildParams("/allowlist list dm", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Channel: telegram"); - expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); - expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); - }); - - it("adds entries to config and pairing store", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { telegram: { allowFrom: ["123"] } }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); - - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as ClawdbotConfig; - const params = buildParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { telegram: { allowFrom: ["123", "789"] } }, - }), - ); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - }); - expect(result.reply?.text).toContain("DM allowlist added"); - }); -}); diff --git a/src/auto-reply/reply/commands-config-writes.test.ts b/src/auto-reply/reply/commands-config-writes.test.ts deleted file mode 100644 index 7c55c3a01..000000000 --- a/src/auto-reply/reply/commands-config-writes.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import type { MsgContext } from "../templating.js"; -import { buildCommandContext, handleCommands } from "./commands.js"; -import { parseInlineDirectives } from "./directive-handling.js"; - -function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { - const ctx = { - Body: commandBody, - CommandBody: commandBody, - CommandSource: "text", - CommandAuthorized: true, - Provider: "whatsapp", - Surface: "whatsapp", - ...ctxOverrides, - } as MsgContext; - - const command = buildCommandContext({ - ctx, - cfg, - isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), - commandAuthorized: true, - }); - - return { - ctx, - cfg, - command, - directives: parseInlineDirectives(commandBody), - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off" as const, - resolvedReasoningLevel: "off" as const, - resolveDefaultThinkingLevel: async () => undefined, - provider: "whatsapp", - model: "test-model", - contextTokens: 0, - isGroup: false, - }; -} - -describe("handleCommands /config configWrites gating", () => { - it("blocks /config set when channel config writes are disabled", async () => { - const cfg = { - commands: { config: true, text: true }, - channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, - } as ClawdbotConfig; - const params = buildParams('/config set messages.ackReaction=":)"', cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config writes are disabled"); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts new file mode 100644 index 000000000..1c60dc98a --- /dev/null +++ b/src/auto-reply/reply/commands-parsing.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { extractMessageText } from "./commands-subagents.js"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "whatsapp", + Surface: "whatsapp", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "whatsapp", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("parseConfigCommand", () => { + it("parses show/unset", () => { + expect(parseConfigCommand("/config")).toEqual({ action: "show" }); + expect(parseConfigCommand("/config show")).toEqual({ + action: "show", + path: undefined, + }); + expect(parseConfigCommand("/config show foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config get foo.bar")).toEqual({ + action: "show", + path: "foo.bar", + }); + expect(parseConfigCommand("/config unset foo.bar")).toEqual({ + action: "unset", + path: "foo.bar", + }); + }); + + it("parses set with JSON", () => { + const cmd = parseConfigCommand('/config set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); +}); + +describe("parseDebugCommand", () => { + it("parses show/reset", () => { + expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); + expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); + }); + + it("parses set with JSON", () => { + const cmd = parseDebugCommand('/debug set foo={"a":1}'); + expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); + }); + + it("parses unset", () => { + const cmd = parseDebugCommand("/debug unset foo.bar"); + expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); + }); +}); + +describe("extractMessageText", () => { + it("preserves user text that looks like tool call markers", () => { + const message = { + role: "user", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); + }); + + it("sanitizes assistant tool call markers", () => { + const message = { + role: "assistant", + content: "Here [Tool Call: foo (ID: 1)] ok", + }; + const result = extractMessageText(message); + expect(result?.text).toBe("Here ok"); + }); +}); + +describe("handleCommands /config configWrites gating", () => { + it("blocks /config set when channel config writes are disabled", async () => { + const cfg = { + commands: { config: true, text: true }, + channels: { whatsapp: { allowFrom: ["*"], configWrites: false } }, + } as ClawdbotConfig; + const params = buildParams('/config set messages.ackReaction=":)"', cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config writes are disabled"); + }); +}); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-policy.test.ts similarity index 59% rename from src/auto-reply/reply/commands-models.test.ts rename to src/auto-reply/reply/commands-policy.test.ts index c32abfc7d..5ba5026a7 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-policy.test.ts @@ -5,6 +5,47 @@ import type { MsgContext } from "../templating.js"; import { buildCommandContext, handleCommands } from "./commands.js"; import { parseInlineDirectives } from "./directive-handling.js"; +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../config/config.js", async () => { + const actual = + await vi.importActual("../../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: readConfigFileSnapshotMock, + validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, + writeConfigFile: writeConfigFileMock, + }; +}); + +const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); +const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); +const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../pairing/pairing-store.js", async () => { + const actual = await vi.importActual( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../channels/plugins/pairing.js", + ); + return { + ...actual, + listPairingChannels: () => ["telegram"], + }; +}); + vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, @@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa resolvedVerboseLevel: "off" as const, resolvedReasoningLevel: "off" as const, resolveDefaultThinkingLevel: async () => undefined, - provider: "anthropic", - model: "claude-opus-4-5", - contextTokens: 16000, + provider: "telegram", + model: "test-model", + contextTokens: 0, isGroup: false, }; } +describe("handleCommands /allowlist", () => { + it("lists config + store allowFrom entries", async () => { + readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); + + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["123", "@Alice"] } }, + } as ClawdbotConfig; + const params = buildParams("/allowlist list dm", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Channel: telegram"); + expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice"); + expect(result.reply?.text).toContain("Paired allowFrom (store): 456"); + }); + + it("adds entries to config and pairing store", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as ClawdbotConfig; + const params = buildParams("/allowlist add dm 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { telegram: { allowFrom: ["123", "789"] } }, + }), + ); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + }); + expect(result.reply?.text).toContain("DM allowlist added"); + }); +}); + describe("/models command", () => { const cfg = { commands: { text: true }, - // allowlist is empty => allowAny, but still okay for listing agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, } as unknown as ClawdbotConfig; diff --git a/src/auto-reply/reply/commands-subagents.test.ts b/src/auto-reply/reply/commands-subagents.test.ts deleted file mode 100644 index eaf7c3026..000000000 --- a/src/auto-reply/reply/commands-subagents.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { extractMessageText } from "./commands-subagents.js"; - -describe("extractMessageText", () => { - it("preserves user text that looks like tool call markers", () => { - const message = { - role: "user", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toContain("[Tool Call: foo (ID: 1)]"); - }); - - it("sanitizes assistant tool call markers", () => { - const message = { - role: "assistant", - content: "Here [Tool Call: foo (ID: 1)] ok", - }; - const result = extractMessageText(message); - expect(result?.text).toBe("Here ok"); - }); -}); diff --git a/src/auto-reply/reply/config-commands.test.ts b/src/auto-reply/reply/config-commands.test.ts deleted file mode 100644 index a1d19f039..000000000 --- a/src/auto-reply/reply/config-commands.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseConfigCommand } from "./config-commands.js"; - -describe("parseConfigCommand", () => { - it("parses show/unset", () => { - expect(parseConfigCommand("/config")).toEqual({ action: "show" }); - expect(parseConfigCommand("/config show")).toEqual({ - action: "show", - path: undefined, - }); - expect(parseConfigCommand("/config show foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config get foo.bar")).toEqual({ - action: "show", - path: "foo.bar", - }); - expect(parseConfigCommand("/config unset foo.bar")).toEqual({ - action: "unset", - path: "foo.bar", - }); - }); - - it("parses set with JSON", () => { - const cmd = parseConfigCommand('/config set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); -}); diff --git a/src/auto-reply/reply/debug-commands.test.ts b/src/auto-reply/reply/debug-commands.test.ts deleted file mode 100644 index 8c2094520..000000000 --- a/src/auto-reply/reply/debug-commands.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseDebugCommand } from "./debug-commands.js"; - -describe("parseDebugCommand", () => { - it("parses show/reset", () => { - expect(parseDebugCommand("/debug")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug show")).toEqual({ action: "show" }); - expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" }); - }); - - it("parses set with JSON", () => { - const cmd = parseDebugCommand('/debug set foo={"a":1}'); - expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } }); - }); - - it("parses unset", () => { - const cmd = parseDebugCommand("/debug unset foo.bar"); - expect(cmd).toEqual({ action: "unset", path: "foo.bar" }); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts deleted file mode 100644 index c1e2ab7d9..000000000 --- a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ModelAliasIndex } from "../../agents/model-selection.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { parseInlineDirectives } from "./directive-handling.js"; -import { - maybeHandleModelDirectiveInfo, - resolveModelSelectionFromDirective, -} from "./directive-handling.model.js"; - -function baseAliasIndex(): ModelAliasIndex { - return { byAlias: new Map(), byKey: new Map() }; -} - -describe("/model chat UX", () => { - it("shows summary for /model with no args", async () => { - const directives = parseInlineDirectives("/model"); - const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; - - const reply = await maybeHandleModelDirectiveInfo({ - directives, - cfg, - agentDir: "/tmp/agent", - activeAgentId: "main", - provider: "anthropic", - model: "claude-opus-4-5", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - aliasIndex: baseAliasIndex(), - allowedModelCatalog: [], - resetModelOverride: false, - }); - - expect(reply?.text).toContain("Current:"); - expect(reply?.text).toContain("Browse: /models"); - expect(reply?.text).toContain("Switch: /model "); - }); - - it("auto-applies closest match for typos", () => { - const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); - const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; - - const resolved = resolveModelSelectionFromDirective({ - directives, - cfg, - agentDir: "/tmp/agent", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - aliasIndex: baseAliasIndex(), - allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]), - allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }], - provider: "anthropic", - }); - - expect(resolved.modelSelection).toEqual({ - provider: "anthropic", - model: "claude-opus-4-5", - isDefault: true, - }); - expect(resolved.errorText).toBeUndefined(); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts similarity index 66% rename from src/auto-reply/reply/directive-handling.impl.model-persist.test.ts rename to src/auto-reply/reply/directive-handling.model.test.ts index 847ff7030..abd2ff8ef 100644 --- a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -5,8 +5,12 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { parseInlineDirectives } from "./directive-handling.js"; import { handleDirectiveOnly } from "./directive-handling.impl.js"; +import { + maybeHandleModelDirectiveInfo, + resolveModelSelectionFromDirective, +} from "./directive-handling.model.js"; -// Mock dependencies +// Mock dependencies for directive handling persistence. vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentConfig: vi.fn(() => ({})), resolveAgentDir: vi.fn(() => "/tmp/agent"), @@ -36,6 +40,55 @@ function baseConfig(): ClawdbotConfig { } as unknown as ClawdbotConfig; } +describe("/model chat UX", () => { + it("shows summary for /model with no args", async () => { + const directives = parseInlineDirectives("/model"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const reply = await maybeHandleModelDirectiveInfo({ + directives, + cfg, + agentDir: "/tmp/agent", + activeAgentId: "main", + provider: "anthropic", + model: "claude-opus-4-5", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelCatalog: [], + resetModelOverride: false, + }); + + expect(reply?.text).toContain("Current:"); + expect(reply?.text).toContain("Browse: /models"); + expect(reply?.text).toContain("Switch: /model "); + }); + + it("auto-applies closest match for typos", () => { + const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); + const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]), + allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }], + provider: "anthropic", + }); + + expect(resolved.modelSelection).toEqual({ + provider: "anthropic", + model: "claude-opus-4-5", + isDefault: true, + }); + expect(resolved.errorText).toBeUndefined(); + }); +}); + describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]); const allowedModelCatalog = [ @@ -106,7 +159,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { formatModelSwitchEvent: (label) => `Switched to ${label}`, }); - // No model directive = no model message expect(result?.text ?? "").not.toContain("Model set to"); expect(result?.text ?? "").not.toContain("failed"); }); diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts deleted file mode 100644 index 7ea021764..000000000 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; - -import type { SessionEntry } from "../../config/sessions.js"; -import type { FollowupRun } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -const runEmbeddedPiAgentMock = vi.fn(); - -vi.mock("../../agents/model-fallback.js", () => ({ - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), -})); - -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), -})); - -import { createFollowupRunner } from "./followup-runner.js"; - -describe("createFollowupRunner compaction", () => { - it("adds verbose auto-compaction notice and tracks count", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - }; - const sessionStore: Record = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => void; - }) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-5", - }); - - const queued = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: "on", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as FollowupRun; - - await runner(queued); - - expect(onBlockReply).toHaveBeenCalled(); - expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete"); - expect(sessionStore.main.compactionCount).toBe(1); - }); -}); diff --git a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts b/src/auto-reply/reply/followup-runner.test.ts similarity index 60% rename from src/auto-reply/reply/followup-runner.messaging-tools.test.ts rename to src/auto-reply/reply/followup-runner.test.ts index dd080eedc..19213081d 100644 --- a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; import type { FollowupRun } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; @@ -57,6 +61,79 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => }, }) as FollowupRun; +describe("createFollowupRunner compaction", () => { + it("adds verbose auto-compaction notice and tracks count", async () => { + const storePath = path.join( + await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")), + "sessions.json", + ); + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore: Record = { + main: sessionEntry, + }; + const onBlockReply = vi.fn(async () => {}); + + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onAgentEvent?: (evt: { stream: string; data: Record }) => void; + }) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }, + ); + + const runner = createFollowupRunner({ + opts: { onBlockReply }, + typing: createMockTypingController(), + typingMode: "instant", + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + defaultModel: "anthropic/claude-opus-4-5", + }); + + const queued = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey: "main", + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "on", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as FollowupRun; + + await runner(queued); + + expect(onBlockReply).toHaveBeenCalled(); + expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete"); + expect(sessionStore.main.compactionCount).toBe(1); + }); +}); + describe("createFollowupRunner messaging tool dedupe", () => { it("drops payloads already sent via messaging tool", async () => { const onBlockReply = vi.fn(async () => {}); diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts new file mode 100644 index 000000000..a7a9f6174 --- /dev/null +++ b/src/auto-reply/reply/formatting.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { parseAudioTag } from "./audio-tags.js"; +import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; +import { createReplyReferencePlanner } from "./reply-reference.js"; +import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; + +describe("parseAudioTag", () => { + it("detects audio_as_voice and strips the tag", () => { + const result = parseAudioTag("Hello [[audio_as_voice]] world"); + expect(result.audioAsVoice).toBe(true); + expect(result.hadTag).toBe(true); + expect(result.text).toBe("Hello world"); + }); + + it("returns empty output for missing text", () => { + const result = parseAudioTag(undefined); + expect(result.audioAsVoice).toBe(false); + expect(result.hadTag).toBe(false); + expect(result.text).toBe(""); + }); + + it("removes tag-only messages", () => { + const result = parseAudioTag("[[audio_as_voice]]"); + expect(result.audioAsVoice).toBe(true); + expect(result.text).toBe(""); + }); +}); + +describe("block reply coalescer", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces chunks within the idle window", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["Hello world"]); + coalescer.stop(); + }); + + it("waits until minChars before idle flush", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "short" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual([]); + + coalescer.enqueue({ text: "message" }); + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual(["short message"]); + coalescer.stop(); + }); + + it("flushes buffered text before media payloads", () => { + const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }); + }, + }); + + coalescer.enqueue({ text: "Hello" }); + coalescer.enqueue({ text: "world" }); + coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); + void coalescer.flush({ force: true }); + + expect(flushes[0].text).toBe("Hello world"); + expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); + coalescer.stop(); + }); +}); + +describe("createReplyReferencePlanner", () => { + it("disables references when mode is off", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + startId: "parent", + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + }); + + it("uses startId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.hasReplied()).toBe(true); + planner.markSent(); + expect(planner.use()).toBeUndefined(); + }); + + it("returns startId for every call when mode is all", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + }); + expect(planner.use()).toBe("parent"); + expect(planner.use()).toBe("parent"); + }); + + it("prefers existing thread id regardless of mode", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.hasReplied()).toBe(true); + }); + + it("honors allowReference=false", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + startId: "parent", + allowReference: false, + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + planner.markSent(); + expect(planner.hasReplied()).toBe(true); + }); +}); + +describe("createStreamingDirectiveAccumulator", () => { + it("stashes reply_to_current until a renderable chunk arrives", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); + + const result = accumulator.consume("Hello"); + expect(result?.text).toBe("Hello"); + expect(result?.replyToCurrent).toBe(true); + expect(result?.replyToTag).toBe(true); + }); + + it("handles reply tags split across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to_")).toBeNull(); + + const result = accumulator.consume("current]] Yo"); + expect(result?.text).toBe("Yo"); + expect(result?.replyToCurrent).toBe(true); + expect(result?.replyToTag).toBe(true); + }); + + it("propagates explicit reply ids across chunks", () => { + const accumulator = createStreamingDirectiveAccumulator(); + + expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); + + const result = accumulator.consume("Hi"); + expect(result?.text).toBe("Hi"); + expect(result?.replyToId).toBe("abc-123"); + expect(result?.replyToTag).toBe(true); + }); +}); diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts deleted file mode 100644 index 6ae069141..000000000 --- a/src/auto-reply/reply/groups.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ClawdbotConfig } from "../../config/config.js"; -import type { GroupKeyResolution } from "../../config/sessions.js"; -import type { TemplateContext } from "../templating.js"; -import { resolveGroupRequireMention } from "./groups.js"; - -describe("resolveGroupRequireMention", () => { - it("respects Discord guild/channel requireMention settings", () => { - const cfg: ClawdbotConfig = { - channels: { - discord: { - guilds: { - "145": { - requireMention: false, - channels: { - general: { allow: true }, - }, - }, - }, - }, - }, - }; - const ctx: TemplateContext = { - Provider: "discord", - From: "discord:group:123", - GroupChannel: "#general", - GroupSpace: "145", - }; - const groupResolution: GroupKeyResolution = { - channel: "discord", - id: "123", - chatType: "group", - }; - - expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); - }); - - it("respects Slack channel requireMention settings", () => { - const cfg: ClawdbotConfig = { - channels: { - slack: { - channels: { - C123: { requireMention: false }, - }, - }, - }, - }; - const ctx: TemplateContext = { - Provider: "slack", - From: "slack:channel:C123", - GroupSubject: "#general", - }; - const groupResolution: GroupKeyResolution = { - channel: "slack", - id: "C123", - chatType: "group", - }; - - expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.test.ts b/src/auto-reply/reply/inbound-context.test.ts deleted file mode 100644 index 58647176c..000000000 --- a/src/auto-reply/reply/inbound-context.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { MsgContext } from "../templating.js"; -import { finalizeInboundContext } from "./inbound-context.js"; - -describe("finalizeInboundContext", () => { - it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => { - const ctx: MsgContext = { - Body: "a\\nb\r\nc", - RawBody: "raw\\nline", - ChatType: "channel", - From: "whatsapp:group:123@g.us", - GroupSubject: "Test", - }; - - const out = finalizeInboundContext(ctx); - expect(out.Body).toBe("a\nb\nc"); - expect(out.RawBody).toBe("raw\nline"); - expect(out.BodyForAgent).toBe("a\nb\nc"); - expect(out.BodyForCommands).toBe("raw\nline"); - expect(out.CommandAuthorized).toBe(false); - expect(out.ChatType).toBe("channel"); - expect(out.ConversationLabel).toContain("Test"); - }); - - it("can force BodyForCommands to follow updated CommandBody", () => { - const ctx: MsgContext = { - Body: "base", - BodyForCommands: "", - CommandBody: "say hi", - From: "signal:+15550001111", - ChatType: "direct", - }; - - finalizeInboundContext(ctx, { forceBodyForCommands: true }); - expect(ctx.BodyForCommands).toBe("say hi"); - }); -}); diff --git a/src/auto-reply/reply/inbound-dedupe.test.ts b/src/auto-reply/reply/inbound-dedupe.test.ts deleted file mode 100644 index d9dbd148a..000000000 --- a/src/auto-reply/reply/inbound-dedupe.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { MsgContext } from "../templating.js"; -import { - buildInboundDedupeKey, - resetInboundDedupe, - shouldSkipDuplicateInbound, -} from "./inbound-dedupe.js"; - -describe("inbound dedupe", () => { - it("builds a stable key when MessageSid is present", () => { - const ctx: MsgContext = { - Provider: "telegram", - OriginatingChannel: "telegram", - OriginatingTo: "telegram:123", - MessageSid: "42", - }; - expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42"); - }); - - it("skips duplicates with the same key", () => { - resetInboundDedupe(); - const ctx: MsgContext = { - Provider: "whatsapp", - OriginatingChannel: "whatsapp", - OriginatingTo: "whatsapp:+1555", - MessageSid: "msg-1", - }; - expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false); - expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true); - }); - - it("does not dedupe when the peer changes", () => { - resetInboundDedupe(); - const base: MsgContext = { - Provider: "whatsapp", - OriginatingChannel: "whatsapp", - MessageSid: "msg-1", - }; - expect( - shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }), - ).toBe(false); - expect( - shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }), - ).toBe(false); - }); - - it("does not dedupe across session keys", () => { - resetInboundDedupe(); - const base: MsgContext = { - Provider: "whatsapp", - OriginatingChannel: "whatsapp", - OriginatingTo: "whatsapp:+1555", - MessageSid: "msg-1", - }; - expect( - shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }), - ).toBe(false); - expect( - shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }), - ).toBe(false); - expect( - shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }), - ).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/inbound-sender-meta.test.ts b/src/auto-reply/reply/inbound-sender-meta.test.ts deleted file mode 100644 index 2bc8d3d86..000000000 --- a/src/auto-reply/reply/inbound-sender-meta.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { MsgContext } from "../templating.js"; -import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js"; - -describe("formatInboundBodyWithSenderMeta", () => { - it("does nothing for direct messages", () => { - const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi"); - }); - - it("appends a sender meta line for non-direct messages", () => { - const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe( - "[X] hi\n[from: Alice (A1)]", - ); - }); - - it("prefers SenderE164 in the label when present", () => { - const ctx: MsgContext = { - ChatType: "group", - SenderName: "Bob", - SenderId: "bob@s.whatsapp.net", - SenderE164: "+222", - }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe( - "[X] hi\n[from: Bob (+222)]", - ); - }); - - it("appends with a real newline even if the body contains literal \\\\n", () => { - const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe( - "[X] one\\n[X] two\n[from: Bob (+222)]", - ); - }); - - it("does not duplicate a sender meta line when one is already present", () => { - const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe( - "[X] hi\n[from: Alice (A1)]", - ); - }); - - it("does not append when the body already includes a sender prefix", () => { - const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi"); - }); - - it("does not append when the sender prefix follows an envelope header", () => { - const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; - expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe( - "[Signal Group] Alice (A1): hi", - ); - }); -}); diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts deleted file mode 100644 index d1ac537d5..000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { normalizeInboundTextNewlines } from "./inbound-text.js"; - -describe("normalizeInboundTextNewlines", () => { - it("keeps real newlines", () => { - expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb"); - }); - - it("normalizes CRLF/CR to LF", () => { - expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb"); - expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb"); - }); - - it("decodes literal \\\\n to newlines when no real newlines exist", () => { - expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb"); - }); -}); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index d0c16977a..000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js"; - -describe("mention helpers", () => { - it("builds regexes and skips invalid patterns", () => { - const regexes = buildMentionRegexes({ - messages: { - groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, - }, - }); - expect(regexes).toHaveLength(1); - expect(regexes[0]?.test("clawd")).toBe(true); - }); - - it("normalizes zero-width characters", () => { - expect(normalizeMentionText("cl\u200bawd")).toBe("clawd"); - }); - - it("matches patterns case-insensitively", () => { - const regexes = buildMentionRegexes({ - messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, - }); - expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); - }); - - it("uses per-agent mention patterns when configured", () => { - const regexes = buildMentionRegexes( - { - messages: { - groupChat: { mentionPatterns: ["\\bglobal\\b"] }, - }, - agents: { - list: [ - { - id: "work", - groupChat: { mentionPatterns: ["\\bworkbot\\b"] }, - }, - ], - }, - }, - "work", - ); - expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true); - expect(matchesMentionPatterns("global: hi", regexes)).toBe(false); - }); -}); diff --git a/src/auto-reply/reply/reply-reference.test.ts b/src/auto-reply/reply/reply-reference.test.ts deleted file mode 100644 index 57f29763c..000000000 --- a/src/auto-reply/reply/reply-reference.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { createReplyReferencePlanner } from "./reply-reference.js"; - -describe("createReplyReferencePlanner", () => { - it("disables references when mode is off", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses startId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.hasReplied()).toBe(true); - planner.markSent(); - expect(planner.use()).toBeUndefined(); - }); - - it("returns startId for every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - }); - expect(planner.use()).toBe("parent"); - expect(planner.use()).toBe("parent"); - }); - - it("prefers existing thread id regardless of mode", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.hasReplied()).toBe(true); - }); - - it("honors allowReference=false", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - startId: "parent", - allowReference: false, - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - planner.markSent(); - expect(planner.hasReplied()).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-routing.test.ts similarity index 60% rename from src/auto-reply/reply/reply-dispatcher.test.ts rename to src/auto-reply/reply/reply-routing.test.ts index 3c4780505..3f369ec92 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-routing.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; +import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; + +const emptyCfg = {} as ClawdbotConfig; describe("createReplyDispatcher", () => { it("drops empty payloads and silent tokens without media", async () => { @@ -150,3 +155,94 @@ describe("createReplyDispatcher", () => { vi.useRealTimers(); }); }); + +describe("resolveReplyToMode", () => { + it("defaults to first for Telegram", () => { + expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first"); + }); + + it("defaults to off for Discord and Slack", () => { + expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); + expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); + }); + + it("defaults to all when channel is unknown", () => { + expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); + }); + + it("uses configured value when present", () => { + const cfg = { + channels: { + telegram: { replyToMode: "all" }, + discord: { replyToMode: "first" }, + slack: { replyToMode: "all" }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); + expect(resolveReplyToMode(cfg, "discord")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack")).toBe("all"); + }); + + it("uses chat-type replyToMode overrides for Slack when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all", group: "first" }, + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); + }); + + it("falls back to top-level replyToMode when no chat-type override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); + }); + + it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + }); +}); + +describe("createReplyToModeFilter", () => { + it("drops replyToId when mode is off", () => { + const filter = createReplyToModeFilter("off"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); + }); + + it("keeps replyToId when mode is off and reply tags are allowed", () => { + const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true }); + expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); + }); + + it("keeps replyToId when mode is all", () => { + const filter = createReplyToModeFilter("all"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + }); + + it("keeps only the first replyToId when mode is first", () => { + const filter = createReplyToModeFilter("first"); + expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); + expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts deleted file mode 100644 index 2a4e9a7f3..000000000 --- a/src/auto-reply/reply/reply-threading.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; - -const emptyCfg = {} as ClawdbotConfig; - -describe("resolveReplyToMode", () => { - it("defaults to first for Telegram", () => { - expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first"); - }); - - it("defaults to off for Discord and Slack", () => { - expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off"); - expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off"); - }); - - it("defaults to all when channel is unknown", () => { - expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all"); - }); - - it("uses configured value when present", () => { - const cfg = { - channels: { - telegram: { replyToMode: "all" }, - discord: { replyToMode: "first" }, - slack: { replyToMode: "all" }, - }, - } as ClawdbotConfig; - expect(resolveReplyToMode(cfg, "telegram")).toBe("all"); - expect(resolveReplyToMode(cfg, "discord")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack")).toBe("all"); - }); - - it("uses chat-type replyToMode overrides for Slack when configured", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - replyToModeByChatType: { direct: "all", group: "first" }, - }, - }, - } as ClawdbotConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); - }); - - it("falls back to top-level replyToMode when no chat-type override is set", () => { - const cfg = { - channels: { - slack: { - replyToMode: "first", - }, - }, - } as ClawdbotConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); - }); - - it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { - const cfg = { - channels: { - slack: { - replyToMode: "off", - dm: { replyToMode: "all" }, - }, - }, - } as ClawdbotConfig; - expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); - expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); - }); -}); - -describe("createReplyToModeFilter", () => { - it("drops replyToId when mode is off", () => { - const filter = createReplyToModeFilter("off"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined(); - }); - - it("keeps replyToId when mode is off and reply tags are allowed", () => { - const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true }); - expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); - }); - - it("keeps replyToId when mode is all", () => { - const filter = createReplyToModeFilter("all"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - }); - - it("keeps only the first replyToId when mode is first", () => { - const filter = createReplyToModeFilter("first"); - expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1"); - expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined(); - }); -}); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts deleted file mode 100644 index db840038c..000000000 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import { buildModelAliasIndex } from "../../agents/model-selection.js"; -import { applyResetModelOverride } from "./session-reset-model.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "minimax", id: "m2.1", name: "M2.1" }, - { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, - ]), -})); - -describe("applyResetModelOverride", () => { - it("selects a model hint and strips it from the body", async () => { - const cfg = {} as ClawdbotConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBe("minimax"); - expect(sessionEntry.modelOverride).toBe("m2.1"); - expect(sessionCtx.BodyStripped).toBe("summarize"); - }); - - it("clears auth profile overrides when reset applies a model", async () => { - const cfg = {} as ClawdbotConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 2, - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: true, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.authProfileOverride).toBeUndefined(); - expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); - expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); - }); - - it("skips when resetTriggered is false", async () => { - const cfg = {} as ClawdbotConfig; - const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); - const sessionEntry = { - sessionId: "s1", - updatedAt: Date.now(), - }; - const sessionStore = { "agent:main:dm:1": sessionEntry }; - const sessionCtx = { BodyStripped: "minimax summarize" }; - const ctx = { ChatType: "direct" }; - - await applyResetModelOverride({ - cfg, - resetTriggered: false, - bodyStripped: "minimax summarize", - sessionCtx, - ctx, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - defaultProvider: "openai", - defaultModel: "gpt-4o-mini", - aliasIndex, - }); - - expect(sessionEntry.providerOverride).toBeUndefined(); - expect(sessionEntry.modelOverride).toBeUndefined(); - expect(sessionCtx.BodyStripped).toBe("minimax summarize"); - }); -}); diff --git a/src/auto-reply/reply/session-reset-group.test.ts b/src/auto-reply/reply/session-resets.test.ts similarity index 62% rename from src/auto-reply/reply/session-reset-group.test.ts rename to src/auto-reply/reply/session-resets.test.ts index ed08bd5a1..4f0903521 100644 --- a/src/auto-reply/reply/session-reset-group.test.ts +++ b/src/auto-reply/reply/session-resets.test.ts @@ -2,10 +2,21 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { buildModelAliasIndex } from "../../agents/model-selection.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; import { initSessionState } from "./session.js"; +import { applyResetModelOverride } from "./session-reset-model.js"; +import { prependSystemEvents } from "./session-updates.js"; + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(async () => [ + { provider: "minimax", id: "m2.1", name: "M2.1" }, + { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, + ]), +})); describe("initSessionState reset triggers in WhatsApp groups", () => { async function createStorePath(prefix: string): Promise { @@ -54,7 +65,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { allowFrom: ["+41796666864"], }); - // Group message context matching what WhatsApp handler creates const groupMessageCtx = { Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`, RawBody: "/new", @@ -76,7 +86,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { commandAuthorized: true, }); - // The reset should be detected expect(result.triggerBodyNormalized).toBe("/new"); expect(result.isNewSession).toBe(true); expect(result.sessionId).not.toBe(existingSessionId); @@ -99,7 +108,6 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { allowFrom: ["+41796666864"], }); - // Group message from different sender (not in allowFrom) const groupMessageCtx = { Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, RawBody: "/new", @@ -111,7 +119,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { Provider: "whatsapp", Surface: "whatsapp", SenderName: "OtherPerson", - SenderE164: "+1555123456", // Different sender (not authorized) + SenderE164: "+1555123456", SenderId: "1555123456:0@s.whatsapp.net", }; @@ -121,9 +129,8 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { commandAuthorized: true, }); - // Reset should NOT be triggered for unauthorized sender - session ID should stay the same expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); // Session should NOT change + expect(result.sessionId).toBe(existingSessionId); expect(result.isNewSession).toBe(false); }); @@ -143,9 +150,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { }); const groupMessageCtx = { - // Body is wrapped with context prefixes Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, - // RawBody is clean RawBody: "/new", CommandBody: "/new", From: "120363406150318674@g.us", @@ -251,3 +256,124 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { expect(result.isNewSession).toBe(false); }); }); + +describe("applyResetModelOverride", () => { + it("selects a model hint and strips it from the body", async () => { + const cfg = {} as ClawdbotConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBe("minimax"); + expect(sessionEntry.modelOverride).toBe("m2.1"); + expect(sessionCtx.BodyStripped).toBe("summarize"); + }); + + it("clears auth profile overrides when reset applies a model", async () => { + const cfg = {} as ClawdbotConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + authProfileOverride: "anthropic:default", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: true, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.authProfileOverride).toBeUndefined(); + expect(sessionEntry.authProfileOverrideSource).toBeUndefined(); + expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined(); + }); + + it("skips when resetTriggered is false", async () => { + const cfg = {} as ClawdbotConfig; + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" }); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + }; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + const sessionCtx = { BodyStripped: "minimax summarize" }; + const ctx = { ChatType: "direct" }; + + await applyResetModelOverride({ + cfg, + resetTriggered: false, + bodyStripped: "minimax summarize", + sessionCtx, + ctx, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + defaultProvider: "openai", + defaultModel: "gpt-4o-mini", + aliasIndex, + }); + + expect(sessionEntry.providerOverride).toBeUndefined(); + expect(sessionEntry.modelOverride).toBeUndefined(); + expect(sessionCtx.BodyStripped).toBe("minimax summarize"); + }); +}); + +describe("prependSystemEvents", () => { + it("adds a local timestamp to queued system events by default", async () => { + vi.useFakeTimers(); + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + const timestamp = new Date("2026-01-12T20:19:17Z"); + vi.setSystemTime(timestamp); + + enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); + + const result = await prependSystemEvents({ + cfg: {} as ClawdbotConfig, + sessionKey: "agent:main:main", + isMainSession: false, + isNewSession: false, + prefixedBodyBase: "User: hi", + }); + + expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./); + + resetSystemEventsForTest(); + process.env.TZ = originalTz; + vi.useRealTimers(); + }); +}); diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts deleted file mode 100644 index d673e2b4f..000000000 --- a/src/auto-reply/reply/session-updates.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; -import { prependSystemEvents } from "./session-updates.js"; - -describe("prependSystemEvents", () => { - it("adds a local timestamp to queued system events by default", async () => { - vi.useFakeTimers(); - const originalTz = process.env.TZ; - process.env.TZ = "America/Los_Angeles"; - const timestamp = new Date("2026-01-12T20:19:17Z"); - vi.setSystemTime(timestamp); - - enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); - - const result = await prependSystemEvents({ - cfg: {} as ClawdbotConfig, - sessionKey: "agent:main:main", - isMainSession: false, - isNewSession: false, - prefixedBodyBase: "User: hi", - }); - - expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./); - - resetSystemEventsForTest(); - process.env.TZ = originalTz; - vi.useRealTimers(); - }); -}); diff --git a/src/auto-reply/reply/session.sender-meta.test.ts b/src/auto-reply/reply/session.sender-meta.test.ts deleted file mode 100644 index 455cfbb11..000000000 --- a/src/auto-reply/reply/session.sender-meta.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../../config/config.js"; -import { initSessionState } from "./session.js"; - -describe("initSessionState sender meta", () => { - it("injects sender meta into BodyStripped for group chats", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as ClawdbotConfig; - - const result = await initSessionState({ - ctx: { - Body: "[WhatsApp 123@g.us] ping", - ChatType: "group", - SenderName: "Bob", - SenderE164: "+222", - SenderId: "222@s.whatsapp.net", - SessionKey: "agent:main:whatsapp:group:123@g.us", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]"); - }); - - it("does not inject sender meta for direct chats", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-")); - const storePath = path.join(root, "sessions.json"); - const cfg = { session: { store: storePath } } as ClawdbotConfig; - - const result = await initSessionState({ - ctx: { - Body: "[WhatsApp +1] ping", - ChatType: "direct", - SenderName: "Bob", - SenderE164: "+222", - SessionKey: "agent:main:whatsapp:dm:+222", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping"); - }); -}); diff --git a/src/auto-reply/reply/streaming-directives.test.ts b/src/auto-reply/reply/streaming-directives.test.ts deleted file mode 100644 index 02d32ded8..000000000 --- a/src/auto-reply/reply/streaming-directives.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; - -describe("createStreamingDirectiveAccumulator", () => { - it("stashes reply_to_current until a renderable chunk arrives", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); - - const result = accumulator.consume("Hello"); - expect(result?.text).toBe("Hello"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("handles reply tags split across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to_")).toBeNull(); - - const result = accumulator.consume("current]] Yo"); - expect(result?.text).toBe("Yo"); - expect(result?.replyToCurrent).toBe(true); - expect(result?.replyToTag).toBe(true); - }); - - it("propagates explicit reply ids across chunks", () => { - const accumulator = createStreamingDirectiveAccumulator(); - - expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); - - const result = accumulator.consume("Hi"); - expect(result?.text).toBe("Hi"); - expect(result?.replyToId).toBe("abc-123"); - expect(result?.replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts deleted file mode 100644 index 064e58adf..000000000 --- a/src/auto-reply/reply/typing-mode.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createMockTypingController } from "./test-helpers.js"; -import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; - -describe("resolveTypingMode", () => { - it("defaults to instant for direct chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("defaults to message for group chats without mentions", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("defaults to instant for mentioned group chats", () => { - expect( - resolveTypingMode({ - configured: undefined, - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("instant"); - }); - - it("honors configured mode across contexts", () => { - expect( - resolveTypingMode({ - configured: "thinking", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: false, - }), - ).toBe("thinking"); - expect( - resolveTypingMode({ - configured: "message", - isGroupChat: true, - wasMentioned: true, - isHeartbeat: false, - }), - ).toBe("message"); - }); - - it("forces never for heartbeat runs", () => { - expect( - resolveTypingMode({ - configured: "instant", - isGroupChat: false, - wasMentioned: false, - isHeartbeat: true, - }), - ).toBe("never"); - }); -}); - -describe("createTypingSignaler", () => { - it("signals immediately for instant mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: false, - }); - - await signaler.signalRunStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals on text for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals on message start for message mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalMessageStart(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hello"); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); - }); - - it("signals on reasoning for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalReasoningDelta(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - await signaler.signalTextDelta("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("refreshes ttl on text for thinking mode", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "thinking", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hi"); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing on tool start before text", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalToolStart(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("refreshes ttl on tool start when active after text", async () => { - const typing = createMockTypingController({ - isActive: vi.fn(() => true), - }); - const signaler = createTypingSignaler({ - typing, - mode: "message", - isHeartbeat: false, - }); - - await signaler.signalTextDelta("hello"); - typing.startTypingLoop.mockClear(); - typing.startTypingOnText.mockClear(); - typing.refreshTypingTtl.mockClear(); - await signaler.signalToolStart(); - - expect(typing.refreshTypingTtl).toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses typing when disabled", async () => { - const typing = createMockTypingController(); - const signaler = createTypingSignaler({ - typing, - mode: "instant", - isHeartbeat: true, - }); - - await signaler.signalRunStart(); - await signaler.signalTextDelta("hi"); - await signaler.signalReasoningDelta(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts index da7033162..06e9003c5 100644 --- a/src/auto-reply/reply/typing.test.ts +++ b/src/auto-reply/reply/typing.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMockTypingController } from "./test-helpers.js"; +import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; import { createTypingController } from "./typing.js"; describe("typing controller", () => { @@ -91,3 +93,192 @@ describe("typing controller", () => { expect(onReplyStart).toHaveBeenCalledTimes(1); }); }); + +describe("resolveTypingMode", () => { + it("defaults to instant for direct chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("defaults to message for group chats without mentions", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("defaults to instant for mentioned group chats", () => { + expect( + resolveTypingMode({ + configured: undefined, + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("instant"); + }); + + it("honors configured mode across contexts", () => { + expect( + resolveTypingMode({ + configured: "thinking", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: false, + }), + ).toBe("thinking"); + expect( + resolveTypingMode({ + configured: "message", + isGroupChat: true, + wasMentioned: true, + isHeartbeat: false, + }), + ).toBe("message"); + }); + + it("forces never for heartbeat runs", () => { + expect( + resolveTypingMode({ + configured: "instant", + isGroupChat: false, + wasMentioned: false, + isHeartbeat: true, + }), + ).toBe("never"); + }); +}); + +describe("createTypingSignaler", () => { + it("signals immediately for instant mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: false, + }); + + await signaler.signalRunStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("signals on text for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals on message start for message mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalMessageStart(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hello"); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); + }); + + it("signals on reasoning for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalReasoningDelta(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + await signaler.signalTextDelta("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("refreshes ttl on text for thinking mode", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "thinking", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hi"); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing on tool start before text", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalToolStart(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("refreshes ttl on tool start when active after text", async () => { + const typing = createMockTypingController({ + isActive: vi.fn(() => true), + }); + const signaler = createTypingSignaler({ + typing, + mode: "message", + isHeartbeat: false, + }); + + await signaler.signalTextDelta("hello"); + typing.startTypingLoop.mockClear(); + typing.startTypingOnText.mockClear(); + typing.refreshTypingTtl.mockClear(); + await signaler.signalToolStart(); + + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses typing when disabled", async () => { + const typing = createMockTypingController(); + const signaler = createTypingSignaler({ + typing, + mode: "instant", + isHeartbeat: true, + }); + + await signaler.signalRunStart(); + await signaler.signalTextDelta("hi"); + await signaler.signalReasoningDelta(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/templating.test.ts b/src/auto-reply/templating.test.ts deleted file mode 100644 index a4be64f4b..000000000 --- a/src/auto-reply/templating.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyTemplate, type TemplateContext } from "./templating.js"; - -describe("applyTemplate", () => { - it("renders primitive values", () => { - const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext; - const overrides = ctx as Record; - overrides.MessageSid = 42; - overrides.IsNewSession = true; - - expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true"); - }); - - it("renders arrays of primitives", () => { - const ctx = { MediaPaths: ["a"] } as TemplateContext; - (ctx as Record).MediaPaths = ["a", 2, true, null, { ok: false }]; - - expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true"); - }); - - it("drops object values", () => { - const ctx: TemplateContext = { CommandArgs: { raw: "go" } }; - - expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args="); - }); - - it("renders missing placeholders as empty", () => { - const ctx: TemplateContext = {}; - - expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing="); - }); -}); From 0eb7e1864cdc8ca4380416570fcd836139f2df2d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:34:04 +0000 Subject: [PATCH 08/24] test: move auto-reply directive coverage to e2e --- ...tive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts} | 0 ...-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts} | 0 ...or.defaults-think-low-reasoning-capable-models-no.e2e.test.ts} | 0 ...-behavior.ignores-inline-model-uses-default-model.e2e.test.ts} | 0 ...tive-behavior.lists-allowlisted-models-model-list.e2e.test.ts} | 0 ...refers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts} | 0 ...vior.requires-per-agent-allowlist-addition-global.e2e.test.ts} | 0 ...vior.returns-status-alongside-directive-only-acks.e2e.test.ts} | 0 ...ehavior.shows-current-elevated-level-as-off-after.e2e.test.ts} | 0 ...havior.shows-current-verbose-level-verbose-has-no.e2e.test.ts} | 0 ...vior.supports-fuzzy-model-matches-model-directive.e2e.test.ts} | 0 ...ior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts} | 0 ...pts.test.ts => reply.triggers.group-intro-prompts.e2e.test.ts} | 0 ...-handling.allows-activation-from-allowfrom-groups.e2e.test.ts} | 0 ...dling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts} | 0 ...ndling.allows-elevated-off-groups-without-mention.e2e.test.ts} | 0 ...ling.filters-usage-summary-current-model-provider.e2e.test.ts} | 0 ...ng.handles-inline-commands-strips-it-before-agent.e2e.test.ts} | 0 ...nores-inline-elevated-directive-unapproved-sender.e2e.test.ts} | 0 ...ndling.includes-error-cause-embedded-agent-throws.e2e.test.ts} | 0 ...handling.keeps-inline-status-unauthorized-senders.e2e.test.ts} | 0 ...ng.reports-active-auth-profile-key-snippet-status.e2e.test.ts} | 0 ...rs.trigger-handling.runs-compact-as-gated-command.e2e.test.ts} | 0 ....trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts} | 0 ...hows-endpoint-default-model-status-not-configured.e2e.test.ts} | 0 ...andling.shows-quick-model-picker-grouped-by-model.e2e.test.ts} | 0 ...igger-handling.targets-active-session-native-stop.e2e.test.ts} | 0 27 files changed, 0 insertions(+), 0 deletions(-) rename src/auto-reply/{reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts => reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts => reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts => reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts => reply.directive.directive-behavior.ignores-inline-model-uses-default-model.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts => reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts => reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts => reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts => reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts => reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts => reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts => reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts} (100%) rename src/auto-reply/{reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts => reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.group-intro-prompts.test.ts => reply.triggers.group-intro-prompts.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts => reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts => reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts => reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts => reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts => reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts => reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts => reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts => reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts => reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts => reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts => reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts => reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts => reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.e2e.test.ts} (100%) rename src/auto-reply/{reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts => reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts} (100%) diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts rename to src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts rename to src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts rename to src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts b/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts rename to src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts rename to src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts rename to src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts rename to src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts b/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts rename to src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts rename to src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts rename to src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts rename to src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts rename to src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.group-intro-prompts.test.ts rename to src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts b/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts From 37e5f077b870f2ff8f8f6fcf340863a6a4a2ffdb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:34:20 +0000 Subject: [PATCH 09/24] test: move gateway server coverage to e2e --- ...-a.test.ts => server.agent.gateway-server-agent-a.e2e.test.ts} | 0 ...-b.test.ts => server.agent.gateway-server-agent-b.e2e.test.ts} | 0 src/gateway/{server.auth.test.ts => server.auth.e2e.test.ts} | 0 .../{server.channels.test.ts => server.channels.e2e.test.ts} | 0 ...at-b.test.ts => server.chat.gateway-server-chat-b.e2e.test.ts} | 0 ...r-chat.test.ts => server.chat.gateway-server-chat.e2e.test.ts} | 0 ...erver.config-apply.test.ts => server.config-apply.e2e.test.ts} | 0 ...erver.config-patch.test.ts => server.config-patch.e2e.test.ts} | 0 src/gateway/{server.cron.test.ts => server.cron.e2e.test.ts} | 0 src/gateway/{server.health.test.ts => server.health.e2e.test.ts} | 0 src/gateway/{server.hooks.test.ts => server.hooks.e2e.test.ts} | 0 ...ver.ios-client-id.test.ts => server.ios-client-id.e2e.test.ts} | 0 ...wake-misc.test.ts => server.models-voicewake-misc.e2e.test.ts} | 0 src/gateway/{server.reload.test.ts => server.reload.e2e.test.ts} | 0 ...t-update.test.ts => server.roles-allowlist-update.e2e.test.ts} | 0 ...ver.sessions-send.test.ts => server.sessions-send.e2e.test.ts} | 0 ...t.ts => server.sessions.gateway-server-sessions-a.e2e.test.ts} | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename src/gateway/{server.agent.gateway-server-agent-a.test.ts => server.agent.gateway-server-agent-a.e2e.test.ts} (100%) rename src/gateway/{server.agent.gateway-server-agent-b.test.ts => server.agent.gateway-server-agent-b.e2e.test.ts} (100%) rename src/gateway/{server.auth.test.ts => server.auth.e2e.test.ts} (100%) rename src/gateway/{server.channels.test.ts => server.channels.e2e.test.ts} (100%) rename src/gateway/{server.chat.gateway-server-chat-b.test.ts => server.chat.gateway-server-chat-b.e2e.test.ts} (100%) rename src/gateway/{server.chat.gateway-server-chat.test.ts => server.chat.gateway-server-chat.e2e.test.ts} (100%) rename src/gateway/{server.config-apply.test.ts => server.config-apply.e2e.test.ts} (100%) rename src/gateway/{server.config-patch.test.ts => server.config-patch.e2e.test.ts} (100%) rename src/gateway/{server.cron.test.ts => server.cron.e2e.test.ts} (100%) rename src/gateway/{server.health.test.ts => server.health.e2e.test.ts} (100%) rename src/gateway/{server.hooks.test.ts => server.hooks.e2e.test.ts} (100%) rename src/gateway/{server.ios-client-id.test.ts => server.ios-client-id.e2e.test.ts} (100%) rename src/gateway/{server.models-voicewake-misc.test.ts => server.models-voicewake-misc.e2e.test.ts} (100%) rename src/gateway/{server.reload.test.ts => server.reload.e2e.test.ts} (100%) rename src/gateway/{server.roles-allowlist-update.test.ts => server.roles-allowlist-update.e2e.test.ts} (100%) rename src/gateway/{server.sessions-send.test.ts => server.sessions-send.e2e.test.ts} (100%) rename src/gateway/{server.sessions.gateway-server-sessions-a.test.ts => server.sessions.gateway-server-sessions-a.e2e.test.ts} (100%) diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts similarity index 100% rename from src/gateway/server.agent.gateway-server-agent-a.test.ts rename to src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts similarity index 100% rename from src/gateway/server.agent.gateway-server-agent-b.test.ts rename to src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.e2e.test.ts similarity index 100% rename from src/gateway/server.auth.test.ts rename to src/gateway/server.auth.e2e.test.ts diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.e2e.test.ts similarity index 100% rename from src/gateway/server.channels.test.ts rename to src/gateway/server.channels.e2e.test.ts diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts similarity index 100% rename from src/gateway/server.chat.gateway-server-chat-b.test.ts rename to src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts similarity index 100% rename from src/gateway/server.chat.gateway-server-chat.test.ts rename to src/gateway/server.chat.gateway-server-chat.e2e.test.ts diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.e2e.test.ts similarity index 100% rename from src/gateway/server.config-apply.test.ts rename to src/gateway/server.config-apply.e2e.test.ts diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.e2e.test.ts similarity index 100% rename from src/gateway/server.config-patch.test.ts rename to src/gateway/server.config-patch.e2e.test.ts diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.e2e.test.ts similarity index 100% rename from src/gateway/server.cron.test.ts rename to src/gateway/server.cron.e2e.test.ts diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.e2e.test.ts similarity index 100% rename from src/gateway/server.health.test.ts rename to src/gateway/server.health.e2e.test.ts diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.e2e.test.ts similarity index 100% rename from src/gateway/server.hooks.test.ts rename to src/gateway/server.hooks.e2e.test.ts diff --git a/src/gateway/server.ios-client-id.test.ts b/src/gateway/server.ios-client-id.e2e.test.ts similarity index 100% rename from src/gateway/server.ios-client-id.test.ts rename to src/gateway/server.ios-client-id.e2e.test.ts diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts similarity index 100% rename from src/gateway/server.models-voicewake-misc.test.ts rename to src/gateway/server.models-voicewake-misc.e2e.test.ts diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.e2e.test.ts similarity index 100% rename from src/gateway/server.reload.test.ts rename to src/gateway/server.reload.e2e.test.ts diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts similarity index 100% rename from src/gateway/server.roles-allowlist-update.test.ts rename to src/gateway/server.roles-allowlist-update.e2e.test.ts diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.e2e.test.ts similarity index 100% rename from src/gateway/server.sessions-send.test.ts rename to src/gateway/server.sessions-send.e2e.test.ts diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts similarity index 100% rename from src/gateway/server.sessions.gateway-server-sessions-a.test.ts rename to src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts From b77e7306577575c6cc50e4c853592988baf149fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 17:56:50 +0000 Subject: [PATCH 10/24] fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) --- CHANGELOG.md | 1 + docs/concepts/markdown-formatting.md | 26 +- extensions/bluebubbles/src/config-schema.ts | 2 + extensions/bluebubbles/src/monitor.test.ts | 2 + extensions/bluebubbles/src/monitor.ts | 18 +- extensions/matrix/src/config-schema.ts | 2 + .../matrix/src/matrix/monitor/handler.ts | 7 + .../matrix/src/matrix/monitor/replies.ts | 19 +- extensions/matrix/src/matrix/send.test.ts | 2 + extensions/matrix/src/matrix/send.ts | 11 +- extensions/matrix/src/matrix/send/types.ts | 1 + extensions/mattermost/src/config-schema.ts | 2 + .../mattermost/src/mattermost/monitor.ts | 7 +- extensions/mattermost/src/mattermost/send.ts | 11 +- extensions/msteams/src/messenger.test.ts | 10 +- extensions/msteams/src/messenger.ts | 13 +- extensions/msteams/src/reply-dispatcher.ts | 5 + extensions/msteams/src/send.ts | 23 +- .../nextcloud-talk/src/config-schema.ts | 2 + extensions/nextcloud-talk/src/send.ts | 12 +- extensions/nostr/src/channel.ts | 9 +- extensions/nostr/src/config-schema.ts | 5 +- extensions/zalo/src/config-schema.ts | 2 + extensions/zalo/src/monitor.ts | 18 +- extensions/zalouser/src/config-schema.ts | 2 + extensions/zalouser/src/monitor.ts | 16 +- src/config/markdown-tables.ts | 60 +++ src/config/types.base.ts | 7 + src/config/types.discord.ts | 3 + src/config/types.imessage.ts | 9 +- src/config/types.msteams.ts | 9 +- src/config/types.signal.ts | 9 +- src/config/types.slack.ts | 3 + src/config/types.telegram.ts | 3 + src/config/types.whatsapp.ts | 11 +- src/config/zod-schema.core.ts | 9 + src/config/zod-schema.providers-core.ts | 8 + src/config/zod-schema.providers-whatsapp.ts | 3 + .../monitor/message-handler.process.ts | 7 + src/discord/monitor/reply-delivery.ts | 7 +- src/discord/send.outbound.ts | 12 +- src/imessage/monitor/deliver.ts | 12 +- src/imessage/send.ts | 10 + src/infra/outbound/deliver.ts | 14 +- src/markdown/ir.table-bullets.test.ts | 56 ++- src/markdown/ir.ts | 360 ++++++++++++------ src/markdown/tables.ts | 34 ++ src/plugin-sdk/index.ts | 4 + src/plugins/runtime/index.ts | 4 + src/plugins/runtime/types.ts | 5 + src/signal/format.ts | 18 +- src/signal/send.ts | 8 +- src/slack/format.ts | 18 +- src/slack/monitor/replies.ts | 6 +- src/slack/monitor/slash.ts | 11 + src/slack/send.ts | 8 +- src/telegram/bot-message-dispatch.ts | 7 + src/telegram/bot-native-commands.ts | 7 + src/telegram/bot/delivery.ts | 10 +- src/telegram/format.ts | 11 +- src/telegram/send.ts | 8 +- src/web/auto-reply/deliver-reply.ts | 7 +- src/web/auto-reply/monitor/process-message.ts | 7 + src/web/outbound.ts | 10 + 64 files changed, 837 insertions(+), 186 deletions(-) create mode 100644 src/config/markdown-tables.ts create mode 100644 src/markdown/tables.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 859c74a48..fb89dd89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. +- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md index da2c1b268..91799a3e9 100644 --- a/docs/concepts/markdown-formatting.md +++ b/docs/concepts/markdown-formatting.md @@ -25,6 +25,7 @@ stay consistent across channels. 1. **Parse Markdown -> IR** - IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. - Offsets are UTF-16 code units so Signal style ranges align with its API. + - Tables are parsed only when a channel opts into table conversion. 2. **Chunk IR (format-first)** - Chunking happens on the IR text before rendering. - Inline formatting does not split across chunks; spans are sliced per chunk. @@ -59,7 +60,30 @@ IR (schematic): - Slack, Telegram, and Signal outbound adapters render from the IR. - Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or - their own formatting rules. + their own formatting rules, with Markdown table conversion applied before + chunking when enabled. + +## Table handling + +Markdown tables are not consistently supported across chat clients. Use +`markdown.tables` to control conversion per channel (and per account). + +- `code`: render tables as code blocks (default for most channels). +- `bullets`: convert each row into bullet points (default for Signal + WhatsApp). +- `off`: disable table parsing and conversion; raw table text passes through. + +Config keys: + +```yaml +channels: + discord: + markdown: + tables: code + accounts: + work: + markdown: + tables: off +``` ## Chunking rules diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 84b389142..9e2f6e50f 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({ const bluebubblesAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, serverUrl: z.string().optional(), password: z.string().optional(), webhookPath: z.string().optional(), diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 96e85e84b..fa40e82a7 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime { chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"], + convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"], }, reply: { dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ab503882d..81a921ca9 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1662,9 +1662,15 @@ async function processMessage( ? [payload.mediaUrl] : []; if (mediaList.length > 0) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; const result = await sendBlueBubblesMedia({ cfg: config, @@ -1686,8 +1692,14 @@ async function processMessage( account.config.textChunkLimit && account.config.textChunkLimit > 0 ? account.config.textChunkLimit : DEFAULT_TEXT_LIMIT; - const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); - if (!chunks.length && payload.text) chunks.push(payload.text); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const chunks = core.channel.text.chunkMarkdownText(text, textLimit); + if (!chunks.length && text) chunks.push(text); if (!chunks.length) return; for (const chunk of chunks) { const result = await sendMessageBlueBubbles(outboundTarget, chunk, { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 3cb396883..2d035dc43 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -35,6 +36,7 @@ const matrixRoomSchema = z export const MatrixConfigSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, homeserver: z.string().optional(), userId: z.string().optional(), accessToken: z.string().optional(), diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 62a7a2c26..49deabbf8 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } let didSendReply = false; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: route.accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + accountId: route.accountId, + tableMode, }); didSendReply = true; }, diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 7a9cc06aa..d2a6e34da 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,6 +1,6 @@ import type { MatrixClient } from "matrix-bot-sdk"; -import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; import { getMatrixRuntime } from "../../runtime.js"; @@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: { textLimit: number; replyToMode: "off" | "first" | "all"; threadId?: string; + accountId?: string; + tableMode?: MarkdownTableMode; }): Promise { const core = getMatrixRuntime(); + const tableMode = + params.tableMode ?? + core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "matrix", + accountId: params.accountId, + }); const logVerbose = (message: string) => { if (core.logging.shouldLogVerbose()) { params.runtime.log?.(message); @@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: { } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl @@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); if (mediaList.length === 0) { - for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) { + for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed) continue; await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; @@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? (reply.text ?? "") : ""; + const caption = first ? text : ""; await sendMessageMatrix(params.roomId, caption, { client: params.client, mediaUrl, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 5520d126e..2f0053ecf 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -43,6 +43,8 @@ const runtimeStub = { text: { resolveTextChunkLimit: () => 4000, chunkMarkdownText: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 634871123..79d20471c 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -50,9 +50,18 @@ export async function sendMessageMatrix( try { const roomId = await resolveMatrixRoomId(client, to); const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); + const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 51b1b1024..eb59f8a62 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -87,6 +87,7 @@ export type MatrixSendResult = { export type MatrixSendOpts = { client?: import("matrix-bot-sdk").MatrixClient; mediaUrl?: string; + accountId?: string; replyToId?: string; threadId?: string | number | null; timeoutMs?: number; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 618747995..40ae8a31a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,6 +4,7 @@ import { BlockStreamingCoalesceSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; @@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), botToken: z.string().optional(), diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 7e5079ecb..cce05f0cb 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { fallbackLimit: account.textChunkLimit ?? 4000, }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), @@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); if (mediaUrls.length === 0) { const chunks = core.channel.text.chunkMarkdownText(text, textLimit); for (const chunk of chunks.length > 0 ? chunks : [text]) { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index c2a2a251c..cd205340d 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -181,6 +181,15 @@ export async function sendMessageMattermost( } } + if (message) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + message = core.channel.text.convertMarkdownTables(message, tableMode); + } + if (!message && (!fileIds || fileIds.length === 0)) { if (uploadError) { throw new Error(`Mattermost media upload failed: ${uploadError.message}`); @@ -205,4 +214,4 @@ export async function sendMessageMattermost( messageId: post.id ?? "unknown", channelId, }; -} \ No newline at end of file +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 04d1f55e1..9fbd628c5 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -21,6 +21,8 @@ const runtimeStub = { } return chunks; }, + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; @@ -34,6 +36,7 @@ describe("msteams messenger", () => { it("filters silent replies", () => { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { textChunkLimit: 4000, + tableMode: "code", }); expect(messages).toEqual([]); }); @@ -41,7 +44,7 @@ describe("msteams messenger", () => { it("filters silent reply prefixes", () => { const messages = renderReplyPayloadsToMessages( [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([]); }); @@ -49,7 +52,7 @@ describe("msteams messenger", () => { it("splits media into separate messages by default", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); }); @@ -57,7 +60,7 @@ describe("msteams messenger", () => { it("supports inline media mode", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000, mediaMode: "inline" }, + { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); }); @@ -66,6 +69,7 @@ describe("msteams messenger", () => { const long = "hello ".repeat(200); const messages = renderReplyPayloadsToMessages([{ text: long }], { textChunkLimit: 50, + tableMode: "code", }); expect(messages.length).toBeGreaterThan(1); }); diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index d6a0b9963..a5eb99b73 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -1,6 +1,7 @@ import { isSilentReplyText, loadWebMedia, + type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, SILENT_REPLY_TOKEN, @@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = { textChunkLimit: number; chunkText?: boolean; mediaMode?: "split" | "inline"; + tableMode?: MarkdownTableMode; }; /** @@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages( const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkText = options.chunkText !== false; const mediaMode = options.mediaMode ?? "split"; + const tableMode = + options.tableMode ?? + getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg: getMSTeamsRuntime().config.loadConfig(), + channel: "msteams", + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode, + ); if (!text && mediaList.length === 0) continue; diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index d50661264..f711c8240 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: { ).responsePrefix, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", + }); const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, chunkText: true, mediaMode: "split", + tableMode, }); const mediaMaxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 83d0cf149..82a4114ef 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -16,6 +16,7 @@ import { import { extractFilename, extractMessageId } from "./media-helpers.js"; import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; import { buildMSTeamsPollCard } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; export type SendMSTeamsMessageParams = { @@ -93,13 +94,21 @@ export async function sendMessageMSTeams( params: SendMSTeamsMessageParams, ): Promise { const { cfg, to, text, mediaUrl } = params; + const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "msteams", + }); + const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables( + text ?? "", + tableMode, + ); const ctx = await resolveMSTeamsSendContext({ cfg, to }); const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug("sending proactive message", { conversationId, conversationType, - textLength: text.length, + textLength: messageText.length, hasMedia: Boolean(mediaUrl), }); @@ -134,7 +143,7 @@ export async function sendMessageMSTeams( const { activity, uploadId } = prepareFileConsentActivity({ media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, conversationId, - description: text || undefined, + description: messageText || undefined, }); log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); @@ -172,14 +181,14 @@ export async function sendMessageMSTeams( const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } if (isImage && !sharePointSiteId) { // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } // Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive @@ -223,7 +232,7 @@ export async function sendMessageMSTeams( const fileCardAttachment = buildTeamsFileInfoCard(driveItem); const activity = { type: "message", - text: text || undefined, + text: messageText || undefined, attachments: [fileCardAttachment], }; @@ -264,7 +273,7 @@ export async function sendMessageMSTeams( const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; const activity = { type: "message", - text: text ? `${text}\n\n${fileLink}` : fileLink, + text: messageText ? `${messageText}\n\n${fileLink}` : fileLink, }; const baseRef = buildConversationReference(ref); @@ -290,7 +299,7 @@ export async function sendMessageMSTeams( } // No media: send text only - return sendTextWithMedia(ctx, text, undefined); + return sendTextWithMedia(ctx, messageText, undefined); } /** diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index c442f6b59..085319d1c 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -3,6 +3,7 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; import { z } from "zod"; @@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, baseUrl: z.string().optional(), botSecret: z.string().optional(), botSecretFile: z.string().optional(), diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index cf55f5509..1dd8f5094 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk( throw new Error("Message must be non-empty for Nextcloud Talk sends"); } + const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "nextcloud-talk", + accountId: account.accountId, + }); + const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables( + text.trim(), + tableMode, + ); + const body: Record = { - message: text.trim(), + message, }; if (opts.replyTo) { body.replyTo = opts.replyTo; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 30f2f7dfc..e6df0872c 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 4000, sendText: async ({ to, text, accountId }) => { + const core = getNostrRuntime(); const aid = accountId ?? DEFAULT_ACCOUNT_ID; const bus = activeBuses.get(aid); if (!bus) { throw new Error(`Nostr bus not running for account ${aid}`); } + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "nostr", + accountId: aid, + }); + const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); - await bus.sendDm(normalizedTo, text); + await bus.sendDm(normalizedTo, message); return { channel: "nostr", to: normalizedTo }; }, }, diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index bb01a068d..08ac773b0 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,5 +1,5 @@ +import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; -import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({ /** Whether this channel is enabled */ enabled: z.boolean().optional(), + /** Markdown formatting overrides (tables). */ + markdown: MarkdownConfigSchema, + /** Private key in hex or nsec bech32 format */ privateKey: z.string().optional(), diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 3ab955848..25e22bd3b 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]); const zaloAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, botToken: z.string().optional(), tokenFile: z.string().optional(), webhookUrl: z.string().optional(), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index cb68388cf..939dcdbde 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: { runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalo", + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, @@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: { core, statusSink, fetcher, + tableMode, }); }, onError: (err, info) => { @@ -608,8 +615,11 @@ async function deliverZaloReply(params: { core: ZaloCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; fetcher?: ZaloFetch; + tableMode?: MarkdownTableMode; }): Promise { const { payload, token, chatId, runtime, core, statusSink, fetcher } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -620,7 +630,7 @@ async function deliverZaloReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); @@ -632,8 +642,8 @@ async function deliverZaloReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT); for (const chunk of chunks) { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index ca36c1c72..bf80d28c0 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -10,6 +11,7 @@ const groupConfigSchema = z.object({ const zalouserAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, profile: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index b3ab31dd3..4015fcc8d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; -import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk"; import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk"; import { sendMessageZalouser } from "./send.js"; import type { @@ -332,6 +332,11 @@ async function processMessage( runtime, core, statusSink, + tableMode: core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalouser", + accountId: account.accountId, + }), }); }, onError: (err, info) => { @@ -351,8 +356,11 @@ async function deliverZalouserReply(params: { runtime: RuntimeEnv; core: ZalouserCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + tableMode?: MarkdownTableMode; }): Promise { const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -363,7 +371,7 @@ async function deliverZalouserReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { logVerbose(core, runtime, `Sending media to ${chatId}`); @@ -380,8 +388,8 @@ async function deliverZalouserReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); for (const chunk of chunks) { try { diff --git a/src/config/markdown-tables.ts b/src/config/markdown-tables.ts new file mode 100644 index 000000000..387ad6cab --- /dev/null +++ b/src/config/markdown-tables.ts @@ -0,0 +1,60 @@ +import { normalizeChannelId } from "../channels/plugins/index.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import type { ClawdbotConfig } from "./config.js"; +import type { MarkdownTableMode } from "./types.base.js"; + +type MarkdownConfigEntry = { + markdown?: { + tables?: MarkdownTableMode; + }; +}; + +type MarkdownConfigSection = MarkdownConfigEntry & { + accounts?: Record; +}; + +const DEFAULT_TABLE_MODES = new Map([ + ["signal", "bullets"], + ["whatsapp", "bullets"], +]); + +const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode => + value === "off" || value === "bullets" || value === "code"; + +function resolveMarkdownModeFromSection( + section: MarkdownConfigSection | undefined, + accountId?: string | null, +): MarkdownTableMode | undefined { + if (!section) return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const accounts = section.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + const directMode = direct?.markdown?.tables; + if (isMarkdownTableMode(directMode)) return directMode; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + const matchMode = match?.markdown?.tables; + if (isMarkdownTableMode(matchMode)) return matchMode; + } + const sectionMode = section.markdown?.tables; + return isMarkdownTableMode(sectionMode) ? sectionMode : undefined; +} + +export function resolveMarkdownTableMode(params: { + cfg?: Partial; + channel?: string | null; + accountId?: string | null; +}): MarkdownTableMode { + const channel = normalizeChannelId(params.channel); + const defaultMode = channel ? (DEFAULT_TABLE_MODES.get(channel) ?? "code") : "code"; + if (!channel || !params.cfg) return defaultMode; + const channelsConfig = params.cfg.channels as Record | undefined; + const section = (channelsConfig?.[channel] ?? + (params.cfg as Record | undefined)?.[channel]) as + | MarkdownConfigSection + | undefined; + return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode; +} diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 2fe689f95..a84736571 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = { breakPreference?: "paragraph" | "newline" | "sentence"; }; +export type MarkdownTableMode = "off" | "bullets" | "code"; + +export type MarkdownConfig = { + /** Table rendering mode (off|bullets|code). */ + tables?: MarkdownTableMode; +}; + export type HumanDelayConfig = { /** Delay style for block replies (off|natural|custom). */ mode?: "off" | "natural" | "custom"; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c8f0a38b3..cdedcb0d7 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -70,6 +71,8 @@ export type DiscordAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Discord (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 37e4c5453..c166fee54 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type IMessageAccountConfig = { @@ -6,6 +11,8 @@ export type IMessageAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this iMessage account. Default: true. */ diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index f18dccb14..170c64e47 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type MSTeamsWebhookConfig = { @@ -34,6 +39,8 @@ export type MSTeamsConfig = { enabled?: boolean; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Azure Bot App ID (from Azure Bot registration). */ diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index c71d97169..f46fb0f8f 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; @@ -8,6 +13,8 @@ export type SignalAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Signal account. Default: true. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index f0e9e1f21..e2ca63b3c 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -80,6 +81,8 @@ export type SlackAccountConfig = { webhookPath?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Slack (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 3533d6d4f..02a822c13 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -3,6 +3,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -35,6 +36,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: TelegramCapabilitiesConfig; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Custom commands to register in Telegram's command menu (merged with native). */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 28ed34c56..90b5497d4 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type WhatsAppActionConfig = { @@ -12,6 +17,8 @@ export type WhatsAppConfig = { accounts?: Record; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Send read receipts for incoming messages (default true). */ @@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this WhatsApp account provider. Default: true. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 01427ab86..7bdf86bdf 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z }) .strict(); +export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]); + +export const MarkdownConfigSchema = z + .object({ + tables: MarkdownTableModeSchema.optional(), + }) + .strict() + .optional(); + export const HumanDelaySchema = z .object({ mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f687253c..12f6cbb3d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -7,6 +7,7 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, + MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, ReplyToModeSchema, @@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, customCommands: z.array(TelegramCustomCommandSchema).optional(), @@ -193,6 +195,7 @@ export const DiscordAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -296,6 +299,7 @@ export const SlackAccountSchema = z signingSecret: z.string().optional(), webhookPath: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), account: z.string().optional(), @@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), @@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), serverUrl: z.string().optional(), @@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z .object({ enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), appId: z.string().optional(), appPassword: z.string().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 6de67790d..de6cda2f8 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -5,12 +5,14 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, } from "./zod-schema.core.js"; export const WhatsAppAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), @@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z .object({ accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 4838c9d44..ad1e4baea 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -27,6 +27,7 @@ import { resolveStorePath, updateLastRoute, } from "../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; @@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, textLimit, maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + tableMode, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index e7713af1e..f54efb1b9 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -1,6 +1,8 @@ import type { RequestClient } from "@buape/carbon"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import { chunkDiscordText } from "../chunk.js"; import { sendMessageDiscord } from "../send.js"; @@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: { textLimit: number; maxLinesPerMessage?: number; replyToId?: string; + tableMode?: MarkdownTableMode; }) { const chunkLimit = Math.min(params.textLimit, 2000); for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const tableMode = params.tableMode ?? "code"; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; const replyTo = params.replyToId?.trim() || undefined; diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 51d5742e0..3c83f7b94 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -1,7 +1,9 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import type { RetryConfig } from "../infra/retry.js"; import type { PollInput } from "../polls.js"; import { resolveDiscordAccount } from "./accounts.js"; @@ -38,6 +40,12 @@ export async function sendMessageDiscord( cfg, accountId: opts.accountId, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId: accountInfo.accountId, + }); + const textWithTables = convertMarkdownTables(text ?? "", tableMode); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient, request); @@ -47,7 +55,7 @@ export async function sendMessageDiscord( result = await sendDiscordMedia( rest, channelId, - text, + textWithTables, opts.mediaUrl, opts.replyTo, request, @@ -58,7 +66,7 @@ export async function sendMessageDiscord( result = await sendDiscordText( rest, channelId, - text, + textWithTables, opts.replyTo, request, accountInfo.config.maxLinesPerMessage, diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 779cbd3e5..aa3c6dbb1 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -1,4 +1,7 @@ import { chunkText } from "../../auto-reply/chunk.js"; +import { loadConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; @@ -14,9 +17,16 @@ export async function deliverReplies(params: { textLimit: number; }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params; + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId, + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { for (const chunk of chunkText(text, textLimit)) { diff --git a/src/imessage/send.ts b/src/imessage/send.ts index 32e963bc8..30972ef09 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -1,7 +1,9 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { resolveIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; @@ -88,6 +90,14 @@ export async function sendMessageIMessage( if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } + if (message.trim()) { + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId: account.accountId, + }); + message = convertMarkdownTables(message, tableMode); + } const params: Record = { text: message, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 21fffe807..73f5550e0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import type { sendMessageDiscord } from "../../discord/send.js"; import type { sendMessageIMessage } from "../../imessage/send.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; @@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: { }) : undefined; const isSignalChannel = channel === "signal"; + const signalTableMode = isSignalChannel + ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) + : "code"; const signalMaxBytes = isSignalChannel ? resolveChannelMediaMaxBytes({ cfg, @@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: { throwIfAborted(abortSignal); let signalChunks = textLimit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY) - : markdownToSignalTextChunks(text, textLimit); + ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + }) + : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); if (signalChunks.length === 0 && text) { signalChunks = [{ text, styles: [] }]; } @@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: { const sendSignalMedia = async (caption: string, mediaUrl: string) => { throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? { + const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + })[0] ?? { text: caption, styles: [], }; diff --git a/src/markdown/ir.table-bullets.test.ts b/src/markdown/ir.table-bullets.test.ts index 841c922fe..358cb7eac 100644 --- a/src/markdown/ir.table-bullets.test.ts +++ b/src/markdown/ir.table-bullets.test.ts @@ -11,7 +11,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should contain bullet points with header:value format expect(ir.text).toContain("• Value: 1"); expect(ir.text).toContain("• Value: 2"); @@ -29,7 +29,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // First column becomes row label expect(ir.text).toContain("Speed"); expect(ir.text).toContain("Scale"); @@ -40,22 +40,20 @@ describe("markdownToIR tableMode bullets", () => { expect(ir.text).toContain("• Postgres: Large"); }); - it("preserves flat mode as default", () => { + it("leaves table syntax untouched by default", () => { const md = ` | A | B | |---|---| | 1 | 2 | `.trim(); - const ir = markdownToIR(md); // default is flat - - // Flat mode uses tabs - expect(ir.text).toContain("A"); - expect(ir.text).toContain("B"); - expect(ir.text).toContain("1"); - expect(ir.text).toContain("2"); - // Should not have bullet formatting + const ir = markdownToIR(md); + + // No table conversion by default + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); expect(ir.text).not.toContain("•"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(false); }); it("handles empty cells gracefully", () => { @@ -67,7 +65,7 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should handle empty cell without crashing expect(ir.text).toContain("B"); expect(ir.text).toContain("• Value: 2"); @@ -81,11 +79,41 @@ describe("markdownToIR tableMode bullets", () => { `.trim(); const ir = markdownToIR(md, { tableMode: "bullets" }); - + // Should have bold style for row label const hasRowLabelBold = ir.styles.some( - (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1" + (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1", ); expect(hasRowLabelBold).toBe(true); }); + + it("renders tables as code blocks in code mode", () => { + const md = ` +| A | B | +|---|---| +| 1 | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "code" }); + + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(true); + }); + + it("preserves inline styles and links in bullets mode", () => { + const md = ` +| Name | Value | +|------|-------| +| _Row_ | [Link](https://example.com) | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + const hasItalic = ir.styles.some( + (s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row", + ); + expect(hasItalic).toBe(true); + expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true); + }); }); diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index 5351fa32c..186abeda0 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import { chunkText } from "../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type ListState = { type: "bullet" | "ordered"; @@ -12,24 +13,8 @@ type LinkState = { labelStart: number; }; -type TableCell = { - content: string; - isHeader: boolean; -}; - -type TableRow = TableCell[]; - -type TableState = { - headers: string[]; - rows: TableRow[]; - currentRow: TableCell[]; - currentCell: string; - inHeader: boolean; -}; - type RenderEnv = { listStack: ListState[]; - linkStack: LinkState[]; }; type MarkdownToken = { @@ -65,19 +50,36 @@ type OpenStyle = { start: number; }; -export type TableRenderMode = "flat" | "bullets"; - -type RenderState = { +type RenderTarget = { text: string; styles: MarkdownStyleSpan[]; openStyles: OpenStyle[]; links: MarkdownLinkSpan[]; + linkStack: LinkState[]; +}; + +type TableCell = { + text: string; + styles: MarkdownStyleSpan[]; + links: MarkdownLinkSpan[]; +}; + +type TableState = { + headers: TableCell[]; + rows: TableCell[][]; + currentRow: TableCell[]; + currentCell: RenderTarget | null; + inHeader: boolean; +}; + +type RenderState = RenderTarget & { env: RenderEnv; headingStyle: "none" | "bold"; blockquotePrefix: string; enableSpoilers: boolean; - tableMode: TableRenderMode; + tableMode: MarkdownTableMode; table: TableState | null; + hasTables: boolean; }; export type MarkdownParseOptions = { @@ -86,8 +88,8 @@ export type MarkdownParseOptions = { headingStyle?: "none" | "bold"; blockquotePrefix?: string; autolink?: boolean; - /** How to render tables: "flat" (tabs/newlines) or "bullets" (nested bullet list). Default: "flat" */ - tableMode?: TableRenderMode; + /** How to render tables (off|bullets|code). Default: off. */ + tableMode?: MarkdownTableMode; }; function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { @@ -98,7 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { typographer: false, }); md.enable("strikethrough"); - md.enable("table"); + if (options.tableMode && options.tableMode !== "off") { + md.enable("table"); + } else { + md.disable("table"); + } if (options.autolink === false) { md.disable("autolink"); } @@ -166,28 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { return result; } +function initRenderTarget(): RenderTarget { + return { + text: "", + styles: [], + openStyles: [], + links: [], + linkStack: [], + }; +} + +function resolveRenderTarget(state: RenderState): RenderTarget { + return state.table?.currentCell ?? state; +} + function appendText(state: RenderState, value: string) { if (!value) return; - // If we're inside a table cell in bullets mode, collect into cell buffer - if (state.table && state.tableMode === "bullets") { - state.table.currentCell += value; - return; - } - state.text += value; + const target = resolveRenderTarget(state); + target.text += value; } function openStyle(state: RenderState, style: MarkdownStyle) { - state.openStyles.push({ style, start: state.text.length }); + const target = resolveRenderTarget(state); + target.openStyles.push({ style, start: target.text.length }); } function closeStyle(state: RenderState, style: MarkdownStyle) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - if (state.openStyles[i]?.style === style) { - const start = state.openStyles[i].start; - state.openStyles.splice(i, 1); - const end = state.text.length; + const target = resolveRenderTarget(state); + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + if (target.openStyles[i]?.style === style) { + const start = target.openStyles[i].start; + target.openStyles.splice(i, 1); + const end = target.text.length; if (end > start) { - state.styles.push({ start, end, style }); + target.styles.push({ start, end, style }); } return; } @@ -212,39 +230,37 @@ function appendListPrefix(state: RenderState) { function renderInlineCode(state: RenderState, content: string) { if (!content) return; - // In bullets mode inside table, just add text without styling - if (state.table && state.tableMode === "bullets") { - state.table.currentCell += content; - return; - } - const start = state.text.length; - state.text += content; - state.styles.push({ start, end: start + content.length, style: "code" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += content; + target.styles.push({ start, end: start + content.length, style: "code" }); } function renderCodeBlock(state: RenderState, content: string) { let code = content ?? ""; if (!code.endsWith("\n")) code = `${code}\n`; - const start = state.text.length; - state.text += code; - state.styles.push({ start, end: start + code.length, style: "code_block" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += code; + target.styles.push({ start, end: start + code.length, style: "code_block" }); if (state.env.listStack.length === 0) { - state.text += "\n"; + target.text += "\n"; } } function handleLinkClose(state: RenderState) { - const link = state.env.linkStack.pop(); + const target = resolveRenderTarget(state); + const link = target.linkStack.pop(); if (!link?.href) return; const href = link.href.trim(); if (!href) return; const start = link.labelStart; - const end = state.text.length; + const end = target.text.length; if (end <= start) { - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); return; } - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); } function initTableState(): TableState { @@ -252,14 +268,72 @@ function initTableState(): TableState { headers: [], rows: [], currentRow: [], - currentCell: "", + currentCell: null, inHeader: false, }; } +function finishTableCell(cell: RenderTarget): TableCell { + closeRemainingStyles(cell); + return { + text: cell.text, + styles: cell.styles, + links: cell.links, + }; +} + +function trimCell(cell: TableCell): TableCell { + const text = cell.text; + let start = 0; + let end = text.length; + while (start < end && /\s/.test(text[start] ?? "")) start += 1; + while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1; + if (start === 0 && end === text.length) return cell; + const trimmedText = text.slice(start, end); + const trimmedLength = trimmedText.length; + const trimmedStyles: MarkdownStyleSpan[] = []; + for (const span of cell.styles) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style }); + } + } + const trimmedLinks: MarkdownLinkSpan[] = []; + for (const span of cell.links) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href }); + } + } + return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks }; +} + +function appendCell(state: RenderState, cell: TableCell) { + if (!cell.text) return; + const start = state.text.length; + state.text += cell.text; + for (const span of cell.styles) { + state.styles.push({ + start: start + span.start, + end: start + span.end, + style: span.style, + }); + } + for (const link of cell.links) { + state.links.push({ + start: start + link.start, + end: start + link.end, + href: link.href, + }); + } +} + function renderTableAsBullets(state: RenderState) { if (!state.table) return; - const { headers, rows } = state.table; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); // If no headers or rows, skip if (headers.length === 0 && rows.length === 0) return; @@ -273,22 +347,31 @@ function renderTableAsBullets(state: RenderState) { for (const row of rows) { if (row.length === 0) continue; - const rowLabel = row[0]?.content?.trim() || ""; - if (rowLabel) { - // Bold the row label - const start = state.text.length; - state.text += rowLabel; - state.styles.push({ start, end: state.text.length, style: "bold" }); + const rowLabel = row[0]; + if (rowLabel?.text) { + const labelStart = state.text.length; + appendCell(state, rowLabel); + const labelEnd = state.text.length; + if (labelEnd > labelStart) { + state.styles.push({ start: labelStart, end: labelEnd, style: "bold" }); + } state.text += "\n"; } // Add each column as a bullet point for (let i = 1; i < row.length; i++) { - const header = headers[i]?.trim() || `Column ${i}`; - const value = row[i]?.content?.trim() || ""; - if (value) { - state.text += `• ${header}: ${value}\n`; + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; + } else { + state.text += `Column ${i}: `; } + appendCell(state, value); + state.text += "\n"; } state.text += "\n"; } @@ -296,37 +379,77 @@ function renderTableAsBullets(state: RenderState) { // Simple table: just list headers and values for (const row of rows) { for (let i = 0; i < row.length; i++) { - const header = headers[i]?.trim() || ""; - const value = row[i]?.content?.trim() || ""; - if (header && value) { - state.text += `• ${header}: ${value}\n`; - } else if (value) { - state.text += `• ${value}\n`; + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; } + appendCell(state, value); + state.text += "\n"; } state.text += "\n"; } } } -function renderTableAsFlat(state: RenderState) { +function renderTableAsCode(state: RenderState) { if (!state.table) return; - const { headers, rows } = state.table; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); - // Render headers - for (const header of headers) { - state.text += header.trim() + "\t"; - } - if (headers.length > 0) { - state.text = state.text.trimEnd() + "\n"; - } + const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); + if (columnCount === 0) return; - // Render rows - for (const row of rows) { - for (const cell of row) { - state.text += cell.content.trim() + "\t"; + const widths = Array.from({ length: columnCount }, () => 0); + const updateWidths = (cells: TableCell[]) => { + for (let i = 0; i < columnCount; i += 1) { + const cell = cells[i]; + const width = cell?.text.length ?? 0; + if (widths[i] < width) widths[i] = width; } - state.text = state.text.trimEnd() + "\n"; + }; + updateWidths(headers); + for (const row of rows) updateWidths(row); + + const codeStart = state.text.length; + + const appendRow = (cells: TableCell[]) => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + state.text += " "; + const cell = cells[i]; + if (cell) appendCell(state, cell); + const pad = widths[i] - (cell?.text.length ?? 0); + if (pad > 0) state.text += " ".repeat(pad); + state.text += " |"; + } + state.text += "\n"; + }; + + const appendDivider = () => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + const dashCount = Math.max(3, widths[i]); + state.text += ` ${"-".repeat(dashCount)} |`; + } + state.text += "\n"; + }; + + appendRow(headers); + appendDivider(); + for (const row of rows) { + appendRow(row); + } + + const codeEnd = state.text.length; + if (codeEnd > codeStart) { + state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" }); + } + if (state.env.listStack.length === 0) { + state.text += "\n"; } } @@ -368,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { break; case "link_open": { const href = getAttr(token, "href") ?? ""; - state.env.linkStack.push({ href, labelStart: state.text.length }); + const target = resolveRenderTarget(state); + target.linkStack.push({ href, labelStart: target.text.length }); break; } case "link_close": @@ -428,15 +552,18 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { // Table handling case "table_open": - if (state.tableMode === "bullets") { + if (state.tableMode !== "off") { state.table = initTableState(); + state.hasTables = true; } break; case "table_close": - if (state.tableMode === "bullets" && state.table) { - renderTableAsBullets(state); - } else if (state.tableMode === "flat" && state.table) { - renderTableAsFlat(state); + if (state.table) { + if (state.tableMode === "bullets") { + renderTableAsBullets(state); + } else if (state.tableMode === "code") { + renderTableAsCode(state); + } } state.table = null; break; @@ -461,33 +588,24 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "tr_close": if (state.table) { if (state.table.inHeader) { - state.table.headers = state.table.currentRow.map((c) => c.content); + state.table.headers = state.table.currentRow; } else { state.table.rows.push(state.table.currentRow); } state.table.currentRow = []; - } else if (state.tableMode === "flat") { - // Legacy flat mode without table state - state.text += "\n"; } break; case "th_open": case "td_open": if (state.table) { - state.table.currentCell = ""; + state.table.currentCell = initRenderTarget(); } break; case "th_close": case "td_close": - if (state.table) { - state.table.currentRow.push({ - content: state.table.currentCell, - isHeader: token.type === "th_close", - }); - state.table.currentCell = ""; - } else if (state.tableMode === "flat") { - // Legacy flat mode without table state - state.text += "\t"; + if (state.table?.currentCell) { + state.table.currentRow.push(finishTableCell(state.table.currentCell)); + state.table.currentCell = null; } break; @@ -501,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { } } -function closeRemainingStyles(state: RenderState) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - const open = state.openStyles[i]; - const end = state.text.length; +function closeRemainingStyles(target: RenderTarget) { + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + const open = target.openStyles[i]; + const end = target.text.length; if (end > open.start) { - state.styles.push({ + target.styles.push({ start: open.start, end, style: open.style, }); } } - state.openStyles = []; + target.openStyles = []; } function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] { @@ -594,26 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number): } export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR { - const env: RenderEnv = { listStack: [], linkStack: [] }; + return markdownToIRWithMeta(markdown, options).ir; +} + +export function markdownToIRWithMeta( + markdown: string, + options: MarkdownParseOptions = {}, +): { ir: MarkdownIR; hasTables: boolean } { + const env: RenderEnv = { listStack: [] }; const md = createMarkdownIt(options); const tokens = md.parse(markdown ?? "", env as unknown as object); if (options.enableSpoilers) { applySpoilerTokens(tokens as MarkdownToken[]); } - const tableMode = options.tableMode ?? "flat"; + const tableMode = options.tableMode ?? "off"; const state: RenderState = { text: "", styles: [], openStyles: [], links: [], + linkStack: [], env, headingStyle: options.headingStyle ?? "none", blockquotePrefix: options.blockquotePrefix ?? "", enableSpoilers: options.enableSpoilers ?? false, tableMode, table: null, + hasTables: false, }; renderTokens(tokens as MarkdownToken[], state); @@ -631,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = { finalLength === state.text.length ? state.text : state.text.slice(0, finalLength); return { - text: finalText, - styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), - links: clampLinkSpans(state.links, finalLength), + ir: { + text: finalText, + styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), + links: clampLinkSpans(state.links, finalLength), + }, + hasTables: state.hasTables, }; } diff --git a/src/markdown/tables.ts b/src/markdown/tables.ts new file mode 100644 index 000000000..9ae2b750e --- /dev/null +++ b/src/markdown/tables.ts @@ -0,0 +1,34 @@ +import type { MarkdownTableMode } from "../config/types.base.js"; +import { markdownToIRWithMeta } from "./ir.js"; +import { renderMarkdownWithMarkers } from "./render.js"; + +const MARKDOWN_STYLE_MARKERS = { + bold: { open: "**", close: "**" }, + italic: { open: "_", close: "_" }, + strikethrough: { open: "~~", close: "~~" }, + code: { open: "`", close: "`" }, + code_block: { open: "```\n", close: "```" }, +} as const; + +export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string { + if (!markdown || mode === "off") return markdown; + const { ir, hasTables } = markdownToIRWithMeta(markdown, { + linkify: false, + autolink: false, + headingStyle: "none", + blockquotePrefix: "", + tableMode: mode, + }); + if (!hasTables) return markdown; + return renderMarkdownWithMarkers(ir, { + styleMarkers: MARKDOWN_STYLE_MARKERS, + escapeText: (text) => text, + buildLink: (link, text) => { + const href = link.href.trim(); + if (!href) return null; + const label = text.slice(link.start, link.end); + if (!label) return null; + return { start: link.start, end: link.end, open: "[", close: `](${href})` }; + }, + }); +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 45a4681c7..72bb72422 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -73,6 +73,8 @@ export type { DmPolicy, DmConfig, GroupPolicy, + MarkdownConfig, + MarkdownTableMode, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, @@ -92,6 +94,8 @@ export { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, + MarkdownTableModeSchema, normalizeAllowFrom, requireOpenAllowFrom, } from "../config/zod-schema.core.js"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 4765c71c7..6bb10984b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -34,6 +34,7 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveStateDir } from "../../config/paths.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { @@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { probeIMessage } from "../../imessage/probe.js"; import { sendMessageIMessage } from "../../imessage/send.js"; import { shouldLogVerbose } from "../../globals.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import { getChildLogger } from "../../logging.js"; import { normalizeLogLevel } from "../../logging/levels.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; @@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime { chunkText, resolveTextChunkLimit, hasControlCommand, + resolveMarkdownTableMode, + convertMarkdownTables, }, reply: { dispatchReplyWithBufferedBlockDispatcher, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 089e20c37..1f321d04b 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers = type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; +type ResolveMarkdownTableMode = + typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode; +type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables; type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand; type IsControlCommandMessage = typeof import("../../auto-reply/command-detection.js").isControlCommandMessage; @@ -168,6 +171,8 @@ export type PluginRuntime = { chunkText: ChunkText; resolveTextChunkLimit: ResolveTextChunkLimit; hasControlCommand: HasControlCommand; + resolveMarkdownTableMode: ResolveMarkdownTableMode; + convertMarkdownTables: ConvertMarkdownTables; }; reply: { dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; diff --git a/src/signal/format.ts b/src/signal/format.ts index 0890ce608..127884e89 100644 --- a/src/signal/format.ts +++ b/src/signal/format.ts @@ -4,6 +4,7 @@ import { type MarkdownIR, type MarkdownStyle, } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; @@ -18,6 +19,10 @@ export type SignalFormattedText = { styles: SignalTextStyleRange[]; }; +type SignalMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + type SignalStyleSpan = { start: number; end: number; @@ -188,22 +193,31 @@ function renderSignalText(ir: MarkdownIR): SignalFormattedText { }; } -export function markdownToSignalText(markdown: string): SignalFormattedText { +export function markdownToSignalText( + markdown: string, + options: SignalMarkdownOptions = {}, +): SignalFormattedText { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); return renderSignalText(ir); } -export function markdownToSignalTextChunks(markdown: string, limit: number): SignalFormattedText[] { +export function markdownToSignalTextChunks( + markdown: string, + limit: number, + options: SignalMarkdownOptions = {}, +): SignalFormattedText[] { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => renderSignalText(chunk)); diff --git a/src/signal/send.ts b/src/signal/send.ts index dce4cda7a..32ca09094 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; @@ -164,7 +165,12 @@ export async function sendMessageSignal( if (textMode === "plain") { textStyles = opts.textStyles ?? []; } else { - const formatted = markdownToSignalText(message); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "signal", + accountId: accountInfo.accountId, + }); + const formatted = markdownToSignalText(message, { tableMode }); message = formatted.text; textStyles = formatted.styles; } diff --git a/src/slack/format.ts b/src/slack/format.ts index 575841921..7f44b5df2 100644 --- a/src/slack/format.ts +++ b/src/slack/format.ts @@ -1,4 +1,5 @@ import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; // Escape special characters for Slack mrkdwn format. @@ -83,12 +84,20 @@ function buildSlackLink(link: MarkdownLinkSpan, text: string) { }; } -export function markdownToSlackMrkdwn(markdown: string): string { +type SlackMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + +export function markdownToSlackMrkdwn( + markdown: string, + options: SlackMarkdownOptions = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); return renderMarkdownWithMarkers(ir, { styleMarkers: { @@ -103,12 +112,17 @@ export function markdownToSlackMrkdwn(markdown: string): string { }); } -export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] { +export function markdownToSlackMrkdwnChunks( + markdown: string, + limit: number, + options: SlackMarkdownOptions = {}, +): string[] { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index 59c9d8164..ca4635123 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -1,6 +1,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import type { RuntimeEnv } from "../../runtime.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack } from "../send.js"; @@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: { respond: SlackRespondFn; ephemeral: boolean; textLimit: number; + tableMode?: MarkdownTableMode; }) { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); @@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: { .filter(Boolean) .join("\n"); if (!combined) continue; - for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) { + for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, { + tableMode: params.tableMode, + })) { messages.push(chunk); } } diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d8e97dd43..8f290d892 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); }, onError: (err, info) => { @@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); } } catch (err) { diff --git a/src/slack/send.ts b/src/slack/send.ts index 06de9770d..3759b2826 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { parseSlackTarget } from "./targets.js"; import { resolveSlackBotToken } from "./token.js"; @@ -143,7 +144,12 @@ export async function sendMessageSlack( const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: account.accountId, + }); + const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode }); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 79f57a28c..4afbaa653 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({ let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, onVoiceRecording: sendRecordVoice, }); }, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 11e83dcc3..c3d3a7b74 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; @@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({ id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const systemPromptParts = [ groupConfig?.systemPrompt?.trim() || null, @@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, }); }, onError: (err, info) => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index cb6356061..e05b224da 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js"; import { splitTelegramCaption } from "../caption.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -26,6 +27,7 @@ export async function deliverReplies(params: { replyToMode: ReplyToMode; textLimit: number; messageThreadId?: number; + tableMode?: MarkdownTableMode; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; }) { @@ -49,7 +51,9 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { - const chunks = markdownToTelegramChunks(reply.text || "", textLimit); + const chunks = markdownToTelegramChunks(reply.text || "", textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: @@ -139,7 +143,9 @@ export async function deliverReplies(params: { // Send deferred follow-up text right after the first media item. // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { - const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); + const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 8b08a35f0..b0472c69c 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -5,6 +5,7 @@ import { type MarkdownIR, } from "../markdown/ir.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; export type TelegramFormattedChunk = { html: string; @@ -46,12 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string { }); } -export function markdownToTelegramHtml(markdown: string): string { +export function markdownToTelegramHtml( + markdown: string, + options: { tableMode?: MarkdownTableMode } = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", - tableMode: "bullets", + tableMode: options.tableMode, }); return renderTelegramHtml(ir); } @@ -59,12 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string { export function markdownToTelegramChunks( markdown: string, limit: number, + options: { tableMode?: MarkdownTableMode } = {}, ): TelegramFormattedChunk[] { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", - tableMode: "bullets", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => ({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 253db203e..01120d354 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -310,7 +311,12 @@ export async function sendMessageTelegram( throw new Error("Message must be non-empty for Telegram sends"); } const textMode = opts.textMode ?? "markdown"; - const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text, { tableMode }); const textParams = hasThreadParams ? { parse_mode: "HTML" as const, diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 294589548..2204a9e8f 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -1,4 +1,6 @@ import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { loadWebMedia } from "../media.js"; @@ -19,10 +21,13 @@ export async function deliverWebReply(params: { }; connectionId?: string; skipLog?: boolean; + tableMode?: MarkdownTableMode; }) { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); - const textChunks = chunkMarkdownText(replyResult.text || "", textLimit); + const tableMode = params.tableMode ?? "code"; + const convertedText = convertMarkdownTables(replyResult.text || "", tableMode); + const textChunks = chunkMarkdownText(convertedText, textLimit); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index ea9895853..c1d280a65 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -28,6 +28,7 @@ import { recordSessionMetaFromInbound, resolveStorePath, } from "../../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; @@ -235,6 +236,11 @@ export async function processMessage(params: { : undefined; const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); + const tableMode = resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + }); let didLogHeartbeatStrip = false; let didSendReply = false; const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg) @@ -345,6 +351,7 @@ export async function processMessage(params: { connectionId: params.connectionId, // Tool + block updates are noisy; skip their log lines. skipLog: info.kind !== "final", + tableMode, }); didSendReply = true; if (info.kind === "tool") { diff --git a/src/web/outbound.ts b/src/web/outbound.ts index d67abb2a1..0ca867961 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -4,6 +4,9 @@ import { getChildLogger } from "../logging/logger.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; +import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; @@ -25,6 +28,13 @@ export async function sendMessageWhatsApp( const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( options.accountId, ); + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "whatsapp", + accountId: resolvedAccountId ?? options.accountId, + }); + text = convertMarkdownTables(text ?? "", tableMode); const logger = getChildLogger({ module: "web-outbound", correlationId, From 1af227b61995fb3f4f6c376fe7d2a78d044ed71e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:41:02 +0000 Subject: [PATCH 11/24] fix: forward unknown TUI slash commands --- CHANGELOG.md | 1 + src/tui/tui-command-handlers.test.ts | 47 ++++++++++++++++++++++++++++ src/tui/tui-command-handlers.ts | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/tui/tui-command-handlers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb89dd89f..889c8ef67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes +- TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts new file mode 100644 index 000000000..fc2ac4fa6 --- /dev/null +++ b/src/tui/tui-command-handlers.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCommandHandlers } from "./tui-command-handlers.js"; + +describe("tui command handlers", () => { + it("forwards unknown slash commands to the gateway", async () => { + const sendChat = vi.fn().mockResolvedValue({ runId: "r1" }); + const addUser = vi.fn(); + const addSystem = vi.fn(); + const requestRender = vi.fn(); + const setActivityStatus = vi.fn(); + + const { handleCommand } = createCommandHandlers({ + client: { sendChat } as never, + chatLog: { addUser, addSystem } as never, + tui: { requestRender } as never, + opts: {}, + state: { + currentSessionKey: "agent:main:main", + activeChatRunId: null, + sessionInfo: {}, + } as never, + deliverDefault: false, + openOverlay: vi.fn(), + closeOverlay: vi.fn(), + refreshSessionInfo: vi.fn(), + loadHistory: vi.fn(), + setSession: vi.fn(), + refreshAgents: vi.fn(), + abortActive: vi.fn(), + setActivityStatus, + formatSessionKey: vi.fn(), + }); + + await handleCommand("/context"); + + expect(addSystem).not.toHaveBeenCalled(); + expect(addUser).toHaveBeenCalledWith("/context"); + expect(sendChat).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "/context", + }), + ); + expect(requestRender).toHaveBeenCalled(); + }); +}); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 79765b5fc..7bedb4d62 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -428,7 +428,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { process.exit(0); break; default: - chatLog.addSystem(`unknown command: /${name}`); + await sendMessage(raw); break; } tui.requestRender(); From 8195497cecd0422ccf69ae9f17ef59976883590b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:58:41 +0000 Subject: [PATCH 12/24] fix: surface gateway slash commands in TUI --- CHANGELOG.md | 1 + src/tui/commands.test.ts | 8 +++++++- src/tui/commands.ts | 20 +++++++++++++++++++- src/tui/tui.ts | 1 + 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 889c8ef67..d9b07c888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. +- TUI: include Gateway slash commands in autocomplete and `/help`. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index 2c0fde55d..43be20733 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseCommand } from "./commands.js"; +import { getSlashCommands, parseCommand } from "./commands.js"; describe("tui slash commands", () => { it("treats /elev as an alias for /elevated", () => { @@ -13,4 +13,10 @@ describe("tui slash commands", () => { args: "off", }); }); + + it("includes gateway text commands", () => { + const commands = getSlashCommands({}); + expect(commands.some((command) => command.name === "context")).toBe(true); + expect(commands.some((command) => command.name === "commands")).toBe(true); + }); }); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 59806cfbd..04f40bd2c 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -1,5 +1,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui"; +import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js"; import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js"; +import type { ClawdbotConfig } from "../config/types.js"; const VERBOSE_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"]; @@ -13,6 +15,7 @@ export type ParsedCommand = { }; export type SlashCommandOptions = { + cfg?: ClawdbotConfig; provider?: string; model?: string; }; @@ -34,7 +37,7 @@ export function parseCommand(input: string): ParsedCommand { export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] { const thinkLevels = listThinkingLevelLabels(options.provider, options.model); - return [ + const commands: SlashCommand[] = [ { name: "help", description: "Show slash command help" }, { name: "status", description: "Show gateway status summary" }, { name: "agent", description: "Switch agent (or open picker)" }, @@ -115,6 +118,20 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman { name: "exit", description: "Exit the TUI" }, { name: "quit", description: "Exit the TUI" }, ]; + + const seen = new Set(commands.map((command) => command.name)); + const gatewayCommands = options.cfg ? listChatCommandsForConfig(options.cfg) : listChatCommands(); + for (const command of gatewayCommands) { + const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`]; + for (const alias of aliases) { + const name = alias.replace(/^\//, "").trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + commands.push({ name, description: command.description }); + } + } + + return commands; } export function helpText(options: SlashCommandOptions = {}): string { @@ -122,6 +139,7 @@ export function helpText(options: SlashCommandOptions = {}): string { return [ "Slash commands:", "/help", + "/commands", "/status", "/agent (or /agents)", "/session (or /sessions)", diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 37e5752e8..cf8341d59 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -245,6 +245,7 @@ export async function runTui(opts: TuiOptions) { editor.setAutocompleteProvider( new CombinedAutocompleteProvider( getSlashCommands({ + cfg: config, provider: sessionInfo.modelProvider, model: sessionInfo.model, }), From cad7ed1cb8643ddb14c373cefb77a31d18765191 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 18:59:59 +0000 Subject: [PATCH 13/24] fix(exec-approvals): stabilize allowlist ids (#1521) --- CHANGELOG.md | 1 + .../Sources/Clawdbot/ExecApprovals.swift | 46 ++++++++++++++++++- .../Clawdbot/SystemRunSettingsView.swift | 20 ++++---- docs/tools/exec-approvals.md | 2 + src/gateway/protocol/schema/exec-approvals.ts | 1 + src/infra/exec-approvals.ts | 23 +++++++++- ui/src/ui/controllers/exec-approvals.ts | 1 + 7 files changed, 84 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b07c888..ddc935494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - TUI: include Gateway slash commands in autocomplete and `/help`. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) +- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. ## 2026.1.22 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index 537ceeaad..c6f413922 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable { case deny } -struct ExecAllowlistEntry: Codable, Hashable { +struct ExecAllowlistEntry: Codable, Hashable, Identifiable { + var id: UUID var pattern: String var lastUsedAt: Double? var lastUsedCommand: String? var lastResolvedPath: String? + + init( + id: UUID = UUID(), + pattern: String, + lastUsedAt: Double? = nil, + lastUsedCommand: String? = nil, + lastResolvedPath: String? = nil) + { + self.id = id + self.pattern = pattern + self.lastUsedAt = lastUsedAt + self.lastUsedCommand = lastUsedCommand + self.lastResolvedPath = lastResolvedPath + } + + private enum CodingKeys: String, CodingKey { + case id + case pattern + case lastUsedAt + case lastUsedCommand + case lastResolvedPath + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.pattern = try container.decode(String.self, forKey: .pattern) + self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt) + self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand) + self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.pattern, forKey: .pattern) + try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt) + try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand) + try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath) + } } struct ExecApprovalsDefaults: Codable { @@ -295,6 +336,7 @@ enum ExecApprovalsStore { let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) .map { entry in ExecAllowlistEntry( + id: entry.id, pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), lastUsedAt: entry.lastUsedAt, lastUsedCommand: entry.lastUsedCommand, @@ -379,6 +421,7 @@ enum ExecApprovalsStore { let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in guard item.pattern == pattern else { return item } return ExecAllowlistEntry( + id: item.id, pattern: item.pattern, lastUsedAt: Date().timeIntervalSince1970 * 1000, lastUsedCommand: command, @@ -398,6 +441,7 @@ enum ExecApprovalsStore { let cleaned = allowlist .map { item in ExecAllowlistEntry( + id: item.id, pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), lastUsedAt: item.lastUsedAt, lastUsedCommand: item.lastUsedCommand, diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift index 0ac799e6d..eef826c3f 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -123,12 +123,12 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { - ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in + ForEach(self.model.entries, id: \.id) { entry in ExecAllowlistRow( entry: Binding( - get: { self.model.entries[index] }, - set: { self.model.updateEntry($0, at: index) }), - onRemove: { self.model.removeEntry(at: index) }) + get: { self.model.entry(for: entry.id) ?? entry }, + set: { self.model.updateEntry($0, id: entry.id) }), + onRemove: { self.model.removeEntry(id: entry.id) }) } } } @@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel { ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { + func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } self.entries[index] = entry ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func removeEntry(at index: Int) { + func removeEntry(id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } self.entries.remove(at: index) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } + func entry(for id: UUID) -> ExecAllowlistEntry? { + self.entries.first(where: { $0.id == id }) + } + func refreshSkillBins(force: Bool = false) async { guard self.autoAllowSkills else { self.skillBins = [] diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index fc657a74f..2ab96695c 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -54,6 +54,7 @@ Example schema: "autoAllowSkills": true, "allowlist": [ { + "id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F", "pattern": "~/Projects/**/bin/rg", "lastUsedAt": 1737150000000, "lastUsedCommand": "rg -n TODO", @@ -96,6 +97,7 @@ Examples: - `/opt/homebrew/bin/rg` Each allowlist entry tracks: +- **id** stable UUID used for UI identity (optional) - **last used** timestamp - **last used command** - **last resolved path** diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index 9f1a25d38..d58e74ab2 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js"; export const ExecApprovalsAllowlistEntrySchema = Type.Object( { + id: Type.Optional(NonEmptyString), pattern: Type.String(), lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })), lastUsedCommand: Type.Optional(Type.String()), diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 65dc4f024..0830ed89a 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = { }; export type ExecAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; @@ -120,6 +121,19 @@ function ensureDir(filePath: string) { fs.mkdirSync(dir, { recursive: true }); } +function ensureAllowlistIds( + allowlist: ExecAllowlistEntry[] | undefined, +): ExecAllowlistEntry[] | undefined { + if (!Array.isArray(allowlist) || allowlist.length === 0) return allowlist; + let changed = false; + const next = allowlist.map((entry) => { + if (entry.id) return entry; + changed = true; + return { ...entry, id: crypto.randomUUID() }; + }); + return changed ? next : allowlist; +} + export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); const token = file.socket?.token?.trim(); @@ -130,6 +144,12 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault; delete agents.default; } + for (const [key, agent] of Object.entries(agents)) { + const allowlist = ensureAllowlistIds(agent.allowlist); + if (allowlist !== agent.allowlist) { + agents[key] = { ...agent, allowlist }; + } + } const normalized: ExecApprovalsFile = { version: 1, socket: { @@ -1145,6 +1165,7 @@ export function recordAllowlistUse( item.pattern === entry.pattern ? { ...item, + id: item.id ?? crypto.randomUUID(), lastUsedAt: Date.now(), lastUsedCommand: command, lastResolvedPath: resolvedPath, @@ -1168,7 +1189,7 @@ export function addAllowlistEntry( const trimmed = pattern.trim(); if (!trimmed) return; if (allowlist.some((entry) => entry.pattern === trimmed)) return; - allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() }); + allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() }); agents[target] = { ...existing, allowlist }; approvals.agents = agents; saveExecApprovals(approvals); diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts index 4f59caae2..ba938b9f3 100644 --- a/ui/src/ui/controllers/exec-approvals.ts +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = { }; export type ExecApprovalsAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; From 3d958d54663d5256c3662a9bf671301825609b7e Mon Sep 17 00:00:00 2001 From: Robby Date: Fri, 23 Jan 2026 20:06:14 +0100 Subject: [PATCH 14/24] fix(linux): add user bin directories to systemd service PATH for skill installation (#1512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(linux): add user bin directories to systemd service PATH Fixes #1503 On Linux, the systemd service PATH was hardcoded to only include system directories (/usr/local/bin, /usr/bin, /bin), causing binaries installed via npm global with custom prefix or node version managers to not be found. This adds common Linux user bin directories to the PATH: - ~/.local/bin (XDG standard, pip, etc.) - ~/.npm-global/bin (npm custom prefix) - ~/bin (user's personal bin) - Node version manager paths (nvm, fnm, volta, asdf) - ~/.local/share/pnpm (pnpm global) - ~/.bun/bin (Bun) User directories are added before system directories so user-installed binaries take precedence. 🤖 AI-assisted (Claude Opus 4.5 via Clawdbot) 📋 Testing: Existing unit tests pass (7/7) * test: add comprehensive tests for Linux user bin directory resolution - Add dedicated tests for resolveLinuxUserBinDirs() function - Test path ordering (extraDirs > user dirs > system dirs) - Test buildMinimalServicePath() with HOME set/unset - Test platform-specific behavior (Linux vs macOS vs Windows) Test count: 7 → 20 (+13 tests) * test: add comprehensive tests for Linux user bin directory handling - Test Linux user directories included when HOME is set - Test Linux user directories excluded when HOME is missing - Test path ordering (extraDirs > user dirs > system dirs) - Test platform-specific behavior (Linux vs macOS vs Windows) - Test buildMinimalServicePath() with HOME in env Covers getMinimalServicePathParts() and buildMinimalServicePath() for all Linux user bin directory edge cases. Test count: 7 → 16 (+9 tests) --- src/daemon/service-env.test.ts | 135 +++++++++++++++++++++++++++++++++ src/daemon/service-env.ts | 36 ++++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index aa7fbca5d..cdc16cb65 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -4,8 +4,98 @@ import { buildMinimalServicePath, buildNodeServiceEnvironment, buildServiceEnvironment, + getMinimalServicePathParts, } from "./service-env.js"; +describe("getMinimalServicePathParts - Linux user directories", () => { + it("includes user bin directories when HOME is set on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + }); + + // Should include all common user bin directories + expect(result).toContain("/home/testuser/.local/bin"); + expect(result).toContain("/home/testuser/.npm-global/bin"); + expect(result).toContain("/home/testuser/bin"); + expect(result).toContain("/home/testuser/.nvm/current/bin"); + expect(result).toContain("/home/testuser/.fnm/current/bin"); + expect(result).toContain("/home/testuser/.volta/bin"); + expect(result).toContain("/home/testuser/.asdf/shims"); + expect(result).toContain("/home/testuser/.local/share/pnpm"); + expect(result).toContain("/home/testuser/.bun/bin"); + }); + + it("excludes user bin directories when HOME is undefined on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: undefined, + }); + + // Should only include system directories + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); + + // Should not include any user-specific paths + expect(result.some((p) => p.includes(".local"))).toBe(false); + expect(result.some((p) => p.includes(".npm-global"))).toBe(false); + expect(result.some((p) => p.includes(".nvm"))).toBe(false); + }); + + it("places user directories before system directories on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + }); + + const userDirIndex = result.indexOf("/home/testuser/.local/bin"); + const systemDirIndex = result.indexOf("/usr/bin"); + + expect(userDirIndex).toBeGreaterThan(-1); + expect(systemDirIndex).toBeGreaterThan(-1); + expect(userDirIndex).toBeLessThan(systemDirIndex); + }); + + it("places extraDirs before user directories on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + extraDirs: ["/custom/bin"], + }); + + const extraDirIndex = result.indexOf("/custom/bin"); + const userDirIndex = result.indexOf("/home/testuser/.local/bin"); + + expect(extraDirIndex).toBeGreaterThan(-1); + expect(userDirIndex).toBeGreaterThan(-1); + expect(extraDirIndex).toBeLessThan(userDirIndex); + }); + + it("does not include Linux user directories on macOS", () => { + const result = getMinimalServicePathParts({ + platform: "darwin", + home: "/Users/testuser", + }); + + // Should not include Linux-specific user dirs even with HOME set + expect(result.some((p) => p.includes(".npm-global"))).toBe(false); + expect(result.some((p) => p.includes(".nvm"))).toBe(false); + + // Should only include macOS system directories + expect(result).toContain("/opt/homebrew/bin"); + expect(result).toContain("/usr/local/bin"); + }); + + it("does not include Linux user directories on Windows", () => { + const result = getMinimalServicePathParts({ + platform: "win32", + home: "C:\\Users\\testuser", + }); + + // Windows returns empty array (uses existing PATH) + expect(result).toEqual([]); + }); +}); + describe("buildMinimalServicePath", () => { it("includes Homebrew + system dirs on macOS", () => { const result = buildMinimalServicePath({ @@ -26,6 +116,51 @@ describe("buildMinimalServicePath", () => { expect(result).toBe("C:\\\\Windows\\\\System32"); }); + it("includes Linux user directories when HOME is set in env", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: { HOME: "/home/alice" }, + }); + const parts = result.split(path.delimiter); + + // Verify user directories are included + expect(parts).toContain("/home/alice/.local/bin"); + expect(parts).toContain("/home/alice/.npm-global/bin"); + expect(parts).toContain("/home/alice/.nvm/current/bin"); + + // Verify system directories are also included + expect(parts).toContain("/usr/local/bin"); + expect(parts).toContain("/usr/bin"); + expect(parts).toContain("/bin"); + }); + + it("excludes Linux user directories when HOME is not in env", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: {}, + }); + const parts = result.split(path.delimiter); + + // Should only have system directories + expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); + + // No user-specific paths + expect(parts.some((p) => p.includes("home"))).toBe(false); + }); + + it("ensures user directories come before system directories on Linux", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: { HOME: "/home/bob" }, + }); + const parts = result.split(path.delimiter); + + const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin"); + const firstSystemDirIdx = parts.indexOf("/usr/local/bin"); + + expect(firstUserDirIdx).toBeLessThan(firstSystemDirIdx); + }); + it("includes extra directories when provided", () => { const result = buildMinimalServicePath({ platform: "linux", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 8851cdb59..a6d184e67 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -17,6 +17,7 @@ import { export type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; + home?: string; }; type BuildServicePathOptions = MinimalServicePathOptions & { @@ -33,6 +34,31 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { return []; } +/** + * Resolve common user bin directories for Linux. + * These are paths where npm global installs and node version managers typically place binaries. + */ +export function resolveLinuxUserBinDirs(home: string | undefined): string[] { + if (!home) return []; + + const dirs: string[] = []; + + // Common user bin directories + dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc. + dirs.push(`${home}/.npm-global/bin`); // npm custom prefix (recommended for non-root) + dirs.push(`${home}/bin`); // User's personal bin + + // Node version managers + dirs.push(`${home}/.nvm/current/bin`); // nvm with current symlink + dirs.push(`${home}/.fnm/current/bin`); // fnm + dirs.push(`${home}/.volta/bin`); // Volta + dirs.push(`${home}/.asdf/shims`); // asdf + dirs.push(`${home}/.local/share/pnpm`); // pnpm global bin + dirs.push(`${home}/.bun/bin`); // Bun + + return dirs; +} + export function getMinimalServicePathParts(options: MinimalServicePathOptions = {}): string[] { const platform = options.platform ?? process.platform; if (platform === "win32") return []; @@ -41,12 +67,17 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const extraDirs = options.extraDirs ?? []; const systemDirs = resolveSystemPathDirs(platform); + // Add Linux user bin directories (npm global, nvm, fnm, volta, etc.) + const linuxUserDirs = platform === "linux" ? resolveLinuxUserBinDirs(options.home) : []; + const add = (dir: string) => { if (!dir) return; if (!parts.includes(dir)) parts.push(dir); }; for (const dir of extraDirs) add(dir); + // User dirs first so user-installed binaries take precedence + for (const dir of linuxUserDirs) add(dir); for (const dir of systemDirs) add(dir); return parts; @@ -59,7 +90,10 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}): return env.PATH ?? ""; } - return getMinimalServicePathParts(options).join(path.delimiter); + return getMinimalServicePathParts({ + ...options, + home: options.home ?? env.HOME, + }).join(path.delimiter); } export function buildServiceEnvironment(params: { From ff30cef8a43a2997f6e259ae3749b23ba013eca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:16:41 +0000 Subject: [PATCH 15/24] fix: expand linux service PATH handling --- CHANGELOG.md | 1 + docs/start/faq.md | 1 + src/daemon/service-audit.test.ts | 21 +++++++++++++++++ src/daemon/service-audit.ts | 11 ++++++--- src/daemon/service-env.test.ts | 25 ++++++++++++++++++++ src/daemon/service-env.ts | 40 +++++++++++++++++++++++++++----- test/setup.ts | 6 ++++- test/test-env.ts | 4 ++++ vitest.config.ts | 1 - vitest.e2e.config.ts | 1 - 10 files changed, 99 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc935494..4106f7827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. +- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. diff --git a/docs/start/faq.md b/docs/start/faq.md index 38defb953..a3efb2b0b 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -324,6 +324,7 @@ brew install ``` If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells. +Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set. ### Can I switch between npm and git installs later? diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 328c04a1a..e8e8d89ff 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; +import { buildMinimalServicePath } from "./service-env.js"; describe("auditGatewayServiceConfig", () => { it("flags bun runtime", async () => { @@ -39,4 +40,24 @@ describe("auditGatewayServiceConfig", () => { audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), ).toBe(true); }); + + it("accepts Linux minimal PATH with user directories", async () => { + const env = { HOME: "/home/testuser", PNPM_HOME: "/opt/pnpm" }; + const minimalPath = buildMinimalServicePath({ platform: "linux", env }); + const audit = await auditGatewayServiceConfig({ + env, + platform: "linux", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { PATH: minimalPath }, + }, + }); + + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal), + ).toBe(false); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), + ).toBe(false); + }); }); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index bf8ae8be3..20ddd4ff2 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -6,7 +6,7 @@ import { isVersionManagedNodePath, resolveSystemNodePath, } from "./runtime-paths.js"; -import { getMinimalServicePathParts } from "./service-env.js"; +import { getMinimalServicePathPartsFromEnv } from "./service-env.js"; import { resolveSystemdUserUnitPath } from "./systemd.js"; export type GatewayServiceCommand = { @@ -206,6 +206,7 @@ function normalizePathEntry(entry: string, platform: NodeJS.Platform): string { function auditGatewayServicePath( command: GatewayServiceCommand, issues: ServiceConfigIssue[], + env: Record, platform: NodeJS.Platform, ) { if (platform === "win32") return; @@ -219,12 +220,13 @@ function auditGatewayServicePath( return; } - const expected = getMinimalServicePathParts({ platform }); + const expected = getMinimalServicePathPartsFromEnv({ platform, env }); const parts = servicePath .split(getPathModule(platform).delimiter) .map((entry) => entry.trim()) .filter(Boolean); const normalizedParts = parts.map((entry) => normalizePathEntry(entry, platform)); + const normalizedExpected = new Set(expected.map((entry) => normalizePathEntry(entry, platform))); const missing = expected.filter((entry) => { const normalized = normalizePathEntry(entry, platform); return !normalizedParts.includes(normalized); @@ -239,6 +241,9 @@ function auditGatewayServicePath( const nonMinimal = parts.filter((entry) => { const normalized = normalizePathEntry(entry, platform); + if (normalizedExpected.has(normalized)) { + return false; + } return ( normalized.includes("/.nvm/") || normalized.includes("/.fnm/") || @@ -315,7 +320,7 @@ export async function auditGatewayServiceConfig(params: { const platform = params.platform ?? process.platform; auditGatewayCommand(params.command?.programArguments, issues); - auditGatewayServicePath(params.command, issues, platform); + auditGatewayServicePath(params.command, issues, params.env, platform); await auditGatewayRuntime(params.env, params.command, issues, platform); if (platform === "linux") { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index cdc16cb65..b87ab2ece 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -5,6 +5,7 @@ import { buildNodeServiceEnvironment, buildServiceEnvironment, getMinimalServicePathParts, + getMinimalServicePathPartsFromEnv, } from "./service-env.js"; describe("getMinimalServicePathParts - Linux user directories", () => { @@ -70,6 +71,30 @@ describe("getMinimalServicePathParts - Linux user directories", () => { expect(extraDirIndex).toBeLessThan(userDirIndex); }); + it("includes env-configured bin roots when HOME is set on Linux", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + PNPM_HOME: "/opt/pnpm", + NPM_CONFIG_PREFIX: "/opt/npm", + BUN_INSTALL: "/opt/bun", + VOLTA_HOME: "/opt/volta", + ASDF_DATA_DIR: "/opt/asdf", + NVM_DIR: "/opt/nvm", + FNM_DIR: "/opt/fnm", + }, + }); + + expect(result).toContain("/opt/pnpm"); + expect(result).toContain("/opt/npm/bin"); + expect(result).toContain("/opt/bun/bin"); + expect(result).toContain("/opt/volta/bin"); + expect(result).toContain("/opt/asdf/shims"); + expect(result).toContain("/opt/nvm/current/bin"); + expect(result).toContain("/opt/fnm/current/bin"); + }); + it("does not include Linux user directories on macOS", () => { const result = getMinimalServicePathParts({ platform: "darwin", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index a6d184e67..8c447c273 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -18,6 +18,7 @@ export type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; home?: string; + env?: Record; }; type BuildServicePathOptions = MinimalServicePathOptions & { @@ -38,11 +39,31 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { * Resolve common user bin directories for Linux. * These are paths where npm global installs and node version managers typically place binaries. */ -export function resolveLinuxUserBinDirs(home: string | undefined): string[] { +export function resolveLinuxUserBinDirs( + home: string | undefined, + env?: Record, +): string[] { if (!home) return []; const dirs: string[] = []; + const add = (dir: string | undefined) => { + if (dir) dirs.push(dir); + }; + const appendSubdir = (base: string | undefined, subdir: string) => { + if (!base) return undefined; + return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir); + }; + + // Env-configured bin roots (override defaults when present). + add(env?.PNPM_HOME); + add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin")); + add(appendSubdir(env?.BUN_INSTALL, "bin")); + add(appendSubdir(env?.VOLTA_HOME, "bin")); + add(appendSubdir(env?.ASDF_DATA_DIR, "shims")); + add(appendSubdir(env?.NVM_DIR, "current/bin")); + add(appendSubdir(env?.FNM_DIR, "current/bin")); + // Common user bin directories dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc. dirs.push(`${home}/.npm-global/bin`); // npm custom prefix (recommended for non-root) @@ -68,7 +89,8 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const systemDirs = resolveSystemPathDirs(platform); // Add Linux user bin directories (npm global, nvm, fnm, volta, etc.) - const linuxUserDirs = platform === "linux" ? resolveLinuxUserBinDirs(options.home) : []; + const linuxUserDirs = + platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env) : []; const add = (dir: string) => { if (!dir) return; @@ -83,6 +105,15 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = return parts; } +export function getMinimalServicePathPartsFromEnv(options: BuildServicePathOptions = {}): string[] { + const env = options.env ?? process.env; + return getMinimalServicePathParts({ + ...options, + home: options.home ?? env.HOME, + env, + }); +} + export function buildMinimalServicePath(options: BuildServicePathOptions = {}): string { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; @@ -90,10 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}): return env.PATH ?? ""; } - return getMinimalServicePathParts({ - ...options, - home: options.home ?? env.HOME, - }).join(path.delimiter); + return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter); } export function buildServiceEnvironment(params: { diff --git a/test/setup.ts b/test/setup.ts index 971fa4731..02cd85ef1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, vi } from "vitest"; import type { ChannelId, @@ -9,6 +9,10 @@ import type { ClawdbotConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { setActivePluginRegistry } from "../src/plugins/runtime.js"; import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; +import { withIsolatedTestHome } from "./test-env"; + +const testEnv = withIsolatedTestHome(); +afterAll(() => testEnv.cleanup()); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": diff --git a/test/test-env.ts b/test/test-env.ts index 815fe93d7..838713c52 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -130,3 +130,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { return { cleanup, tempHome }; } + +export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } { + return installTestEnv(); +} diff --git a/vitest.config.ts b/vitest.config.ts index 8a783236c..210c4092b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,6 @@ export default defineConfig({ "test/format-error.test.ts", ], setupFiles: ["test/setup.ts"], - globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index a33d324bd..ff6d8e94e 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ maxWorkers: e2eWorkers, include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts"], setupFiles: ["test/setup.ts"], - globalSetup: ["test/global-setup.ts"], exclude: [ "dist/**", "apps/macos/**", From 2f1b9efe9ad1daf92227ed3f21f0edf6d16b24b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:17:57 +0000 Subject: [PATCH 16/24] style: wrap service path helpers From 40181afdedb04ce05f9d28d0a34440e810e8c07e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:25:58 +0000 Subject: [PATCH 17/24] feat: add models status auth probes --- CHANGELOG.md | 1 + docs/cli/index.md | 7 + docs/cli/models.md | 14 + src/cli/models-cli.ts | 29 +- src/commands/models.list.test.ts | 1 + src/commands/models/list.probe.ts | 414 ++++++++++++++++++ src/commands/models/list.status-command.ts | 256 ++++++++--- ...patterns-match-without-botusername.test.ts | 13 +- ...topic-skill-filters-system-prompts.test.ts | 13 +- ...-all-group-messages-grouppolicy-is.test.ts | 13 +- ...e-callback-query-updates-by-update.test.ts | 13 +- ...gram-bot.installs-grammy-throttler.test.ts | 14 +- ...lowfrom-entries-case-insensitively.test.ts | 13 +- ...-case-insensitively-grouppolicy-is.test.ts | 13 +- ...-dms-by-telegram-accountid-binding.test.ts | 13 +- ...ies-without-native-reply-threading.test.ts | 13 +- src/telegram/bot.test.ts | 20 +- 17 files changed, 754 insertions(+), 106 deletions(-) create mode 100644 src/commands/models/list.probe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4106f7827..3612e9686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. +- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes diff --git a/docs/cli/index.md b/docs/cli/index.md index 46f6d173e..fcc013fdc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -700,8 +700,15 @@ Options: - `--json` - `--plain` - `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` +- `--probe-profile ` (repeat or comma-separated) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` Always includes the auth overview and OAuth expiry status for profiles in the auth store. +`--probe` runs live requests (may consume tokens and trigger rate limits). ### `models set ` Set `agents.defaults.model.primary`. diff --git a/docs/cli/models.md b/docs/cli/models.md index f394a44f9..ba4600ce4 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -25,12 +25,26 @@ clawdbot models scan `clawdbot models status` shows the resolved default/fallbacks plus an auth overview. When provider usage snapshots are available, the OAuth/token status section includes provider usage headers. +Add `--probe` to run live auth probes against each configured provider profile. +Probes are real requests (may consume tokens and trigger rate limits). Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +### `models status` +Options: +- `--json` +- `--plain` +- `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` (probe one provider) +- `--probe-profile ` (repeat or comma-separated profile ids) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` + ## Aliases + fallbacks ```bash diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a2674d94a..20a476f81 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) { "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", false, ) + .option("--probe", "Probe configured provider auth (live)", false) + .option("--probe-provider ", "Only probe a single provider") + .option( + "--probe-profile ", + "Only probe specific auth profile ids (repeat or comma-separated)", + (value, previous) => { + const next = Array.isArray(previous) ? previous : previous ? [previous] : []; + next.push(value); + return next; + }, + ) + .option("--probe-timeout ", "Per-probe timeout in ms") + .option("--probe-concurrency ", "Concurrent probes") + .option("--probe-max-tokens ", "Probe max tokens (best-effort)") .action(async (opts) => { await runModelsCommand(async () => { - await modelsStatusCommand(opts, defaultRuntime); + await modelsStatusCommand( + { + json: Boolean(opts.json), + plain: Boolean(opts.plain), + check: Boolean(opts.check), + probe: Boolean(opts.probe), + probeProvider: opts.probeProvider as string | undefined, + probeProfile: opts.probeProfile as string | string[] | undefined, + probeTimeout: opts.probeTimeout as string | undefined, + probeConcurrency: opts.probeConcurrency as string | undefined, + probeMaxTokens: opts.probeMaxTokens as string | undefined, + }, + defaultRuntime, + ); }); }); diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 47ebfe2f5..850f27246 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -17,6 +17,7 @@ const discoverModels = vi.fn(); vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state", loadConfig, })); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts new file mode 100644 index 000000000..fbd172b57 --- /dev/null +++ b/src/commands/models/list.probe.ts @@ -0,0 +1,414 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; + +import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + resolveAuthProfileDisplayLabel, +} from "../../agents/auth-profiles.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { describeFailoverError } from "../../agents/failover-error.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionTranscriptPath, + resolveSessionTranscriptsDirForAgent, +} from "../../config/sessions/paths.js"; +import { redactSecrets } from "../status-all/format.js"; +import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; + +const PROBE_PROMPT = "Reply with OK. Do not use tools."; + +export type AuthProbeStatus = + | "ok" + | "auth" + | "rate_limit" + | "billing" + | "timeout" + | "format" + | "unknown" + | "no_model"; + +export type AuthProbeResult = { + provider: string; + model?: string; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; + status: AuthProbeStatus; + error?: string; + latencyMs?: number; +}; + +type AuthProbeTarget = { + provider: string; + model?: { provider: string; model: string } | null; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; +}; + +export type AuthProbeSummary = { + startedAt: number; + finishedAt: number; + durationMs: number; + totalTargets: number; + options: { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; + }; + results: AuthProbeResult[]; +}; + +export type AuthProbeOptions = { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; +}; + +const toStatus = (reason?: string | null): AuthProbeStatus => { + if (!reason) return "unknown"; + if (reason === "auth") return "auth"; + if (reason === "rate_limit") return "rate_limit"; + if (reason === "billing") return "billing"; + if (reason === "timeout") return "timeout"; + if (reason === "format") return "format"; + return "unknown"; +}; + +function buildCandidateMap(modelCandidates: string[]): Map { + const map = new Map(); + for (const raw of modelCandidates) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + const list = map.get(parsed.provider) ?? []; + if (!list.includes(parsed.model)) list.push(parsed.model); + map.set(parsed.provider, list); + } + return map; +} + +function selectProbeModel(params: { + provider: string; + candidates: Map; + catalog: Array<{ provider: string; id: string }>; +}): { provider: string; model: string } | null { + const { provider, candidates, catalog } = params; + const direct = candidates.get(provider); + if (direct && direct.length > 0) { + return { provider, model: direct[0] }; + } + const fromCatalog = catalog.find((entry) => entry.provider === provider); + if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id }; + return null; +} + +function buildProbeTargets(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; +}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { + const { cfg, providers, modelCandidates, options } = params; + const store = ensureAuthProfileStore(); + const providerFilter = options.provider?.trim(); + const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; + const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); + + return loadModelCatalog({ config: cfg }).then((catalog) => { + const candidates = buildCandidateMap(modelCandidates); + const targets: AuthProbeTarget[] = []; + const results: AuthProbeResult[] = []; + + for (const provider of providers) { + const providerKey = normalizeProviderId(provider); + if (providerFilterKey && providerKey !== providerFilterKey) continue; + + const model = selectProbeModel({ + provider: providerKey, + candidates, + catalog, + }); + + const profileIds = listProfilesForProvider(store, providerKey); + const filteredProfiles = profileFilter.size + ? profileIds.filter((id) => profileFilter.has(id)) + : profileIds; + + if (filteredProfiles.length > 0) { + for (const profileId of filteredProfiles) { + const profile = store.profiles[profileId]; + const mode = profile?.type; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + profileId, + label, + source: "profile", + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + targets.push({ + provider: providerKey, + model, + profileId, + label, + source: "profile", + mode, + }); + } + continue; + } + + if (profileFilter.size > 0) continue; + + const envKey = resolveEnvApiKey(providerKey); + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (!envKey && !customKey) continue; + + const label = envKey ? "env" : "models.json"; + const source = envKey ? "env" : "models.json"; + const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key"; + + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + label, + source, + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + + targets.push({ + provider: providerKey, + model, + label, + source, + mode, + }); + } + + return { targets, results }; + }); +} + +async function probeTarget(params: { + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + workspaceDir: string; + sessionDir: string; + target: AuthProbeTarget; + timeoutMs: number; + maxTokens: number; +}): Promise { + const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params; + if (!target.model) { + return { + provider: target.provider, + model: undefined, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "no_model", + error: "No model available for probe", + }; + } + + const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`; + const sessionFile = resolveSessionTranscriptPath(sessionId, agentId); + await fs.mkdir(sessionDir, { recursive: true }); + + const start = Date.now(); + try { + await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + agentDir, + config: cfg, + prompt: PROBE_PROMPT, + provider: target.model.provider, + model: target.model.model, + authProfileId: target.profileId, + authProfileIdSource: target.profileId ? "user" : undefined, + timeoutMs, + runId: `probe-${crypto.randomUUID()}`, + lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`, + thinkLevel: "off", + reasoningLevel: "off", + verboseLevel: "off", + streamParams: { maxTokens }, + }); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "ok", + latencyMs: Date.now() - start, + }; + } catch (err) { + const described = describeFailoverError(err); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: toStatus(described.reason), + error: redactSecrets(described.message), + latencyMs: Date.now() - start, + }; + } +} + +async function runTargetsWithConcurrency(params: { + cfg: ClawdbotConfig; + targets: AuthProbeTarget[]; + timeoutMs: number; + maxTokens: number; + concurrency: number; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const { cfg, targets, timeoutMs, maxTokens, onProgress } = params; + const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency)); + + const agentId = resolveDefaultAgentId(cfg); + const agentDir = resolveClawdbotAgentDir(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const sessionDir = resolveSessionTranscriptsDirForAgent(agentId); + + await fs.mkdir(workspaceDir, { recursive: true }); + + let completed = 0; + const results: Array = Array.from({ length: targets.length }); + let cursor = 0; + + const worker = async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]; + onProgress?.({ + completed, + total: targets.length, + label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`, + }); + const result = await probeTarget({ + cfg, + agentId, + agentDir, + workspaceDir, + sessionDir, + target, + timeoutMs, + maxTokens, + }); + results[index] = result; + completed += 1; + onProgress?.({ completed, total: targets.length }); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results.filter((entry): entry is AuthProbeResult => Boolean(entry)); +} + +export async function runAuthProbes(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const startedAt = Date.now(); + const plan = await buildProbeTargets({ + cfg: params.cfg, + providers: params.providers, + modelCandidates: params.modelCandidates, + options: params.options, + }); + + const totalTargets = plan.targets.length; + params.onProgress?.({ completed: 0, total: totalTargets }); + + const results = totalTargets + ? await runTargetsWithConcurrency({ + cfg: params.cfg, + targets: plan.targets, + timeoutMs: params.options.timeoutMs, + maxTokens: params.options.maxTokens, + concurrency: params.options.concurrency, + onProgress: params.onProgress, + }) + : []; + + const finishedAt = Date.now(); + + return { + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + totalTargets, + options: params.options, + results: [...plan.results, ...results], + }; +} + +export function formatProbeLatency(latencyMs?: number | null) { + if (!latencyMs && latencyMs !== 0) return "-"; + return formatMs(latencyMs); +} + +export function groupProbeResults(results: AuthProbeResult[]): Map { + const map = new Map(); + for (const result of results) { + const list = map.get(result.provider) ?? []; + list.push(result); + map.set(result.provider, list); + } + return map; +} + +export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] { + return results.slice().sort((a, b) => { + const provider = a.provider.localeCompare(b.provider); + if (provider !== 0) return provider; + const aLabel = a.label || a.profileId || ""; + const bLabel = b.label || b.profileId || ""; + return aLabel.localeCompare(bLabel); + }); +} + +export function describeProbeSummary(summary: AuthProbeSummary): string { + if (summary.totalTargets === 0) return "No probe targets."; + return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`; +} diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 0bd8f16e9..41c126460 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -11,9 +11,15 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; -import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; +import { + buildModelAliasIndex, + parseModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { withProgressTotals } from "../../cli/progress.js"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -26,13 +32,34 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { isRich } from "./list.format.js"; +import { + describeProbeSummary, + formatProbeLatency, + groupProbeResults, + runAuthProbes, + sortProbeResults, + type AuthProbeSummary, +} from "./list.probe.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean; check?: boolean }, + opts: { + json?: boolean; + plain?: boolean; + check?: boolean; + probe?: boolean; + probeProvider?: string; + probeProfile?: string | string[]; + probeTimeout?: string; + probeConcurrency?: string; + probeMaxTokens?: string; + }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); + if (opts.plain && opts.probe) { + throw new Error("--probe cannot be used with --plain output."); + } const cfg = loadConfig(); const resolved = resolveConfiguredModelRef({ cfg, @@ -139,6 +166,69 @@ export async function modelsStatusCommand( .filter((provider) => !providerAuthMap.has(provider)) .sort((a, b) => a.localeCompare(b)); + const probeProfileIds = (() => { + if (!opts.probeProfile) return []; + const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile]; + return raw + .flatMap((value) => String(value ?? "").split(",")) + .map((value) => value.trim()) + .filter(Boolean); + })(); + const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000; + if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) { + throw new Error("--probe-timeout must be a positive number (ms)."); + } + const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2; + if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) { + throw new Error("--probe-concurrency must be > 0."); + } + const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8; + if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) { + throw new Error("--probe-max-tokens must be > 0."); + } + + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const rawCandidates = [ + rawModel || resolvedLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ...allowed, + ].filter(Boolean); + const resolvedCandidates = rawCandidates + .map( + (raw) => + resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + })?.ref, + ) + .filter((ref): ref is { provider: string; model: string } => Boolean(ref)); + const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`); + + let probeSummary: AuthProbeSummary | undefined; + if (opts.probe) { + probeSummary = await withProgressTotals( + { label: "Probing auth profiles…", total: 1 }, + async (update) => { + return await runAuthProbes({ + cfg, + providers, + modelCandidates, + options: { + provider: opts.probeProvider, + profileIds: probeProfileIds, + timeoutMs: probeTimeoutMs, + concurrency: probeConcurrency, + maxTokens: probeMaxTokens, + }, + onProgress: update, + }); + }, + ); + } + const providersWithOauth = providerAuth .filter( (entry) => @@ -228,6 +318,7 @@ export async function modelsStatusCommand( profiles: authHealth.profiles, providers: authHealth.providers, }, + probes: probeSummary, }, }, null, @@ -406,72 +497,113 @@ export async function modelsStatusCommand( runtime.log(colorize(rich, theme.heading, "OAuth/token status")); if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); - return; - } - - const usageByProvider = new Map(); - const usageProviders = Array.from( - new Set( - oauthProfiles - .map((profile) => resolveUsageProviderId(profile.provider)) - .filter((provider): provider is UsageProviderId => Boolean(provider)), - ), - ); - if (usageProviders.length > 0) { - try { - const usageSummary = await loadProviderUsageSummary({ - providers: usageProviders, - agentDir, - timeoutMs: 3500, - }); - for (const snapshot of usageSummary.providers) { - const formatted = formatUsageWindowSummary(snapshot, { - now: Date.now(), - maxWindows: 2, - includeResets: true, + } else { + const usageByProvider = new Map(); + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + if (usageProviders.length > 0) { + try { + const usageSummary = await loadProviderUsageSummary({ + providers: usageProviders, + agentDir, + timeoutMs: 3500, }); - if (formatted) { - usageByProvider.set(snapshot.provider, formatted); + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { + now: Date.now(), + maxWindows: 2, + includeResets: true, + }); + if (formatted) { + usageByProvider.set(snapshot.provider, formatted); + } } + } catch { + // ignore usage failures + } + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "static") return colorize(rich, theme.muted, "static"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + const profilesByProvider = new Map(); + for (const profile of oauthProfiles) { + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; + runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; + runtime.log(` - ${label} ${status}${expiry}${source}`); } - } catch { - // ignore usage failures } } - const formatStatus = (status: string) => { - if (status === "ok") return colorize(rich, theme.success, "ok"); - if (status === "static") return colorize(rich, theme.muted, "static"); - if (status === "expiring") return colorize(rich, theme.warn, "expiring"); - if (status === "missing") return colorize(rich, theme.warn, "unknown"); - return colorize(rich, theme.error, "expired"); - }; - - const profilesByProvider = new Map(); - for (const profile of oauthProfiles) { - const current = profilesByProvider.get(profile.provider); - if (current) current.push(profile); - else profilesByProvider.set(profile.provider, [profile]); - } - - for (const [provider, profiles] of profilesByProvider) { - const usageKey = resolveUsageProviderId(provider); - const usage = usageKey ? usageByProvider.get(usageKey) : undefined; - const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; - runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); - for (const profile of profiles) { - const labelText = profile.label || profile.profileId; - const label = colorize(rich, theme.accent, labelText); - const status = formatStatus(profile.status); - const expiry = - profile.status === "static" - ? "" - : profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(` - ${label} ${status}${expiry}${source}`); + if (probeSummary) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Auth probes")); + if (probeSummary.results.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + } else { + const grouped = groupProbeResults(sortProbeResults(probeSummary.results)); + const statusColor = (status: string) => { + if (status === "ok") return theme.success; + if (status === "rate_limit") return theme.warn; + if (status === "timeout" || status === "billing") return theme.warn; + if (status === "auth" || status === "format") return theme.error; + if (status === "no_model") return theme.muted; + return theme.muted; + }; + for (const [provider, results] of grouped) { + const modelLabel = results.find((r) => r.model)?.model ?? "-"; + runtime.log( + `- ${theme.heading(provider)}${colorize( + rich, + theme.muted, + modelLabel ? ` (model: ${modelLabel})` : "", + )}`, + ); + for (const result of results) { + const status = colorize(rich, statusColor(result.status), result.status); + const latency = formatProbeLatency(result.latencyMs); + const mode = result.mode ? ` (${result.mode})` : ""; + const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : ""; + runtime.log( + ` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize( + rich, + theme.muted, + latency, + )}${detail}`, + ); + } + } + runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary))); } } diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 4fea3521a..7024a2e52 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -111,7 +112,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -121,7 +122,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 2afe8cd1c..1a10ca94c 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 6c712ca1d..7937c1064 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 9ed0ed677..5e8a2dcfa 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index ab43c4269..05aac6388 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -114,7 +116,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -125,7 +127,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index dfdcf43e3..2c4dfa472 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index 1e1174fbf..2281fb407 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 6e83c61c3..829391727 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index 74f87d63b..164095a9c 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -113,7 +114,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -122,7 +123,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 51beb4f4b..da67c2e38 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,18 +6,20 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +let replyModule: typeof import("../auto-reply/reply.js"); const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import * as replyModule from "../auto-reply/reply.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; -import { resolveTelegramFetch } from "./fetch.js"; function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); @@ -155,7 +157,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ From f07c39b26545c67ca06f76b1cac9e7086bc4b866 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:28:50 +0000 Subject: [PATCH 18/24] docs: handle lint/format churn --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b381ceb2f..d7c76e235 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. +- Lint/format churn: + - If staged+unstaged diffs are formatting-only, auto-resolve without asking. + - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation. + - Only ask when changes are semantic (logic/data/behavior). - Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. From c63144ab144dfdb190d9dbd566155d5bf3e2285a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:42:55 +0000 Subject: [PATCH 19/24] fix: hide usage errors in status --- src/infra/provider-usage.format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index f5a1b6995..d10879008 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -39,7 +39,7 @@ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, ): string | null { - if (snapshot.error) return `error: ${snapshot.error}`; + if (snapshot.error) return null; if (snapshot.windows.length === 0) return null; const now = opts?.now ?? Date.now(); const maxWindows = From 75a54f02597f577f22c19bbf7ec851b187ea6612 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:43:18 +0000 Subject: [PATCH 20/24] docs: note models usage suppression --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3612e9686..2f7865602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot ### Fixes - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. +- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) From 6fba598eaf16051ebc1ed5df7e019247252f7a2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:47:45 +0000 Subject: [PATCH 21/24] fix: handle gateway slash command replies in TUI --- CHANGELOG.md | 1 + docs/tui.md | 2 + src/gateway/server-methods/chat.ts | 154 +++++++++++++++++- ...erver.chat.gateway-server-chat.e2e.test.ts | 39 +++++ src/tui/tui-event-handlers.ts | 13 +- src/tui/tui-formatters.test.ts | 9 + src/tui/tui-formatters.ts | 5 + src/tui/tui-session-actions.ts | 7 +- 8 files changed, 227 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7865602..e49b37002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. +- TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. diff --git a/docs/tui.md b/docs/tui.md index e67b22032..4d094dc6b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,6 +88,8 @@ Session lifecycle: - `/settings` - `/exit` +Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands). + ## Local shell commands - Prefix a line with `!` to run a local shell command on the TUI host. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8c71dca75..0e55b45f5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { ensureAgentWorkspace } from "../../agents/workspace.js"; +import { isControlCommandMessage } from "../../auto-reply/command-detection.js"; +import { normalizeCommandBody } from "../../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; +import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js"; +import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js"; +import { defaultGroupActivation } from "../../auto-reply/reply/groups.js"; +import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js"; +import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js"; +import { + normalizeElevatedLevel, + normalizeReasoningLevel, + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../../auto-reply/thinking.js"; +import type { MsgContext } from "../../auto-reply/templating.js"; import { agentCommand } from "../../commands/agent.js"; import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionId, updatedAt: now, }); + store[canonicalKey] = sessionEntry; const clientRunId = p.idempotencyKey; registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); @@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); + if (isControlCommandMessage(parsedMessage, cfg)) { + try { + const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; + const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg }); + const agentCfg = cfg.agents?.defaults; + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const workspace = await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + }); + const ctx: MsgContext = { + Body: parsedMessage, + CommandBody: parsedMessage, + BodyForCommands: parsedMessage, + CommandSource: "text", + CommandAuthorized: true, + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: "tui", + From: p.sessionKey, + To: INTERNAL_MESSAGE_CHANNEL, + SessionKey: p.sessionKey, + ChatType: "direct", + }; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey: p.sessionKey, + isGroup: false, + triggerBodyNormalized: normalizeCommandBody(parsedMessage), + commandAuthorized: true, + }); + const directives = parseInlineDirectives(parsedMessage); + const { provider, model } = resolveSessionModelRef(cfg, sessionEntry); + const contextTokens = resolveContextTokens({ agentCfg, model }); + const resolveDefaultThinkingLevel = async () => { + const configured = agentCfg?.thinkingDefault; + if (configured) return configured; + const catalog = await context.loadGatewayModelCatalog(); + return resolveThinkingDefault({ cfg, provider, model, catalog }); + }; + const resolvedThinkLevel = + normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ?? + (await resolveDefaultThinkingLevel()); + const resolvedVerboseLevel = + normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off"; + const resolvedReasoningLevel = + normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off"; + const resolvedElevatedLevel = normalizeElevatedLevel( + sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault, + ); + const elevated = resolveElevatedPermissions({ + cfg, + agentId, + ctx, + provider: INTERNAL_MESSAGE_CHANNEL, + }); + const commandResult = await handleCommands({ + ctx, + cfg, + command, + agentId, + directives, + elevated, + sessionEntry, + previousSessionEntry: entry, + sessionStore: store, + sessionKey: p.sessionKey, + storePath, + sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global", + workspaceDir: workspace.dir, + defaultGroupActivation: () => defaultGroupActivation(true), + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup: false, + }); + if (!commandResult.shouldContinue) { + const text = commandResult.reply?.text ?? ""; + const message = { + role: "assistant", + content: text.trim() ? [{ type: "text", text }] : [], + timestamp: Date.now(), + command: true, + }; + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "final" as const, + message, + }; + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: true, + payload: { runId: clientRunId, status: "ok" as const }, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } catch (err) { + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "error" as const, + errorMessage: formatForLog(err), + }; + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload: { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }, + error, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const envelopedMessage = formatInboundEnvelope({ channel: "WebChat", diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 75f541f39..d4035037b 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -259,6 +259,45 @@ describe("gateway server chat", () => { } }); + test("routes chat.send slash commands without agent runs", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + try { + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + const eventPromise = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "final" && + o.payload?.runId === "idem-command-1", + 8000, + ); + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "/context list", + idempotencyKey: "idem-command-1", + }); + expect(res.ok).toBe(true); + const evt = await eventPromise; + expect(evt.payload?.message?.command).toBe(true); + expect(spy.mock.calls.length).toBe(callsBefore); + } finally { + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3f8e2befd..148dca67a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,6 +1,6 @@ import type { TUI } from "@mariozechner/pi-tui"; import type { ChatLog } from "./components/chat-log.js"; -import { asString } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; @@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + if (isCommandMessage(evt.message)) { + const text = extractTextFromMessage(evt.message); + if (text) chatLog.addSystem(text); + streamAssembler.drop(evt.runId); + noteFinalizedRun(evt.runId); + state.activeChatRunId = null; + setActivityStatus("idle"); + void refreshSessionInfo?.(); + tui.requestRender(); + return; + } const stopReason = evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) ? typeof (evt.message as Record).stopReason === "string" diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 541c58727..3200b237a 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,6 +4,7 @@ import { extractContentFromMessage, extractTextFromMessage, extractThinkingFromMessage, + isCommandMessage, } from "./tui-formatters.js"; describe("extractTextFromMessage", () => { @@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => { expect(text).toContain("HTTP 429"); }); }); + +describe("isCommandMessage", () => { + it("detects command-marked messages", () => { + expect(isCommandMessage({ command: true })).toBe(true); + expect(isCommandMessage({ command: false })).toBe(false); + expect(isCommandMessage({})).toBe(false); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 11e8e68c9..f77eb9ff1 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -140,6 +140,11 @@ export function extractTextFromMessage( return formatRawAssistantErrorForUi(errorMessage); } +export function isCommandMessage(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + return (message as Record).command === true; +} + export function formatTokens(total?: number | null, context?: number | null) { if (total == null && context == null) return "tokens ?"; const totalLabel = total == null ? "?" : formatTokenCount(total); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 327363653..5dc6696ad 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -6,7 +6,7 @@ import { } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; -import { asString, extractTextFromMessage } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionContext = { @@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) { for (const entry of record.messages ?? []) { if (!entry || typeof entry !== "object") continue; const message = entry as Record; + if (isCommandMessage(message)) { + const text = extractTextFromMessage(message); + if (text) chatLog.addSystem(text); + continue; + } if (message.role === "user") { const text = extractTextFromMessage(message); if (text) chatLog.addUser(text); From 242add587f39f4ad3f8ea4f48e815d7f10917aaa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 19:51:49 +0000 Subject: [PATCH 22/24] fix: quiet auth probe diagnostics --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/runs.ts | 8 ++++++-- src/logging/diagnostic.ts | 17 ++++++++++------- src/process/command-queue.ts | 9 ++++++--- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49b37002..288458bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. +- CLI: suppress diagnostic session/run noise during auth probes. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - TUI: render Gateway slash-command replies as system output (for example, `/context`). - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 4fcefca12..dcbe56244 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); - diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } } export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { ACTIVE_EMBEDDED_RUNS.delete(sessionId); logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); - diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } notifyEmbeddedRunEnded(sessionId); } else { diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index ba6239184..adcb93eca 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -197,17 +197,20 @@ export function logSessionStateChange( }, ) { const state = getSessionState(params); + const isProbeSession = state.sessionId?.startsWith("probe-") ?? false; const prevState = state.state; state.state = params.state; state.lastActivity = Date.now(); if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1); - diag.info( - `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ - state.sessionKey ?? "unknown" - } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ - state.queueDepth - }`, - ); + if (!isProbeSession) { + diag.info( + `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ + state.sessionKey ?? "unknown" + } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ + state.queueDepth + }`, + ); + } emitDiagnosticEvent({ type: "session.state", sessionId: state.sessionId, diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 9b203c938..2f2857130 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -68,9 +68,12 @@ function drainLane(lane: string) { entry.resolve(result); } catch (err) { state.active -= 1; - diag.error( - `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, - ); + const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); + if (!isProbeLane) { + diag.error( + `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, + ); + } pump(); entry.reject(err); } From 7d0a0ae3ba449dd113476620de512be9be0ebdfc Mon Sep 17 00:00:00 2001 From: Paul van Oorschot <20116814+pvoo@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:01:15 +0100 Subject: [PATCH 23/24] fix(discord): autoThread ack reactions + exec approval null handling (#1511) * fix(discord): gate autoThread by thread owner * fix(discord): ack bot-owned autoThreads * fix(discord): ack mentions in open channels - Ack reactions in bot-owned autoThreads - Ack reactions in open channels (no mention required) - DRY: Pass pre-computed isAutoThreadOwnedByBot to avoid redundant checks - Consolidate ack logic with explanatory comment * fix: allow null values in exec.approval.request schema The ExecApprovalRequestParamsSchema was rejecting null values for optional fields like resolvedPath, but the calling code in bash-tools.exec.ts passes null. This caused intermittent 'invalid exec.approval.request params' validation errors. Fix: Accept Type.Union([Type.String(), Type.Null()]) for all optional string fields in the schema. Update test to reflect new behavior. * fix: align discord ack reactions with mention gating (#1511) (thanks @pvoo) --------- Co-authored-by: Wimmie Co-authored-by: Peter Steinberger --- CHANGELOG.md | 2 + src/discord/monitor.test.ts | 51 ++++++++ src/discord/monitor/allow-list.ts | 21 ++- .../monitor/message-handler.preflight.ts | 3 + .../monitor/message-handler.process.test.ts | 123 ++++++++++++++++++ src/discord/monitor/message-utils.ts | 3 + src/discord/monitor/threading.ts | 2 + src/gateway/protocol/schema/exec-approvals.ts | 14 +- .../server-methods/exec-approval.test.ts | 8 +- 9 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 src/discord/monitor/message-handler.process.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 288458bd1..b33b621e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.clawd.bot - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. ### Fixes +- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. +- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: include Gateway slash commands in autocomplete and `/help`. - CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable. diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index be0c8aa65..bc85e5764 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -377,12 +377,63 @@ describe("discord mention gating", () => { resolveDiscordShouldRequireMention({ isGuildMessage: true, isThread: true, + botId: "bot123", + threadOwnerId: "bot123", channelConfig, guildInfo, }), ).toBe(false); }); + it("requires mention inside user-created threads with autoThread enabled", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + threadOwnerId: "user456", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + + it("requires mention when thread owner is unknown", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + it("inherits parent channel mention rules for threads", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 7d495af66..12c2d1d39 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: { export function resolveDiscordShouldRequireMention(params: { isGuildMessage: boolean; isThread: boolean; + botId?: string | null; + threadOwnerId?: string | null; channelConfig?: DiscordChannelConfigResolved | null; guildInfo?: DiscordGuildEntryResolved | null; + /** Pass pre-computed value to avoid redundant checks. */ + isAutoThreadOwnedByBot?: boolean; }): boolean { if (!params.isGuildMessage) return false; - if (params.isThread && params.channelConfig?.autoThread) return false; + // Only skip mention requirement in threads created by the bot (when autoThread is enabled). + const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params); + if (isBotThread) return false; return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; } +export function isDiscordAutoThreadOwnedByBot(params: { + isThread: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + botId?: string | null; + threadOwnerId?: string | null; +}): boolean { + if (!params.isThread) return false; + if (!params.channelConfig?.autoThread) return false; + const botId = params.botId?.trim(); + const threadOwnerId = params.threadOwnerId?.trim(); + return Boolean(botId && threadOwnerId && botId === threadOwnerId); +} + export function isDiscordGroupAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; guildAllowlisted: boolean; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 6df141e35..607b02cdd 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -328,9 +328,12 @@ export async function preflightDiscordMessage( } satisfies HistoryEntry) : undefined; + const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; const shouldRequireMention = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), + botId, + threadOwnerId, channelConfig, guildInfo, }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts new file mode 100644 index 000000000..351f46f74 --- /dev/null +++ b/src/discord/monitor/message-handler.process.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reactMessageDiscord = vi.fn(async () => {}); +const removeReactionDiscord = vi.fn(async () => {}); + +vi.mock("../send.js", () => ({ + reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), + removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), +})); + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +})); + +vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ + createReplyDispatcherWithTyping: vi.fn(() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), +})); + +import { processDiscordMessage } from "./message-handler.process.js"; + +async function createBaseContext(overrides: Record = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); + const storePath = path.join(dir, "sessions.json"); + return { + cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } }, + discordConfig: {}, + accountId: "default", + token: "token", + runtime: { log: () => {}, error: () => {} }, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1024, + textLimit: 4000, + replyToMode: "off", + ackReactionScope: "group-mentions", + groupPolicy: "open", + data: { guild: { id: "g1", name: "Guild" } }, + client: { rest: {} }, + message: { + id: "m1", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + }, + author: { + id: "U1", + username: "alice", + discriminator: "0", + globalName: "Alice", + }, + channelInfo: { name: "general" }, + channelName: "general", + isGuildMessage: true, + isDirectMessage: false, + isGroupDm: false, + commandAuthorized: true, + baseText: "hi", + messageText: "hi", + wasMentioned: false, + shouldRequireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + shouldBypassMention: false, + threadChannel: null, + threadParentId: undefined, + threadParentName: undefined, + threadParentType: undefined, + threadName: undefined, + displayChannelSlug: "general", + guildInfo: null, + guildSlug: "guild", + channelConfig: null, + baseSessionKey: "agent:main:discord:guild:g1", + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:guild:g1", + mainSessionKey: "agent:main:main", + }, + ...overrides, + }; +} + +beforeEach(() => { + reactMessageDiscord.mockClear(); + removeReactionDiscord.mockClear(); +}); + +describe("processDiscordMessage ack reactions", () => { + it("skips ack reactions for group-mentions when mentions are not required", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: false, + effectiveWasMentioned: false, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).not.toHaveBeenCalled(); + }); + + it("sends ack reactions for mention-gated guild messages when mentioned", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: true, + effectiveWasMentioned: true, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); + }); +}); diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index a681afa16..2647e5113 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -16,6 +16,7 @@ export type DiscordChannelInfo = { name?: string; topic?: string; parentId?: string; + ownerId?: string; }; type DiscordSnapshotAuthor = { @@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo( const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; const payload: DiscordChannelInfo = { type: channel.type, name, topic, parentId, + ownerId, }; DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value: payload, diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index bae4ef1c5..71af6408f 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -14,6 +14,7 @@ export type DiscordThreadChannel = { name?: string | null; parentId?: string | null; parent?: { id?: string; name?: string }; + ownerId?: string | null; }; export type DiscordThreadStarter = { @@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: { name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, + ownerId: channelInfo?.ownerId ?? undefined, }; } diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index d58e74ab2..e6f7ce906 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, - cwd: Type.Optional(Type.String()), - host: Type.Optional(Type.String()), - security: Type.Optional(Type.String()), - ask: Type.Optional(Type.String()), - agentId: Type.Optional(Type.String()), - resolvedPath: Type.Optional(Type.String()), - sessionKey: Type.Optional(Type.String()), + cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), + host: Type.Optional(Type.Union([Type.String(), Type.Null()])), + security: Type.Optional(Type.Union([Type.String(), Type.Null()])), + ask: Type.Optional(Type.Union([Type.String(), Type.Null()])), + agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts index 0b1da93f3..71a63e5a3 100644 --- a/src/gateway/server-methods/exec-approval.test.ts +++ b/src/gateway/server-methods/exec-approval.test.ts @@ -36,16 +36,16 @@ describe("exec approval handlers", () => { expect(validateExecApprovalRequestParams(params)).toBe(true); }); - // This documents the TypeBox/AJV behavior that caused the Discord exec bug: - // Type.Optional(Type.String()) does NOT accept null, only string or undefined. - it("rejects request with resolvedPath as null", () => { + // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()])) + // This matches the calling code in bash-tools.exec.ts which passes null. + it("accepts request with resolvedPath as null", () => { const params = { command: "echo hi", cwd: "/tmp", host: "node", resolvedPath: null, }; - expect(validateExecApprovalRequestParams(params)).toBe(false); + expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); From fdbaae6a33c4aa6571d2b816f643b5f128c2f474 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Sat, 24 Jan 2026 09:08:12 +1300 Subject: [PATCH 24/24] macOS: fix trigger word input disappearing when typing and on add (#1506) Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time. Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing. The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`. --- .../Sources/Clawdbot/VoiceWakeSettings.swift | 94 +++++++++++-------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift index 176980cc5..a41e8bb1f 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift @@ -21,6 +21,7 @@ struct VoiceWakeSettings: View { @State private var micObserver = AudioInputDeviceObserver() @State private var micRefreshTask: Task? @State private var availableLocales: [Locale] = [] + @State private var triggerEntries: [TriggerEntry] = [] private let fieldLabelWidth: CGFloat = 140 private let controlWidth: CGFloat = 240 private let isPreview = ProcessInfo.processInfo.isPreview @@ -31,9 +32,9 @@ struct VoiceWakeSettings: View { var id: String { self.uid } } - private struct IndexedWord: Identifiable { - let id: Int - let value: String + private struct TriggerEntry: Identifiable { + let id: UUID + var value: String } private var voiceWakeBinding: Binding { @@ -105,6 +106,7 @@ struct VoiceWakeSettings: View { .onAppear { guard !self.isPreview else { return } self.startMicObserver() + self.loadTriggerEntries() } .onChange(of: self.state.voiceWakeMicID) { _, _ in guard !self.isPreview else { return } @@ -122,8 +124,10 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil Task { await self.meter.stop() } self.micObserver.stop() + self.syncTriggerEntriesToState() } else { self.startMicObserver() + self.loadTriggerEntries() } } .onDisappear { @@ -136,11 +140,16 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil self.micObserver.stop() Task { await self.meter.stop() } + self.syncTriggerEntriesToState() } } - private var indexedWords: [IndexedWord] { - self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } + private func loadTriggerEntries() { + self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) } + } + + private func syncTriggerEntriesToState() { + self.state.swabbleTriggerWords = self.triggerEntries.map(\.value) } private var triggerTable: some View { @@ -154,29 +163,42 @@ struct VoiceWakeSettings: View { } label: { Label("Add word", systemImage: "plus") } - .disabled(self.state.swabbleTriggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + .disabled(self.triggerEntries + .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } + Button("Reset defaults") { + self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } + self.syncTriggerEntriesToState() + } } - Table(self.indexedWords) { - TableColumn("Word") { row in - TextField("Wake word", text: self.binding(for: row.id)) - .textFieldStyle(.roundedBorder) - } - TableColumn("") { row in - Button { - self.removeWord(at: row.id) - } label: { - Image(systemName: "trash") + VStack(spacing: 0) { + ForEach(self.$triggerEntries) { $entry in + HStack(spacing: 8) { + TextField("Wake word", text: $entry.value) + .textFieldStyle(.roundedBorder) + .onSubmit { + self.syncTriggerEntriesToState() + } + + Button { + self.removeWord(id: entry.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Remove trigger word") + .frame(width: 24) + } + .padding(8) + + if entry.id != self.triggerEntries.last?.id { + Divider() } - .buttonStyle(.borderless) - .help("Remove trigger word") } - .width(36) } - .frame(minHeight: 180) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) @@ -211,24 +233,12 @@ struct VoiceWakeSettings: View { } private func addWord() { - self.state.swabbleTriggerWords.append("") + self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) } - private func removeWord(at index: Int) { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords.remove(at: index) - } - - private func binding(for index: Int) -> Binding { - Binding( - get: { - guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" } - return self.state.swabbleTriggerWords[index] - }, - set: { newValue in - guard self.state.swabbleTriggerWords.indices.contains(index) else { return } - self.state.swabbleTriggerWords[index] = newValue - }) + private func removeWord(id: UUID) { + self.triggerEntries.removeAll { $0.id == id } + self.syncTriggerEntriesToState() } private func toggleTest() { @@ -638,13 +648,14 @@ extension VoiceWakeSettings { state.voicePushToTalkEnabled = true state.swabbleTriggerWords = ["Claude", "Hey"] - let view = VoiceWakeSettings(state: state, isActive: true) + var view = VoiceWakeSettings(state: state, isActive: true) view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableLocales = [Locale(identifier: "en_US")] view.meterLevel = 0.42 view.meterError = "No input" view.testState = .detected("ok") view.isTesting = true + view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")] _ = view.body _ = view.localePicker @@ -654,8 +665,9 @@ extension VoiceWakeSettings { _ = view.chimeSection view.addWord() - _ = view.binding(for: 0).wrappedValue - view.removeWord(at: 0) + if let entryId = view.triggerEntries.first?.id { + view.removeWord(id: entryId) + } } } #endif