From c623c51cf4e81cdfab17c1e42941f46718a85566 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:09:32 +0000 Subject: [PATCH 001/178] refactor(ui): share app mount hooks --- ui/src/ui/chat-markdown.browser.test.ts | 30 +++---------------------- ui/src/ui/focus-mode.browser.test.ts | 30 +++---------------------- ui/src/ui/test-helpers/app-mount.ts | 30 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 54 deletions(-) create mode 100644 ui/src/ui/test-helpers/app-mount.ts diff --git a/ui/src/ui/chat-markdown.browser.test.ts b/ui/src/ui/chat-markdown.browser.test.ts index c0e692b0732..17a898bac4c 100644 --- a/ui/src/ui/chat-markdown.browser.test.ts +++ b/ui/src/ui/chat-markdown.browser.test.ts @@ -1,31 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { OpenClawApp } from "./app.ts"; +import { describe, expect, it } from "vitest"; +import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; -// oxlint-disable-next-line typescript/unbound-method -const originalConnect = OpenClawApp.prototype.connect; - -function mountApp(pathname: string) { - window.history.replaceState({}, "", pathname); - const app = document.createElement("openclaw-app") as OpenClawApp; - document.body.append(app); - return app; -} - -beforeEach(() => { - OpenClawApp.prototype.connect = () => { - // no-op: avoid real gateway WS connections in browser tests - }; - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; - localStorage.clear(); - document.body.innerHTML = ""; -}); - -afterEach(() => { - OpenClawApp.prototype.connect = originalConnect; - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; - localStorage.clear(); - document.body.innerHTML = ""; -}); +registerAppMountHooks(); describe("chat markdown rendering", () => { it("renders markdown inside tool output sidebar", async () => { diff --git a/ui/src/ui/focus-mode.browser.test.ts b/ui/src/ui/focus-mode.browser.test.ts index 9576100d1e9..c134ecb5bf5 100644 --- a/ui/src/ui/focus-mode.browser.test.ts +++ b/ui/src/ui/focus-mode.browser.test.ts @@ -1,31 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { OpenClawApp } from "./app.ts"; +import { describe, expect, it } from "vitest"; +import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; -// oxlint-disable-next-line typescript/unbound-method -const originalConnect = OpenClawApp.prototype.connect; - -function mountApp(pathname: string) { - window.history.replaceState({}, "", pathname); - const app = document.createElement("openclaw-app") as OpenClawApp; - document.body.append(app); - return app; -} - -beforeEach(() => { - OpenClawApp.prototype.connect = () => { - // no-op: avoid real gateway WS connections in browser tests - }; - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; - localStorage.clear(); - document.body.innerHTML = ""; -}); - -afterEach(() => { - OpenClawApp.prototype.connect = originalConnect; - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; - localStorage.clear(); - document.body.innerHTML = ""; -}); +registerAppMountHooks(); describe("chat focus mode", () => { it("collapses header + sidebar on chat tab only", async () => { diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts new file mode 100644 index 00000000000..f45d712a50d --- /dev/null +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import { OpenClawApp } from "../app.ts"; + +// oxlint-disable-next-line typescript/unbound-method +const originalConnect = OpenClawApp.prototype.connect; + +export function mountApp(pathname: string) { + window.history.replaceState({}, "", pathname); + const app = document.createElement("openclaw-app") as OpenClawApp; + document.body.append(app); + return app; +} + +export function registerAppMountHooks() { + beforeEach(() => { + OpenClawApp.prototype.connect = () => { + // no-op: avoid real gateway WS connections in browser tests + }; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + OpenClawApp.prototype.connect = originalConnect; + window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; + localStorage.clear(); + document.body.innerHTML = ""; + }); +} From 5c233f4ded959502f1e7ca9d7d6dac67e804644c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:09:59 +0000 Subject: [PATCH 002/178] fix(ui): drop unused vi in test helper --- ui/src/ui/test-helpers/app-mount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index f45d712a50d..d21e453c44c 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, vi } from "vitest"; +import { afterEach, beforeEach } from "vitest"; import { OpenClawApp } from "../app.ts"; // oxlint-disable-next-line typescript/unbound-method From 69418cca2091efde2463cf7884fabd50d7228f00 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 15 Feb 2026 13:11:40 -0800 Subject: [PATCH 003/178] fix (tui): preserve copy-sensitive token wrapping --- src/terminal/note.test.ts | 35 ++++++++++++++++++++++++++++++++ src/terminal/note.ts | 37 ++++++++++++++++++++++++++++++++++ src/tui/tui-formatters.test.ts | 23 +++++++++++++++++++++ src/tui/tui-formatters.ts | 34 ++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/terminal/note.test.ts diff --git a/src/terminal/note.test.ts b/src/terminal/note.test.ts new file mode 100644 index 00000000000..7e51037483b --- /dev/null +++ b/src/terminal/note.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { wrapNoteMessage } from "./note.js"; + +describe("wrapNoteMessage", () => { + it("preserves long filesystem paths without inserting spaces/newlines", () => { + const input = + "/Users/user/Documents/Github/impact-signals-pipeline/with/really/long/segments/file.txt"; + const wrapped = wrapNoteMessage(input, { maxWidth: 22, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("preserves long urls without inserting spaces/newlines", () => { + const input = + "https://example.com/this/is/a/very/long/url/segment/that/should/not/be/split/for-copy"; + const wrapped = wrapNoteMessage(input, { maxWidth: 24, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("preserves long file-like underscore tokens for copy safety", () => { + const input = "administrators_authorized_keys_with_extra_suffix"; + const wrapped = wrapNoteMessage(input, { maxWidth: 14, columns: 80 }); + + expect(wrapped).toBe(input); + }); + + it("still chunks generic long opaque tokens to avoid pathological line width", () => { + const input = "x".repeat(70); + const wrapped = wrapNoteMessage(input, { maxWidth: 20, columns: 80 }); + + expect(wrapped).toContain("\n"); + expect(wrapped.replace(/\n/g, "")).toBe(input); + }); +}); diff --git a/src/terminal/note.ts b/src/terminal/note.ts index 48bca06fecd..e1dc5717f1a 100644 --- a/src/terminal/note.ts +++ b/src/terminal/note.ts @@ -2,6 +2,10 @@ import { note as clackNote } from "@clack/prompts"; import { visibleWidth } from "./ansi.js"; import { stylePromptTitle } from "./prompt-style.js"; +const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; + function splitLongWord(word: string, maxLen: number): string[] { if (maxLen <= 0) { return [word]; @@ -14,6 +18,31 @@ function splitLongWord(word: string, maxLen: number): string[] { return parts.length > 0 ? parts : [word]; } +function isCopySensitiveToken(word: string): boolean { + if (!word) { + return false; + } + if (URL_PREFIX_RE.test(word)) { + return true; + } + if ( + word.startsWith("/") || + word.startsWith("~/") || + word.startsWith("./") || + word.startsWith("../") + ) { + return true; + } + if (WINDOWS_DRIVE_RE.test(word) || word.startsWith("\\\\")) { + return true; + } + if (word.includes("/") || word.includes("\\")) { + return true; + } + // Preserve common file-like tokens (for example administrators_authorized_keys). + return word.includes("_") && FILE_LIKE_RE.test(word); +} + function wrapLine(line: string, maxWidth: number): string[] { if (line.trim().length === 0) { return [line]; @@ -36,6 +65,10 @@ function wrapLine(line: string, maxWidth: number): string[] { for (const word of words) { if (!current) { if (visibleWidth(word) > available) { + if (isCopySensitiveToken(word)) { + current = word; + continue; + } const parts = splitLongWord(word, available); const first = parts.shift() ?? ""; lines.push(prefix + first); @@ -61,6 +94,10 @@ function wrapLine(line: string, maxWidth: number): string[] { available = nextWidth; if (visibleWidth(word) > available) { + if (isCopySensitiveToken(word)) { + current = word; + continue; + } const parts = splitLongWord(word, available); const first = parts.shift() ?? ""; lines.push(prefix + first); diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 7e2a0bbf27a..13368748fec 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -160,4 +160,27 @@ describe("sanitizeRenderableText", () => { expect(longestSegment).toBeLessThanOrEqual(32); }); + + it("preserves long filesystem paths verbatim for copy safety", () => { + const input = + "/Users/jasonshawn/PerfectXiao/a_very_long_directory_name_designed_specifically_to_test_the_line_wrapping_issue/file.txt"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); + + it("preserves long urls verbatim for copy safety", () => { + const input = + "https://example.com/this/is/a/very/long/url/segment/that/should/remain/contiguous/when/rendered"; + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); + + it("preserves long file-like underscore tokens for copy safety", () => { + const input = "administrators_authorized_keys_with_extra_suffix".repeat(2); + const sanitized = sanitizeRenderableText(input); + + expect(sanitized).toBe(input); + }); }); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index ff7b7d49c6b..804d8ca4a5b 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -7,6 +7,9 @@ const MAX_TOKEN_CHARS = 32; const LONG_TOKEN_RE = /\S{33,}/g; const LONG_TOKEN_TEST_RE = /\S{33,}/; const BINARY_LINE_REPLACEMENT_THRESHOLD = 12; +const URL_PREFIX_RE = /^(https?:\/\/|file:\/\/)/i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const FILE_LIKE_RE = /^[a-zA-Z0-9._-]+$/; function hasControlChars(text: string): boolean { for (const char of text) { @@ -47,6 +50,35 @@ function chunkToken(token: string, maxChars: number): string[] { return chunks; } +function isCopySensitiveToken(token: string): boolean { + if (URL_PREFIX_RE.test(token)) { + return true; + } + if ( + token.startsWith("/") || + token.startsWith("~/") || + token.startsWith("./") || + token.startsWith("../") + ) { + return true; + } + if (WINDOWS_DRIVE_RE.test(token) || token.startsWith("\\\\")) { + return true; + } + if (token.includes("/") || token.includes("\\")) { + return true; + } + return token.includes("_") && FILE_LIKE_RE.test(token); +} + +function normalizeLongTokenForDisplay(token: string): string { + // Preserve copy-sensitive tokens exactly (paths/urls/file-like names). + if (isCopySensitiveToken(token)) { + return token; + } + return chunkToken(token, MAX_TOKEN_CHARS).join(" "); +} + function redactBinaryLikeLine(line: string): string { const replacementCount = (line.match(REPLACEMENT_CHAR_RE) || []).length; if ( @@ -80,7 +112,7 @@ export function sanitizeRenderableText(text: string): string { .join("\n") : withoutControlChars; return LONG_TOKEN_TEST_RE.test(redacted) - ? redacted.replace(LONG_TOKEN_RE, (token) => chunkToken(token, MAX_TOKEN_CHARS).join(" ")) + ? redacted.replace(LONG_TOKEN_RE, normalizeLongTokenForDisplay) : redacted; } From 150c5815eb81abf8b36d757d6469781f8060515a Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 15 Feb 2026 13:11:44 -0800 Subject: [PATCH 004/178] fix (agents): honor configured contextWindow overrides --- src/agents/context.test.ts | 41 +++++++++++++++++++++++++++++ src/agents/context.ts | 54 +++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 src/agents/context.test.ts diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts new file mode 100644 index 00000000000..41111b4bb41 --- /dev/null +++ b/src/agents/context.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { applyConfiguredContextWindows } from "./context.js"; + +describe("applyConfiguredContextWindows", () => { + it("overrides discovered cache values with explicit models.providers contextWindow", () => { + const cache = new Map([["anthropic/claude-opus-4-6", 1_000_000]]); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [{ id: "anthropic/claude-opus-4-6", contextWindow: 200_000 }], + }, + }, + }, + }); + + expect(cache.get("anthropic/claude-opus-4-6")).toBe(200_000); + }); + + it("adds config-only model context windows and ignores invalid entries", () => { + const cache = new Map(); + applyConfiguredContextWindows({ + cache, + modelsConfig: { + providers: { + openrouter: { + models: [ + { id: "custom/model", contextWindow: 150_000 }, + { id: "bad/model", contextWindow: 0 }, + { id: "", contextWindow: 300_000 }, + ], + }, + }, + }, + }); + + expect(cache.get("custom/model")).toBe(150_000); + expect(cache.has("bad/model")).toBe(false); + }); +}); diff --git a/src/agents/context.ts b/src/agents/context.ts index b3683e235f2..c919dbf9095 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -6,13 +6,52 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; type ModelEntry = { id: string; contextWindow?: number }; +type ConfigModelEntry = { id?: string; contextWindow?: number }; +type ProviderConfigEntry = { models?: ConfigModelEntry[] }; +type ModelsConfig = { providers?: Record }; + +export function applyConfiguredContextWindows(params: { + cache: Map; + modelsConfig: ModelsConfig | undefined; +}) { + const providers = params.modelsConfig?.providers; + if (!providers || typeof providers !== "object") { + return; + } + for (const provider of Object.values(providers)) { + if (!Array.isArray(provider?.models)) { + continue; + } + for (const model of provider.models) { + const modelId = typeof model?.id === "string" ? model.id : undefined; + const contextWindow = + typeof model?.contextWindow === "number" ? model.contextWindow : undefined; + if (!modelId || !contextWindow || contextWindow <= 0) { + continue; + } + params.cache.set(modelId, contextWindow); + } + } +} const MODEL_CACHE = new Map(); const loadPromise = (async () => { + let cfg: ReturnType | undefined; + try { + cfg = loadConfig(); + } catch { + // If config can't be loaded, leave cache empty. + return; + } + + try { + await ensureOpenClawModelsJson(cfg); + } catch { + // Continue with best-effort discovery/overrides. + } + try { const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js"); - const cfg = loadConfig(); - await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const modelRegistry = discoverModels(authStorage, agentDir); @@ -26,9 +65,16 @@ const loadPromise = (async () => { } } } catch { - // If pi-ai isn't available, leave cache empty; lookup will fall back. + // If model discovery fails, continue with config overrides only. } -})(); + + applyConfiguredContextWindows({ + cache: MODEL_CACHE, + modelsConfig: cfg.models as ModelsConfig | undefined, + }); +})().catch(() => { + // Keep lookup best-effort. +}); export function lookupContextTokens(modelId?: string): number | undefined { if (!modelId) { From 059573a48d33851f0fb2cdc730bec314c67fa8a0 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 15 Feb 2026 13:11:48 -0800 Subject: [PATCH 005/178] chore (changelog): attribute issues #17515 #17466 #17505 #17404 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1e1eeea36..5b8d036b859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. - TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane. - TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07. +- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry. - Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. - Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. - Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. @@ -28,6 +29,7 @@ Docs: https://docs.openclaw.ai - Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n. - Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. - Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. +- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07. - CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. From a02e5759cc081b4e78702b5b65b4e0aa20e9fb97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:18:10 +0000 Subject: [PATCH 006/178] refactor(test): dedupe pi embedded subscribe e2e harness --- .../pi-embedded-subscribe.e2e-harness.ts | 18 ++++ ...-tool-execution-start-preserve.e2e.test.ts | 7 -- ...not-append-text-end-content-is.e2e.test.ts | 7 -- ...lockreplyflush-callback-is-not.e2e.test.ts | 7 -- ...uplicate-text-end-repeats-full.e2e.test.ts | 7 -- ...t-duplicate-block-replies-text.e2e.test.ts | 86 +++++-------------- ...lock-replies-text-end-does-not.e2e.test.ts | 7 -- ...esses-output-without-start-tag.e2e.test.ts | 7 -- ...action-metadata-tool-summaries.e2e.test.ts | 7 -- ...final-answer-block-replies-are.e2e.test.ts | 7 -- ...-indented-fenced-blocks-intact.e2e.test.ts | 7 -- ...d-blocks-splitting-inside-them.e2e.test.ts | 7 -- ...ngle-line-fenced-blocks-reopen.e2e.test.ts | 7 -- ...ft-chunks-paragraph-preference.e2e.test.ts | 40 ++------- ...end-block-replies-message-tool.e2e.test.ts | 7 -- ...ction-retries-before-resolving.e2e.test.ts | 7 -- 16 files changed, 49 insertions(+), 186 deletions(-) create mode 100644 src/agents/pi-embedded-subscribe.e2e-harness.ts diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts new file mode 100644 index 00000000000..d7b66ebb65e --- /dev/null +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -0,0 +1,18 @@ +import type { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; + +type PiSession = Parameters[0]["session"]; + +export function createStubSessionHarness(): { + session: PiSession; + emit: (evt: unknown) => void; +} { + let handler: ((evt: unknown) => void) | undefined; + const session = { + subscribe: (fn: (evt: unknown) => void) => { + handler = fn; + return () => {}; + }, + } as unknown as PiSession; + + return { session, emit: (evt: unknown) => handler?.(evt) }; +} diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts index 30336ed38ec..020d7e939d4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.calls-onblockreplyflush-before-tool-execution-start-preserve.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("calls onBlockReplyFlush before tool_execution_start to preserve message boundaries", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts index 690a1d7abf4..c268c11ff86 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-append-text-end-content-is.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - function setupTextEndSubscription() { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts index 60460571309..1a909ae2746 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-call-onblockreplyflush-callback-is-not.e2e.test.ts @@ -8,13 +8,6 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not call onBlockReplyFlush when callback is not provided", () => { let handler: SessionEventHandler | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts index 00138a7f9ab..a68984b272d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-duplicate-text-end-repeats-full.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not duplicate when text_end repeats full content", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts index 827c58193fd..ee7037a24c0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.e2e.test.ts @@ -1,40 +1,22 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - -type SessionEventHandler = (evt: unknown) => void; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("does not emit duplicate block replies when text_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "text_end", }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -43,7 +25,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -51,7 +33,7 @@ describe("subscribeEmbeddedPiSession", () => { }, }); - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -63,16 +45,10 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Hello block"]); }); it("does not duplicate assistantTexts when message_end repeats", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -81,22 +57,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", }); @@ -110,22 +80,16 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessageWithNewline }); - handler?.({ type: "message_end", message: assistantMessageTrimmed }); + emit({ type: "message_end", message: assistantMessageWithNewline }); + emit({ type: "message_end", message: assistantMessageTrimmed }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", reasoningMode: "on", }); @@ -138,37 +102,31 @@ describe("subscribeEmbeddedPiSession", () => { ], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Hello world"]); }); it("populates assistantTexts for non-streaming models with chunking enabled", () => { // Non-streaming models (e.g. zai/glm-4.7): no text_delta events; message_end // must still populate assistantTexts so providers can deliver a final reply. - let handler: SessionEventHandler | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", blockReplyChunking: { minChars: 50, maxChars: 200 }, // Chunking enabled }); // Simulate non-streaming model: only message_start and message_end, no text_delta - handler?.({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_start", message: { role: "assistant" } }); const assistantMessage = { role: "assistant", content: [{ type: "text", text: "Response from non-streaming model" }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(subscription.assistantTexts).toEqual(["Response from non-streaming model"]); }); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts index d8fcf94c91e..7ce844c55a9 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.emits-block-replies-text-end-does-not.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("emits block replies on text_end and does not duplicate on message_end", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts index ad7bdfd81cb..1dad92b6ce8 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("filters to and suppresses output without a start tag", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts index 37532c48a86..3b04100219b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.includes-canvas-action-metadata-tool-summaries.e2e.test.ts @@ -6,13 +6,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("includes canvas action metadata in tool summaries", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts index 8b4d539465c..0bb70f3d8b5 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-assistanttexts-final-answer-block-replies-are.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps assistantTexts to the final answer when block replies are disabled", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts index d8d868541ad..507ca49da7b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.keeps-indented-fenced-blocks-intact.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("keeps indented fenced blocks intact", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts index f786b104f1f..b3d800af04b 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.reopens-fenced-blocks-splitting-inside-them.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("reopens fenced blocks when splitting inside them", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts index 19cbeaa2a40..f6eeb24a27d 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("splits long single-line fenced blocks with reopen/close", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts index 59973be7e21..6c1bd3f0b13 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.streams-soft-chunks-paragraph-preference.e2e.test.ts @@ -1,32 +1,16 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("streams soft chunks with paragraph preference", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); const subscription = subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -39,7 +23,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "First block line\n\nSecond block line"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -53,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(2); expect(onBlockReply.mock.calls[0][0].text).toBe("First block line"); @@ -61,18 +45,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["First block line", "Second block line"]); }); it("avoids splitting inside fenced code blocks", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onBlockReply, blockReplyBreak: "message_end", @@ -85,7 +63,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = "Intro\n\n```bash\nline1\nline2\n```\n\nOutro"; - handler?.({ + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -99,7 +77,7 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text }], } as AssistantMessage; - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(3); expect(onBlockReply.mock.calls[0][0].text).toBe("Intro"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts index a28d55358b4..bb0fff53264 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("suppresses message_end block replies when the message tool already sent", async () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts index 2f961082555..319baf58bf8 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.waits-multiple-compaction-retries-before-resolving.e2e.test.ts @@ -7,13 +7,6 @@ type StubSession = { }; describe("subscribeEmbeddedPiSession", () => { - const _THINKING_TAG_CASES = [ - { tag: "think", open: "", close: "" }, - { tag: "thinking", open: "", close: "" }, - { tag: "thought", open: "", close: "" }, - { tag: "antthinking", open: "", close: "" }, - ] as const; - it("waits for multiple compaction retries before resolving", async () => { const listeners: SessionEventHandler[] = []; const session = { From 5958454710b661eaad13ff8853262dca662a14bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:27:07 +0000 Subject: [PATCH 007/178] refactor(test): share auth profile order fixtures --- ...-lastgood-round-robin-ordering.e2e.test.ts | 29 ++++--------------- ...les.resolve-auth-profile-order.fixtures.ts | 26 +++++++++++++++++ ...alizes-z-ai-aliases-auth-order.e2e.test.ts | 24 --------------- ...tused-no-explicit-order-exists.e2e.test.ts | 24 --------------- ...ored-profiles-no-config-exists.e2e.test.ts | 29 ++++--------------- 5 files changed, 38 insertions(+), 94 deletions(-) create mode 100644 src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts index 692b67a01cf..79f22798949 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.does-not-prioritize-lastgood-round-robin-ordering.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("does not prioritize lastGood over round-robin ordering", () => { const order = resolveAuthProfileOrder({ diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts new file mode 100644 index 00000000000..bc7b5cf983d --- /dev/null +++ b/src/agents/auth-profiles.resolve-auth-profile-order.fixtures.ts @@ -0,0 +1,26 @@ +import type { AuthProfileStore } from "./auth-profiles.js"; + +export const ANTHROPIC_STORE: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-work", + }, + }, +}; + +export const ANTHROPIC_CFG = { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "api_key" }, + "anthropic:work": { provider: "anthropic", mode: "api_key" }, + }, + }, +}; diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts index a6bd59b3bb6..0817f2280ea 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.normalizes-z-ai-aliases-auth-order.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("normalizes z.ai aliases in auth.order", () => { const order = resolveAuthProfileOrder({ cfg: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts index 55816522c27..2842fb48e15 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.orders-by-lastused-no-explicit-order-exists.e2e.test.ts @@ -2,30 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; describe("resolveAuthProfileOrder", () => { - const _store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const _cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; - it("orders by lastUsed when no explicit order exists", () => { const order = resolveAuthProfileOrder({ store: { diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts index 0a4344bb6b1..c5ec9826e36 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts @@ -1,30 +1,13 @@ import { describe, expect, it } from "vitest"; import { resolveAuthProfileOrder } from "./auth-profiles.js"; +import { + ANTHROPIC_CFG, + ANTHROPIC_STORE, +} from "./auth-profiles.resolve-auth-profile-order.fixtures.js"; describe("resolveAuthProfileOrder", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - "anthropic:work": { - type: "api_key", - provider: "anthropic", - key: "sk-work", - }, - }, - }; - const cfg = { - auth: { - profiles: { - "anthropic:default": { provider: "anthropic", mode: "api_key" }, - "anthropic:work": { provider: "anthropic", mode: "api_key" }, - }, - }, - }; + const store = ANTHROPIC_STORE; + const cfg = ANTHROPIC_CFG; it("uses stored profiles when no config exists", () => { const order = resolveAuthProfileOrder({ From 856e1a3187a80ef970c2a0ce076044e225663e67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:29:15 +0000 Subject: [PATCH 008/178] refactor(test): share skills e2e helper --- ...out-affecting-workspace-skills.e2e.test.ts | 23 +----------------- ...orkspace-skills-managed-skills.e2e.test.ts | 23 +----------------- src/agents/skills.e2e-test-helpers.ts | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 44 deletions(-) create mode 100644 src/agents/skills.e2e-test-helpers.ts diff --git a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts index 44a8e0218a5..dad26e0fb74 100644 --- a/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.applies-bundled-allowlist-without-affecting-workspace-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("applies bundled allowlist without affecting workspace skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts index cc85f1f5701..af9c651fc80 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts @@ -2,30 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { writeSkill } from "./skills.e2e-test-helpers.js"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -async function writeSkill(params: { - dir: string; - name: string; - description: string; - metadata?: string; - body?: string; -}) { - const { dir, name, description, metadata, body } = params; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile( - path.join(dir, "SKILL.md"), - `--- -name: ${name} -description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} ---- - -${body ?? `# ${name}\n`} -`, - "utf-8", - ); -} - describe("buildWorkspaceSkillsPrompt", () => { it("prefers workspace skills over managed skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-")); diff --git a/src/agents/skills.e2e-test-helpers.ts b/src/agents/skills.e2e-test-helpers.ts new file mode 100644 index 00000000000..43f6fb70398 --- /dev/null +++ b/src/agents/skills.e2e-test-helpers.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function writeSkill(params: { + dir: string; + name: string; + description: string; + metadata?: string; + body?: string; +}) { + const { dir, name, description, metadata, body } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""} +--- + +${body ?? `# ${name}\n`} +`, + "utf-8", + ); +} From aabe4d9b456d29c892a02a0d91c0f09e6468fb35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:31:23 +0000 Subject: [PATCH 009/178] refactor(test): reuse env snapshot helper --- src/agents/agent-paths.e2e.test.ts | 21 +++------------------ src/test-utils/env.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 18 deletions(-) create mode 100644 src/test-utils/env.ts diff --git a/src/agents/agent-paths.e2e.test.ts b/src/agents/agent-paths.e2e.test.ts index f455f82862c..f0df2cbbdbc 100644 --- a/src/agents/agent-paths.e2e.test.ts +++ b/src/agents/agent-paths.e2e.test.ts @@ -2,12 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; describe("resolveOpenClawAgentDir", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const env = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_AGENT_DIR", "PI_CODING_AGENT_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -15,21 +14,7 @@ describe("resolveOpenClawAgentDir", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + env.restore(); }); it("defaults to the multi-agent path when no overrides are set", async () => { diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts new file mode 100644 index 00000000000..0976987c272 --- /dev/null +++ b/src/test-utils/env.ts @@ -0,0 +1,18 @@ +export function captureEnv(keys: string[]) { + const snapshot = new Map(); + for (const key of keys) { + snapshot.set(key, process.env[key]); + } + + return { + restore() { + for (const [key, value] of snapshot) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }, + }; +} From 84601bf96bfc9c723483327550399c85b4e58f6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:34:15 +0000 Subject: [PATCH 010/178] fix(test): fix pi embedded subscribe harness typing --- src/agents/pi-embedded-subscribe.e2e-harness.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts index d7b66ebb65e..88370693841 100644 --- a/src/agents/pi-embedded-subscribe.e2e-harness.ts +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -1,6 +1,6 @@ -import type { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; - -type PiSession = Parameters[0]["session"]; +type SubscribeEmbeddedPiSession = + typeof import("./pi-embedded-subscribe.js").subscribeEmbeddedPiSession; +type PiSession = Parameters[0]["session"]; export function createStubSessionHarness(): { session: PiSession; From c3812a1ffb9cfd3e2a8eee8b3f0beb49d290eb01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:41:18 +0000 Subject: [PATCH 011/178] refactor(test): share gateway e2e registry helper --- ...ver.agent.gateway-server-agent-a.e2e.test.ts | 14 +------------- ...ver.agent.gateway-server-agent-b.e2e.test.ts | 17 +---------------- src/gateway/server.channels.e2e.test.ts | 14 +------------- src/gateway/server.e2e-registry-helpers.ts | 17 +++++++++++++++++ .../server.models-voicewake-misc.e2e.test.ts | 14 +------------- 5 files changed, 21 insertions(+), 55 deletions(-) create mode 100644 src/gateway/server.e2e-registry-helpers.ts diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index ed6b9d4cce2..56de4256b4c 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; +import { createRegistry } from "./server.e2e-registry-helpers.js"; import { agentCommand, connectOk, @@ -42,19 +43,6 @@ function expectChannels(call: Record, channel: string) { expect(runContext?.messageChannel).toBe(channel); } -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - const createStubChannelPlugin = (params: { id: ChannelPlugin["id"]; label: string; diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index 2bbe39ecf02..12639455f17 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -10,6 +10,7 @@ import { BARE_SESSION_RESET_PROMPT } from "../auto-reply/reply/session-reset-pro import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; +import { createRegistry } from "./server.e2e-registry-helpers.js"; import { agentCommand, connectOk, @@ -42,22 +43,6 @@ afterAll(async () => { await server.close(); }); -const _BASE_IMAGE_PNG = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; - -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => ({ id: "msteams", meta: { diff --git a/src/gateway/server.channels.e2e.test.ts b/src/gateway/server.channels.e2e.test.ts index c65b87c103a..87661d846b3 100644 --- a/src/gateway/server.channels.e2e.test.ts +++ b/src/gateway/server.channels.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createRegistry } from "./server.e2e-registry-helpers.js"; import { connectOk, installGatewayTestHooks, @@ -41,19 +42,6 @@ vi.mock("./server-plugins.js", async () => { }; }); -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - const createStubChannelPlugin = (params: { id: ChannelPlugin["id"]; label: string; diff --git a/src/gateway/server.e2e-registry-helpers.ts b/src/gateway/server.e2e-registry-helpers.ts new file mode 100644 index 00000000000..148d3fe8227 --- /dev/null +++ b/src/gateway/server.e2e-registry-helpers.ts @@ -0,0 +1,17 @@ +import type { PluginRegistry } from "../plugins/registry.js"; + +export const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels, + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], +}); diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index 27ae4237a5d..03a0c15bc14 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -12,6 +12,7 @@ import { GatewayLockError } from "../infra/gateway-lock.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { createRegistry } from "./server.e2e-registry-helpers.js"; import { connectOk, getFreePort, @@ -67,19 +68,6 @@ const whatsappPlugin = createOutboundTestPlugin({ label: "WhatsApp", }); -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - const whatsappRegistry = createRegistry([ { pluginId: "whatsapp", From 27deda2221a9a0bb45017f0a60d312d435148281 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:42:35 +0000 Subject: [PATCH 012/178] fix(test): drop unused gateway e2e PluginRegistry imports --- src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts | 1 - src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts | 1 - src/gateway/server.models-voicewake-misc.e2e.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts index 56de4256b4c..6a6f2f74ed7 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { PluginRegistry } from "../plugins/registry.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index 12639455f17..f191f23f826 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { PluginRegistry } from "../plugins/registry.js"; import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js"; import { BARE_SESSION_RESET_PROMPT } from "../auto-reply/reply/session-reset-prompt.js"; import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index 03a0c15bc14..9b9bc657b4b 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import type { ChannelOutboundAdapter } from "../channels/plugins/types.js"; -import type { PluginRegistry } from "../plugins/registry.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; From ff4f59ec9062bfa675257f8fd5ef30df1f5e21db Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:45:17 -0800 Subject: [PATCH 013/178] feat(image-tool): support multiple images in a single tool call (#17512) * feat(image-tool): support multiple images in a single tool call - Change 'image' parameter to accept string | string[] (Type.Union) - Add 'maxImages' parameter (default 5) to cap abuse/token explosion - Update buildImageContext to create multiple image content parts - Normalize single string input to array for unified processing - Keep full backward compatibility: single string works as before - Update tool descriptions for both vision and non-vision models - MiniMax VLM falls back to first image (single-image API) - Details output adapts: 'image' key for single, 'images' for multi * bump default max images from 5 to 20 --- src/agents/tools/image-tool.e2e.test.ts | 4 +- src/agents/tools/image-tool.ts | 242 ++++++++++++++++-------- 2 files changed, 159 insertions(+), 87 deletions(-) diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index d5daf9d5de7..2b58753777c 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -192,9 +192,7 @@ describe("image tool implicit imageModel config", () => { }); const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true }); expect(tool).not.toBeNull(); - expect(tool?.description).toContain( - "Only use this tool when the image was NOT already provided", - ); + expect(tool?.description).toContain("Only use this tool when images were NOT already provided"); }); it("allows workspace images outside default local media roots", async () => { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 896b7447138..3d63623b778 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -26,6 +26,7 @@ import { const DEFAULT_PROMPT = "Describe the image."; const ANTHROPIC_IMAGE_PRIMARY = "anthropic/claude-opus-4-6"; const ANTHROPIC_IMAGE_FALLBACK = "anthropic/claude-opus-4-5"; +const DEFAULT_MAX_IMAGES = 20; export const __testing = { decodeDataUrl, @@ -182,15 +183,21 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef return undefined; } -function buildImageContext(prompt: string, base64: string, mimeType: string): Context { +function buildImageContext( + prompt: string, + images: Array<{ base64: string; mimeType: string }>, +): Context { + const content: Array< + { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } + > = [{ type: "text", text: prompt }]; + for (const img of images) { + content.push({ type: "image", data: img.base64, mimeType: img.mimeType }); + } return { messages: [ { role: "user", - content: [ - { type: "text", text: prompt }, - { type: "image", data: base64, mimeType }, - ], + content, timestamp: Date.now(), }, ], @@ -242,8 +249,7 @@ async function runImagePrompt(params: { imageModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; - base64: string; - mimeType: string; + images: Array<{ base64: string; mimeType: string }>; }): Promise<{ text: string; provider: string; @@ -285,9 +291,11 @@ async function runImagePrompt(params: { }); const apiKey = requireApiKey(apiKeyInfo, model.provider); authStorage.setRuntimeApiKey(model.provider, apiKey); - const imageDataUrl = `data:${params.mimeType};base64,${params.base64}`; + // MiniMax VLM only supports a single image; use the first one. if (model.provider === "minimax") { + const first = params.images[0]; + const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`; const text = await minimaxUnderstandImage({ apiKey, prompt: params.prompt, @@ -297,7 +305,7 @@ async function runImagePrompt(params: { return { text, provider: model.provider, model: model.id }; } - const context = buildImageContext(params.prompt, params.base64, params.mimeType); + const context = buildImageContext(params.prompt, params.images); const message = await complete(model, context, { apiKey, maxTokens: resolveImageToolMaxTokens(model.maxTokens), @@ -350,8 +358,8 @@ export function createImageTool(options?: { // If model has native vision, images in the prompt are auto-injected // so this tool is only needed when image wasn't provided in the prompt const description = options?.modelHasVision - ? "Analyze an image with a vision model. Only use this tool when the image was NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." - : "Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL."; + ? "Analyze one or more images with a vision model. Pass a single image path/URL or an array of up to 20. Only use this tool when images were NOT already provided in the user's message. Images mentioned in the prompt are automatically visible to you." + : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Pass a single image path/URL or an array of up to 20. Provide a prompt describing what to analyze."; const localRoots = (() => { const roots = getDefaultLocalRoots(); @@ -368,44 +376,47 @@ export function createImageTool(options?: { description, parameters: Type.Object({ prompt: Type.Optional(Type.String()), - image: Type.String(), + image: Type.Union([Type.String(), Type.Array(Type.String())]), model: Type.Optional(Type.String()), maxBytesMb: Type.Optional(Type.Number()), + maxImages: Type.Optional(Type.Number()), }), execute: async (_toolCallId, args) => { const record = args && typeof args === "object" ? (args as Record) : {}; - const imageRawInput = typeof record.image === "string" ? record.image.trim() : ""; - const imageRaw = imageRawInput.startsWith("@") - ? imageRawInput.slice(1).trim() - : imageRawInput; - if (!imageRaw) { + + // MARK: - Normalize image input (string | string[]) + const rawImageInput = record.image; + const imageInputs: string[] = (() => { + if (typeof rawImageInput === "string") { + return [rawImageInput]; + } + if (Array.isArray(rawImageInput)) { + return rawImageInput.filter((v): v is string => typeof v === "string"); + } + return []; + })(); + if (imageInputs.length === 0) { throw new Error("image required"); } - // The tool accepts file paths, file/data URLs, or http(s) URLs. In some - // agent/model contexts, images can be referenced as pseudo-URIs like - // `image:0` (e.g. "first image in the prompt"). We don't have access to a - // shared image registry here, so fail gracefully instead of attempting to - // `fs.readFile("image:0")` and producing a noisy ENOENT. - const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); - const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); - const isFileUrl = /^file:/i.test(imageRaw); - const isHttpUrl = /^https?:\/\//i.test(imageRaw); - const isDataUrl = /^data:/i.test(imageRaw); - if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + // MARK: - Enforce max images cap + const maxImagesRaw = typeof record.maxImages === "number" ? record.maxImages : undefined; + const maxImages = + typeof maxImagesRaw === "number" && Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 + ? Math.floor(maxImagesRaw) + : DEFAULT_MAX_IMAGES; + if (imageInputs.length > maxImages) { return { content: [ { type: "text", - text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + text: `Too many images: ${imageInputs.length} provided, maximum is ${maxImages}. Please reduce the number of images.`, }, ], - details: { - error: "unsupported_image_reference", - image: imageRawInput, - }, + details: { error: "too_many_images", count: imageInputs.length, max: maxImages }, }; } + const promptRaw = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() @@ -419,73 +430,136 @@ export function createImageTool(options?: { options?.sandbox && options?.sandbox.root.trim() ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge } : null; - const isUrl = isHttpUrl; - if (sandboxConfig && isUrl) { - throw new Error("Sandboxed image tool does not allow remote URLs."); - } - const resolvedImage = (() => { - if (sandboxConfig) { + // MARK: - Load and resolve each image + const loadedImages: Array<{ + base64: string; + mimeType: string; + resolvedImage: string; + rewrittenFrom?: string; + }> = []; + + for (const imageRawInput of imageInputs) { + const trimmed = imageRawInput.trim(); + const imageRaw = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!imageRaw) { + throw new Error("image required (empty string in array)"); + } + + // The tool accepts file paths, file/data URLs, or http(s) URLs. In some + // agent/model contexts, images can be referenced as pseudo-URIs like + // `image:0` (e.g. "first image in the prompt"). We don't have access to a + // shared image registry here, so fail gracefully instead of attempting to + // `fs.readFile("image:0")` and producing a noisy ENOENT. + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); + const isFileUrl = /^file:/i.test(imageRaw); + const isHttpUrl = /^https?:\/\//i.test(imageRaw); + const isDataUrl = /^data:/i.test(imageRaw); + if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + return { + content: [ + { + type: "text", + text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + }, + ], + details: { + error: "unsupported_image_reference", + image: imageRawInput, + }, + }; + } + + if (sandboxConfig && isHttpUrl) { + throw new Error("Sandboxed image tool does not allow remote URLs."); + } + + const resolvedImage = (() => { + if (sandboxConfig) { + return imageRaw; + } + if (imageRaw.startsWith("~")) { + return resolveUserPath(imageRaw); + } return imageRaw; - } - if (imageRaw.startsWith("~")) { - return resolveUserPath(imageRaw); - } - return imageRaw; - })(); - const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl - ? { resolved: "" } - : sandboxConfig - ? await resolveSandboxedImagePath({ - sandbox: sandboxConfig, - imagePath: resolvedImage, - }) - : { - resolved: resolvedImage.startsWith("file://") - ? resolvedImage.slice("file://".length) - : resolvedImage, - }; - const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; + })(); + const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl + ? { resolved: "" } + : sandboxConfig + ? await resolveSandboxedImagePath({ + sandbox: sandboxConfig, + imagePath: resolvedImage, + }) + : { + resolved: resolvedImage.startsWith("file://") + ? resolvedImage.slice("file://".length) + : resolvedImage, + }; + const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; - const media = isDataUrl - ? decodeDataUrl(resolvedImage) - : sandboxConfig - ? await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - sandboxValidated: true, - readFile: (filePath) => - sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), - }) - : await loadWebMedia(resolvedPath ?? resolvedImage, { - maxBytes, - localRoots, - }); - if (media.kind !== "image") { - throw new Error(`Unsupported media type: ${media.kind}`); + const media = isDataUrl + ? decodeDataUrl(resolvedImage) + : sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + sandboxValidated: true, + readFile: (filePath) => + sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes, + localRoots, + }); + if (media.kind !== "image") { + throw new Error(`Unsupported media type: ${media.kind}`); + } + + const mimeType = + ("contentType" in media && media.contentType) || + ("mimeType" in media && media.mimeType) || + "image/png"; + const base64 = media.buffer.toString("base64"); + loadedImages.push({ + base64, + mimeType, + resolvedImage, + ...(resolvedPathInfo.rewrittenFrom + ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } + : {}), + }); } - const mimeType = - ("contentType" in media && media.contentType) || - ("mimeType" in media && media.mimeType) || - "image/png"; - const base64 = media.buffer.toString("base64"); + // MARK: - Run image prompt with all loaded images const result = await runImagePrompt({ cfg: options?.config, agentDir, imageModelConfig, modelOverride, prompt: promptRaw, - base64, - mimeType, + images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })), }); + + const imageDetails = + loadedImages.length === 1 + ? { + image: loadedImages[0].resolvedImage, + ...(loadedImages[0].rewrittenFrom + ? { rewrittenFrom: loadedImages[0].rewrittenFrom } + : {}), + } + : { + images: loadedImages.map((img) => ({ + image: img.resolvedImage, + ...(img.rewrittenFrom ? { rewrittenFrom: img.rewrittenFrom } : {}), + })), + }; + return { content: [{ type: "text", text: result.text }], details: { model: `${result.provider}/${result.model}`, - image: resolvedImage, - ...(resolvedPathInfo.rewrittenFrom - ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } - : {}), + ...imageDetails, attempts: result.attempts, }, }; From c9bb6bd0d8689db5669a5e64fef4725211643254 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:45:38 +0000 Subject: [PATCH 014/178] refactor(infra): extract json file + async lock helpers --- src/infra/json-files.ts | 52 ++++++++++++++++++++++++++++++++++++++ src/infra/pairing-files.ts | 48 ++--------------------------------- src/infra/voicewake.ts | 39 +++------------------------- 3 files changed, 58 insertions(+), 81 deletions(-) create mode 100644 src/infra/json-files.ts diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts new file mode 100644 index 00000000000..d71cbf7639b --- /dev/null +++ b/src/infra/json-files.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function writeJsonAtomic( + filePath: string, + value: unknown, + options?: { mode?: number }, +) { + const mode = options?.mode ?? 0o600; + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${filePath}.${randomUUID()}.tmp`; + await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); + try { + await fs.chmod(tmp, mode); + } catch { + // best-effort; ignore on platforms without chmod + } + await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, mode); + } catch { + // best-effort; ignore on platforms without chmod + } +} + +export function createAsyncLock() { + let lock: Promise = Promise.resolve(); + return async function withLock(fn: () => Promise): Promise { + const prev = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await prev; + try { + return await fn(); + } finally { + release?.(); + } + }; +} diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index fb6c3ee5ebb..f2578facdfb 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -1,8 +1,8 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +export { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; + export function resolvePairingPaths(baseDir: string | undefined, subdir: string) { const root = baseDir ?? resolveStateDir(); const dir = path.join(root, subdir); @@ -13,33 +13,6 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string) }; } -export async function readJsonFile(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw) as T; - } catch { - return null; - } -} - -export async function writeJsonAtomic(filePath: string, value: unknown) { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - const tmp = `${filePath}.${randomUUID()}.tmp`; - await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); - try { - await fs.chmod(tmp, 0o600); - } catch { - // best-effort; ignore on platforms without chmod - } - await fs.rename(tmp, filePath); - try { - await fs.chmod(filePath, 0o600); - } catch { - // best-effort; ignore on platforms without chmod - } -} - export function pruneExpiredPending( pendingById: Record, nowMs: number, @@ -51,20 +24,3 @@ export function pruneExpiredPending( } } } - -export function createAsyncLock() { - let lock: Promise = Promise.resolve(); - return async function withLock(fn: () => Promise): Promise { - const prev = lock; - let release: (() => void) | undefined; - lock = new Promise((resolve) => { - release = resolve; - }); - await prev; - try { - return await fn(); - } finally { - release?.(); - } - }; -} diff --git a/src/infra/voicewake.ts b/src/infra/voicewake.ts index 9d0867a0a00..ee73c8e40a4 100644 --- a/src/infra/voicewake.ts +++ b/src/infra/voicewake.ts @@ -1,7 +1,6 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; +import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; export type VoiceWakeConfig = { triggers: string[]; @@ -22,37 +21,7 @@ function sanitizeTriggers(triggers: string[] | undefined | null): string[] { return cleaned.length > 0 ? cleaned : DEFAULT_TRIGGERS; } -async function readJSON(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw) as T; - } catch { - return null; - } -} - -async function writeJSONAtomic(filePath: string, value: unknown) { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - const tmp = `${filePath}.${randomUUID()}.tmp`; - await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); - await fs.rename(tmp, filePath); -} - -let lock: Promise = Promise.resolve(); -async function withLock(fn: () => Promise): Promise { - const prev = lock; - let release: (() => void) | undefined; - lock = new Promise((resolve) => { - release = resolve; - }); - await prev; - try { - return await fn(); - } finally { - release?.(); - } -} +const withLock = createAsyncLock(); export function defaultVoiceWakeTriggers() { return [...DEFAULT_TRIGGERS]; @@ -60,7 +29,7 @@ export function defaultVoiceWakeTriggers() { export async function loadVoiceWakeConfig(baseDir?: string): Promise { const filePath = resolvePath(baseDir); - const existing = await readJSON(filePath); + const existing = await readJsonFile(filePath); if (!existing) { return { triggers: defaultVoiceWakeTriggers(), updatedAtMs: 0 }; } @@ -84,7 +53,7 @@ export async function setVoiceWakeTriggers( triggers: sanitized, updatedAtMs: Date.now(), }; - await writeJSONAtomic(filePath, next); + await writeJsonAtomic(filePath, next); return next; }); } From 012b674f313b628653414c9a8bbbfed79cf76bf6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:47:51 +0000 Subject: [PATCH 015/178] refactor(infra): share isTailnetIPv4 helper --- src/infra/bonjour-discovery.ts | 15 +-------------- src/infra/tailnet.ts | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index f0ee296156b..426d4eb5141 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -1,4 +1,5 @@ import { runCommandWithTimeout } from "../process/exec.js"; +import { isTailnetIPv4 } from "./tailnet.js"; import { resolveWideAreaDiscoveryDomain } from "./widearea-dns.js"; export type GatewayBonjourBeacon = { @@ -70,20 +71,6 @@ function decodeDnsSdEscapes(value: string): string { return Buffer.from(bytes).toString("utf8"); } -function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((p) => Number.parseInt(p, 10)); - if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { - return false; - } - // Tailscale IPv4 range: 100.64.0.0/10 - const [a, b] = octets; - return a === 100 && b >= 64 && b <= 127; -} - function parseDigShortLines(stdout: string): string[] { return stdout .split("\n") diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index ed666b86848..ed2384cfeb0 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -5,7 +5,7 @@ export type TailnetAddresses = { ipv6: string[]; }; -function isTailnetIPv4(address: string): boolean { +export function isTailnetIPv4(address: string): boolean { const parts = address.split("."); if (parts.length !== 4) { return false; From 50abdaf33b56e963d445aecd4fa7128001619167 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:48:46 +0000 Subject: [PATCH 016/178] refactor(infra): dedupe openclaw root candidate scan --- src/infra/openclaw-root.ts | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 2beb3e8f0c4..257b547f1ff 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -87,19 +87,7 @@ export async function resolveOpenClawPackageRoot(opts: { argv1?: string; moduleUrl?: string; }): Promise { - const candidates: string[] = []; - - if (opts.moduleUrl) { - candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); - } - if (opts.argv1) { - candidates.push(...candidateDirsFromArgv1(opts.argv1)); - } - if (opts.cwd) { - candidates.push(opts.cwd); - } - - for (const candidate of candidates) { + for (const candidate of buildCandidates(opts)) { const found = await findPackageRoot(candidate); if (found) { return found; @@ -114,6 +102,17 @@ export function resolveOpenClawPackageRootSync(opts: { argv1?: string; moduleUrl?: string; }): string | null { + for (const candidate of buildCandidates(opts)) { + const found = findPackageRootSync(candidate); + if (found) { + return found; + } + } + + return null; +} + +function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: string }): string[] { const candidates: string[] = []; if (opts.moduleUrl) { @@ -126,12 +125,5 @@ export function resolveOpenClawPackageRootSync(opts: { candidates.push(opts.cwd); } - for (const candidate of candidates) { - const found = findPackageRootSync(candidate); - if (found) { - return found; - } - } - - return null; + return candidates; } From 0c7785151600f67b79a764f9951abda100bd41f8 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:50:44 -0600 Subject: [PATCH 017/178] fix(agents): mark required-param tool errors as non-retryable (#17533) * Agents: mark missing tool params as non-retryable * Agents: include all missing required params in tool errors * Agents: change required-param errors to retry guidance * Docs: align changelog text for issue #14729 guidance wording --- CHANGELOG.md | 1 + ...aliases-schemas-without-dropping.e2e.test.ts | 17 ++++++++++++++++- src/agents/pi-tools.read.ts | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8d036b859..898f16c5a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn. - Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. - Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev. +- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729) - Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader. - Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman. - Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras. diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts index 6104fc16936..51ccca68c42 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.e2e.test.ts @@ -102,7 +102,10 @@ describe("createOpenClawCodingTools", () => { execute, }; - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); + const wrapped = __testing.wrapToolParamNormalization(tool, [ + { keys: ["path", "file_path"], label: "path (path or file_path)" }, + { keys: ["content"], label: "content" }, + ]); await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); expect(execute).toHaveBeenCalledWith( @@ -115,9 +118,21 @@ describe("createOpenClawCodingTools", () => { await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( /Missing required parameter/, ); + await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Missing required parameters: path \(path or file_path\), content/, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); }); }); diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 3798c6dd8b1..71e9bb72348 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -87,6 +87,12 @@ type RequiredParamGroup = { label?: string; }; +const RETRY_GUIDANCE_SUFFIX = " Supply correct parameters before retrying."; + +function parameterValidationError(message: string): Error { + return new Error(`${message}.${RETRY_GUIDANCE_SUFFIX}`); +} + export const CLAUDE_PARAM_GROUPS = { read: [{ keys: ["path", "file_path"], label: "path (path or file_path)" }], write: [ @@ -245,9 +251,10 @@ export function assertRequiredParams( toolName: string, ): void { if (!record || typeof record !== "object") { - throw new Error(`Missing parameters for ${toolName}`); + throw parameterValidationError(`Missing parameters for ${toolName}`); } + const missingLabels: string[] = []; for (const group of groups) { const satisfied = group.keys.some((key) => { if (!(key in record)) { @@ -265,9 +272,15 @@ export function assertRequiredParams( if (!satisfied) { const label = group.label ?? group.keys.join(" or "); - throw new Error(`Missing required parameter: ${label}`); + missingLabels.push(label); } } + + if (missingLabels.length > 0) { + const joined = missingLabels.join(", "); + const noun = missingLabels.length === 1 ? "parameter" : "parameters"; + throw parameterValidationError(`Missing required ${noun}: ${joined}`); + } } // Generic wrapper to normalize parameters for any tool From c92bcf24c446eb41d2562e31cde579760413ba12 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:51:13 +0000 Subject: [PATCH 018/178] refactor(infra): dedupe device pairing token updates --- src/infra/device-pairing.ts | 59 ++++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 41d786238db..4aba97ee9ab 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -179,6 +179,35 @@ function newToken() { return generatePairingToken(); } +function getPairedDeviceFromState( + state: DevicePairingStateFile, + deviceId: string, +): PairedDevice | null { + return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null; +} + +function cloneDeviceTokens(device: PairedDevice): Record { + return device.tokens ? { ...device.tokens } : {}; +} + +function buildDeviceAuthToken(params: { + role: string; + scopes: string[]; + existing?: DeviceAuthToken; + now: number; + rotatedAtMs?: number; +}): DeviceAuthToken { + return { + token: newToken(), + role: params.role, + scopes: params.scopes, + createdAtMs: params.existing?.createdAtMs ?? params.now, + rotatedAtMs: params.rotatedAtMs, + revokedAtMs: undefined, + lastUsedAtMs: params.existing?.lastUsedAtMs, + }; +} + export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts); @@ -360,7 +389,7 @@ export async function verifyDeviceToken(params: { }): Promise<{ ok: boolean; reason?: string }> { return await withLock(async () => { const state = await loadState(params.baseDir); - const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + const device = getPairedDeviceFromState(state, params.deviceId); if (!device) { return { ok: false, reason: "device-not-paired" }; } @@ -399,7 +428,7 @@ export async function ensureDeviceToken(params: { }): Promise { return await withLock(async () => { const state = await loadState(params.baseDir); - const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + const device = getPairedDeviceFromState(state, params.deviceId); if (!device) { return null; } @@ -408,7 +437,7 @@ export async function ensureDeviceToken(params: { return null; } const requestedScopes = normalizeScopes(params.scopes); - const tokens = device.tokens ? { ...device.tokens } : {}; + const tokens = cloneDeviceTokens(device); const existing = tokens[role]; if (existing && !existing.revokedAtMs) { if (scopesAllow(requestedScopes, existing.scopes)) { @@ -416,15 +445,13 @@ export async function ensureDeviceToken(params: { } } const now = Date.now(); - const next: DeviceAuthToken = { - token: newToken(), + const next = buildDeviceAuthToken({ role, scopes: requestedScopes, - createdAtMs: existing?.createdAtMs ?? now, + existing, + now, rotatedAtMs: existing ? now : undefined, - revokedAtMs: undefined, - lastUsedAtMs: existing?.lastUsedAtMs, - }; + }); tokens[role] = next; device.tokens = tokens; state.pairedByDeviceId[device.deviceId] = device; @@ -441,7 +468,7 @@ export async function rotateDeviceToken(params: { }): Promise { return await withLock(async () => { const state = await loadState(params.baseDir); - const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)]; + const device = getPairedDeviceFromState(state, params.deviceId); if (!device) { return null; } @@ -449,19 +476,17 @@ export async function rotateDeviceToken(params: { if (!role) { return null; } - const tokens = device.tokens ? { ...device.tokens } : {}; + const tokens = cloneDeviceTokens(device); const existing = tokens[role]; const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes); const now = Date.now(); - const next: DeviceAuthToken = { - token: newToken(), + const next = buildDeviceAuthToken({ role, scopes: requestedScopes, - createdAtMs: existing?.createdAtMs ?? now, + existing, + now, rotatedAtMs: now, - revokedAtMs: undefined, - lastUsedAtMs: existing?.lastUsedAtMs, - }; + }); tokens[role] = next; device.tokens = tokens; if (params.scopes !== undefined) { From 8cd20e220fbce3c1ed5beb94ece0db9e2bc9104b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:53:12 +0000 Subject: [PATCH 019/178] refactor(infra): share jsonl transcript reader --- src/infra/session-cost-usage.ts | 83 ++++++++++++++++----------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 4dd1203f91e..e4e64a4c6e2 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -212,39 +212,52 @@ const applyCostTotal = (totals: CostUsageTotals, costTotal: number | undefined) totals.totalCost += costTotal; }; +async function* readJsonlRecords(filePath: string): AsyncGenerator> { + const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" }); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object") { + continue; + } + yield parsed as Record; + } catch { + // Ignore malformed lines + } + } + } finally { + rl.close(); + fileStream.destroy(); + } +} + async function scanTranscriptFile(params: { filePath: string; config?: OpenClawConfig; onEntry: (entry: ParsedTranscriptEntry) => void; }): Promise { - const fileStream = fs.createReadStream(params.filePath, { encoding: "utf-8" }); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) { + for await (const parsed of readJsonlRecords(params.filePath)) { + const entry = parseTranscriptEntry(parsed); + if (!entry) { continue; } - try { - const parsed = JSON.parse(trimmed) as Record; - const entry = parseTranscriptEntry(parsed); - if (!entry) { - continue; - } - if (entry.usage && entry.costTotal === undefined) { - const cost = resolveModelCostConfig({ - provider: entry.provider, - model: entry.model, - config: params.config, - }); - entry.costTotal = estimateUsageCost({ usage: entry.usage, cost }); - } - - params.onEntry(entry); - } catch { - // Ignore malformed lines + if (entry.usage && entry.costTotal === undefined) { + const cost = resolveModelCostConfig({ + provider: entry.provider, + model: entry.model, + config: params.config, + }); + entry.costTotal = estimateUsageCost({ usage: entry.usage, cost }); } + + params.onEntry(entry); } } @@ -400,16 +413,8 @@ export async function discoverAllSessions(params?: { // Try to read first user message for label extraction let firstUserMessage: string | undefined; try { - const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" }); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } + for await (const parsed of readJsonlRecords(filePath)) { try { - const parsed = JSON.parse(trimmed) as Record; const message = parsed.message as Record | undefined; if (message?.role === "user") { const content = message.content; @@ -436,8 +441,6 @@ export async function discoverAllSessions(params?: { // Skip malformed lines } } - rl.close(); - fileStream.destroy(); } catch { // Ignore read errors } @@ -831,16 +834,8 @@ export async function loadSessionLogs(params: { const logs: SessionLogEntry[] = []; const limit = params.limit ?? 50; - const fileStream = fs.createReadStream(sessionFile, { encoding: "utf-8" }); - const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } + for await (const parsed of readJsonlRecords(sessionFile)) { try { - const parsed = JSON.parse(trimmed) as Record; const message = parsed.message as Record | undefined; if (!message) { continue; From 511719424d934144070891baf93126c00235b6b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:55:56 +0000 Subject: [PATCH 020/178] refactor(test): dedupe terminal restore stubs --- src/terminal/restore.test.ts | 50 +++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/terminal/restore.test.ts b/src/terminal/restore.test.ts index 5f79f2732a7..deaa8e74c0a 100644 --- a/src/terminal/restore.test.ts +++ b/src/terminal/restore.test.ts @@ -8,6 +8,20 @@ vi.mock("./progress-line.js", () => ({ import { restoreTerminalState } from "./restore.js"; +function configureTerminalIO(params: { + stdinIsTTY: boolean; + stdoutIsTTY: boolean; + setRawMode?: (mode: boolean) => void; + resume?: () => void; + isPaused?: () => boolean; +}) { + Object.defineProperty(process.stdin, "isTTY", { value: params.stdinIsTTY, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: params.stdoutIsTTY, configurable: true }); + (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = params.setRawMode; + (process.stdin as { resume?: () => void }).resume = params.resume; + (process.stdin as { isPaused?: () => boolean }).isPaused = params.isPaused; +} + describe("restoreTerminalState", () => { const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; @@ -35,11 +49,13 @@ describe("restoreTerminalState", () => { const resume = vi.fn(); const isPaused = vi.fn(() => true); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = setRawMode; - (process.stdin as { resume?: () => void }).resume = resume; - (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; + configureTerminalIO({ + stdinIsTTY: true, + stdoutIsTTY: false, + setRawMode, + resume, + isPaused, + }); restoreTerminalState("test"); @@ -52,11 +68,13 @@ describe("restoreTerminalState", () => { const resume = vi.fn(); const isPaused = vi.fn(() => true); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = setRawMode; - (process.stdin as { resume?: () => void }).resume = resume; - (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; + configureTerminalIO({ + stdinIsTTY: true, + stdoutIsTTY: false, + setRawMode, + resume, + isPaused, + }); restoreTerminalState("test", { resumeStdinIfPaused: true }); @@ -69,11 +87,13 @@ describe("restoreTerminalState", () => { const resume = vi.fn(); const isPaused = vi.fn(() => true); - Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); - (process.stdin as { setRawMode?: (mode: boolean) => void }).setRawMode = setRawMode; - (process.stdin as { resume?: () => void }).resume = resume; - (process.stdin as { isPaused?: () => boolean }).isPaused = isPaused; + configureTerminalIO({ + stdinIsTTY: false, + stdoutIsTTY: false, + setRawMode, + resume, + isPaused, + }); restoreTerminalState("test", { resumeStdinIfPaused: true }); From 3c6cff575897b11dbbe340c6c78f3a42582751bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:57:23 +0000 Subject: [PATCH 021/178] refactor(config): share agent sandbox schema --- src/config/zod-schema.agent-defaults.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 302335a1d52..2508179707c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -1,11 +1,9 @@ import { z } from "zod"; import { HeartbeatSchema, + AgentSandboxSchema, AgentModelSchema, MemorySearchSchema, - SandboxBrowserSchema, - SandboxDockerSchema, - SandboxPruneSchema, } from "./zod-schema.agent-runtime.js"; import { BlockStreamingChunkSchema, @@ -166,20 +164,7 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), - sandbox: z - .object({ - mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), - workspaceAccess: z.union([z.literal("none"), z.literal("ro"), z.literal("rw")]).optional(), - sessionToolsVisibility: z.union([z.literal("spawned"), z.literal("all")]).optional(), - scope: z.union([z.literal("session"), z.literal("agent"), z.literal("shared")]).optional(), - perSession: z.boolean().optional(), - workspaceRoot: z.string().optional(), - docker: SandboxDockerSchema, - browser: SandboxBrowserSchema, - prune: SandboxPruneSchema, - }) - .strict() - .optional(), + sandbox: AgentSandboxSchema, }) .strict() .optional(); From 7c822d039b69f7ccc5b97f740a2348376549e9e2 Mon Sep 17 00:00:00 2001 From: David Harmeyer <13dharmeyer@gmail.com> Date: Sun, 15 Feb 2026 14:01:00 -0800 Subject: [PATCH 022/178] feat(plugins): expose llm input/output hook payloads (openclaw#16724) thanks @SecondThread Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: SecondThread <18317476+SecondThread@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 51 +++++++++++ src/auto-reply/reply/agent-runner.ts | 1 + src/infra/control-ui-assets.ts | 9 +- src/infra/diagnostic-events.ts | 7 ++ src/plugins/hooks.ts | 24 +++++ src/plugins/types.ts | 36 ++++++++ src/plugins/wired-hooks-llm.test.ts | 96 ++++++++++++++++++++ 8 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/plugins/wired-hooks-llm.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 898f16c5a4a..afbfe70894b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. - Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 9fafd965c7c..0dd7eb85f08 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -954,6 +954,32 @@ export async function runEmbeddedAttempt( ); } + if (hookRunner?.hasHooks("llm_input")) { + hookRunner + .runLlmInput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + systemPrompt: systemPromptText, + prompt: effectivePrompt, + historyMessages: activeSession.messages, + imagesCount: imageResult.images.length, + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_input hook failed: ${String(err)}`); + }); + } + // Only pass images option if there are actually images to pass // This avoids potential issues with models that don't expect the images parameter if (imageResult.images.length > 0) { @@ -1103,6 +1129,31 @@ export async function runEmbeddedAttempt( ) .map((entry) => ({ toolName: entry.toolName, meta: entry.meta })); + if (hookRunner?.hasHooks("llm_output")) { + hookRunner + .runLlmOutput( + { + runId: params.runId, + sessionId: params.sessionId, + provider: params.provider, + model: params.modelId, + assistantTexts, + lastAssistant, + usage: getUsageTotals(), + }, + { + agentId: hookAgentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider ?? undefined, + }, + ) + .catch((err) => { + log.warn(`llm_output hook failed: ${String(err)}`); + }); + } + return { aborted, timedOut, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 6b3b021ee42..c8f8eba129a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -450,6 +450,7 @@ export async function runReplyAgent(params: { promptTokens, total: totalTokens, }, + lastCallUsage: runResult.meta.agentMeta?.lastCallUsage, context: { limit: contextTokensUsed, used: totalTokens, diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 953fb30941b..4091f8b7afb 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -97,15 +97,18 @@ export async function resolveControlUiDistIndexPath( for (let i = 0; i < 8; i++) { const pkgJsonPath = path.join(dir, "package.json"); const indexPath = path.join(dir, "dist", "control-ui", "index.html"); - if (fs.existsSync(pkgJsonPath) && fs.existsSync(indexPath)) { + if (fs.existsSync(pkgJsonPath)) { try { const raw = fs.readFileSync(pkgJsonPath, "utf-8"); const parsed = JSON.parse(raw) as { name?: unknown }; if (parsed.name === "openclaw") { - return indexPath; + return fs.existsSync(indexPath) ? indexPath : null; } + // Stop at the first package boundary to avoid resolving through unrelated ancestors. + return null; } catch { - // Invalid package.json, continue searching + // Invalid package.json at package boundary; abort fallback resolution. + return null; } } const parent = path.dirname(dir); diff --git a/src/infra/diagnostic-events.ts b/src/infra/diagnostic-events.ts index b0de66614d0..6b9f9f2d1bd 100644 --- a/src/infra/diagnostic-events.ts +++ b/src/infra/diagnostic-events.ts @@ -22,6 +22,13 @@ export type DiagnosticUsageEvent = DiagnosticBaseEvent & { promptTokens?: number; total?: number; }; + lastCallUsage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; context?: { limit?: number; used?: number; diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 040ce1d35c8..d05774089c2 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -14,6 +14,8 @@ import type { PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeCompactionEvent, + PluginHookLlmInputEvent, + PluginHookLlmOutputEvent, PluginHookBeforeResetEvent, PluginHookBeforeToolCallEvent, PluginHookBeforeToolCallResult, @@ -41,6 +43,8 @@ export type { PluginHookAgentContext, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, + PluginHookLlmInputEvent, + PluginHookLlmOutputEvent, PluginHookAgentEndEvent, PluginHookBeforeCompactionEvent, PluginHookBeforeResetEvent, @@ -212,6 +216,24 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return runVoidHook("agent_end", event, ctx); } + /** + * Run llm_input hook. + * Allows plugins to observe the exact input payload sent to the LLM. + * Runs in parallel (fire-and-forget). + */ + async function runLlmInput(event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) { + return runVoidHook("llm_input", event, ctx); + } + + /** + * Run llm_output hook. + * Allows plugins to observe the exact output payload returned by the LLM. + * Runs in parallel (fire-and-forget). + */ + async function runLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext) { + return runVoidHook("llm_output", event, ctx); + } + /** * Run before_compaction hook. */ @@ -458,6 +480,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return { // Agent hooks runBeforeAgentStart, + runLlmInput, + runLlmOutput, runAgentEnd, runBeforeCompaction, runAfterCompaction, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 32a961df6e6..ad9d283ccd8 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -297,6 +297,8 @@ export type PluginDiagnostic = { export type PluginHookName = | "before_agent_start" + | "llm_input" + | "llm_output" | "agent_end" | "before_compaction" | "after_compaction" @@ -332,6 +334,35 @@ export type PluginHookBeforeAgentStartResult = { prependContext?: string; }; +// llm_input hook +export type PluginHookLlmInputEvent = { + runId: string; + sessionId: string; + provider: string; + model: string; + systemPrompt?: string; + prompt: string; + historyMessages: unknown[]; + imagesCount: number; +}; + +// llm_output hook +export type PluginHookLlmOutputEvent = { + runId: string; + sessionId: string; + provider: string; + model: string; + assistantTexts: string[]; + lastAssistant?: unknown; + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; +}; + // agent_end hook export type PluginHookAgentEndEvent = { messages: unknown[]; @@ -498,6 +529,11 @@ export type PluginHookHandlerMap = { event: PluginHookBeforeAgentStartEvent, ctx: PluginHookAgentContext, ) => Promise | PluginHookBeforeAgentStartResult | void; + llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise | void; + llm_output: ( + event: PluginHookLlmOutputEvent, + ctx: PluginHookAgentContext, + ) => Promise | void; agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise | void; before_compaction: ( event: PluginHookBeforeCompactionEvent, diff --git a/src/plugins/wired-hooks-llm.test.ts b/src/plugins/wired-hooks-llm.test.ts new file mode 100644 index 00000000000..9311f31e30e --- /dev/null +++ b/src/plugins/wired-hooks-llm.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginRegistry } from "./registry.js"; +import { createHookRunner } from "./hooks.js"; + +function createMockRegistry( + hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>, +): PluginRegistry { + return { + hooks: hooks as never[], + typedHooks: hooks.map((h) => ({ + pluginId: "test-plugin", + hookName: h.hookName, + handler: h.handler, + priority: 0, + source: "test", + })), + tools: [], + httpHandlers: [], + httpRoutes: [], + channelRegistrations: [], + gatewayHandlers: {}, + cliRegistrars: [], + services: [], + providers: [], + commands: [], + } as unknown as PluginRegistry; +} + +describe("llm hook runner methods", () => { + it("runLlmInput invokes registered llm_input hooks", async () => { + const handler = vi.fn(); + const registry = createMockRegistry([{ hookName: "llm_input", handler }]); + const runner = createHookRunner(registry); + + await runner.runLlmInput( + { + runId: "run-1", + sessionId: "session-1", + provider: "openai", + model: "gpt-5", + systemPrompt: "be helpful", + prompt: "hello", + historyMessages: [], + imagesCount: 0, + }, + { + agentId: "main", + sessionId: "session-1", + }, + ); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ runId: "run-1", prompt: "hello" }), + expect.objectContaining({ sessionId: "session-1" }), + ); + }); + + it("runLlmOutput invokes registered llm_output hooks", async () => { + const handler = vi.fn(); + const registry = createMockRegistry([{ hookName: "llm_output", handler }]); + const runner = createHookRunner(registry); + + await runner.runLlmOutput( + { + runId: "run-1", + sessionId: "session-1", + provider: "openai", + model: "gpt-5", + assistantTexts: ["hi"], + lastAssistant: { role: "assistant", content: "hi" }, + usage: { + input: 10, + output: 20, + total: 30, + }, + }, + { + agentId: "main", + sessionId: "session-1", + }, + ); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ runId: "run-1", assistantTexts: ["hi"] }), + expect.objectContaining({ sessionId: "session-1" }), + ); + }); + + it("hasHooks returns true for registered llm hooks", () => { + const registry = createMockRegistry([{ hookName: "llm_input", handler: vi.fn() }]); + const runner = createHookRunner(registry); + + expect(runner.hasHooks("llm_input")).toBe(true); + expect(runner.hasHooks("llm_output")).toBe(false); + }); +}); From 5fb4032fb67da8e33c26ad7c9f9a285505017177 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:01:27 +0000 Subject: [PATCH 023/178] refactor(test): share overflow compaction mocks --- .../run.overflow-compaction.e2e.test.ts | 25 ------------------ .../run.overflow-compaction.mocks.shared.ts | 26 +++++++++++++++++++ .../run.overflow-compaction.test.ts | 25 ------------------ 3 files changed, 26 insertions(+), 50 deletions(-) diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts index 20097404db5..2e51e8a2952 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.e2e.test.ts @@ -5,31 +5,6 @@ vi.mock("../../utils.js", () => ({ resolveUserPath: vi.fn((p: string) => p), })); -vi.mock("../auth-profiles.js", () => ({ - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), - hasNonzeroUsage: vi.fn(() => false), -})); - vi.mock("../pi-embedded-helpers.js", async () => { return { isCompactionFailureError: (msg?: string) => { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 407788564ab..6a872721859 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -1,5 +1,31 @@ import { vi } from "vitest"; +vi.mock("../auth-profiles.js", () => ({ + isProfileInCooldown: vi.fn(() => false), + markAuthProfileFailure: vi.fn(async () => {}), + markAuthProfileGood: vi.fn(async () => {}), + markAuthProfileUsed: vi.fn(async () => {}), +})); + +vi.mock("../usage.js", () => ({ + normalizeUsage: vi.fn((usage?: unknown) => + usage && typeof usage === "object" ? usage : undefined, + ), + derivePromptTokens: vi.fn( + (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { + if (!usage) { + return undefined; + } + const input = usage.input ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const sum = input + cacheRead + cacheWrite; + return sum > 0 ? sum : undefined; + }, + ), + hasNonzeroUsage: vi.fn(() => false), +})); + vi.mock("./run/attempt.js", () => ({ runEmbeddedAttempt: vi.fn(), })); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index ded9da42c02..20944a29bad 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -1,31 +1,6 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../auth-profiles.js", () => ({ - isProfileInCooldown: vi.fn(() => false), - markAuthProfileFailure: vi.fn(async () => {}), - markAuthProfileGood: vi.fn(async () => {}), - markAuthProfileUsed: vi.fn(async () => {}), -})); - -vi.mock("../usage.js", () => ({ - normalizeUsage: vi.fn((usage?: unknown) => - usage && typeof usage === "object" ? usage : undefined, - ), - derivePromptTokens: vi.fn( - (usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => { - if (!usage) { - return undefined; - } - const input = usage.input ?? 0; - const cacheRead = usage.cacheRead ?? 0; - const cacheWrite = usage.cacheWrite ?? 0; - const sum = input + cacheRead + cacheWrite; - return sum > 0 ? sum : undefined; - }, - ), -})); - vi.mock("../workspace-run.js", () => ({ resolveRunWorkspaceDir: vi.fn((params: { workspaceDir: string }) => ({ workspaceDir: params.workspaceDir, From d9d93485d9b819240c5c4ffd14f6264704434e26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:04:07 +0000 Subject: [PATCH 024/178] refactor(test): share tool hook handler ctx --- .../wired-hooks-after-tool-call.e2e.test.ts | 117 +++++++----------- 1 file changed, 43 insertions(+), 74 deletions(-) diff --git a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts index cddbf3b42ba..91cfa51014f 100644 --- a/src/plugins/wired-hooks-after-tool-call.e2e.test.ts +++ b/src/plugins/wired-hooks-after-tool-call.e2e.test.ts @@ -20,6 +20,42 @@ vi.mock("../infra/agent-events.js", () => ({ emitAgentEvent: vi.fn(), })); +function createToolHandlerCtx(params: { + runId: string; + sessionKey?: string; + agentId?: string; + onBlockReplyFlush?: unknown; +}) { + return { + params: { + runId: params.runId, + session: { messages: [] }, + agentId: params.agentId, + sessionKey: params.sessionKey, + onBlockReplyFlush: params.onBlockReplyFlush, + }, + state: { + toolMetaById: new Map(), + toolMetas: [] as Array<{ toolName?: string; meta?: string }>, + toolSummaryById: new Set(), + lastToolError: undefined, + pendingMessagingTexts: new Map(), + pendingMessagingTargets: new Map(), + messagingToolSentTexts: [] as string[], + messagingToolSentTextsNormalized: [] as string[], + messagingToolSentTargets: [] as unknown[], + blockBuffer: "", + }, + log: { debug: vi.fn(), warn: vi.fn() }, + flushBlockReplyBuffer: vi.fn(), + shouldEmitToolResult: () => false, + shouldEmitToolOutput: () => false, + emitToolSummary: vi.fn(), + emitToolOutput: vi.fn(), + trimMessagingToolSent: vi.fn(), + }; +} + describe("after_tool_call hook wiring", () => { beforeEach(() => { hookMocks.runner.hasHooks.mockReset(); @@ -36,34 +72,11 @@ describe("after_tool_call hook wiring", () => { const { handleToolExecutionEnd, handleToolExecutionStart } = await import("../agents/pi-embedded-subscribe.handlers.tools.js"); - const ctx = { - params: { - runId: "test-run-1", - session: { messages: [] }, - agentId: "main", - sessionKey: "test-session", - onBlockReplyFlush: undefined, - }, - state: { - toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", - }, - log: { debug: vi.fn(), warn: vi.fn() }, - flushBlockReplyBuffer: vi.fn(), - shouldEmitToolResult: () => false, - shouldEmitToolOutput: () => false, - emitToolSummary: vi.fn(), - emitToolOutput: vi.fn(), - trimMessagingToolSent: vi.fn(), - }; + const ctx = createToolHandlerCtx({ + runId: "test-run-1", + agentId: "main", + sessionKey: "test-session", + }); await handleToolExecutionStart( ctx as never, @@ -103,32 +116,7 @@ describe("after_tool_call hook wiring", () => { const { handleToolExecutionEnd, handleToolExecutionStart } = await import("../agents/pi-embedded-subscribe.handlers.tools.js"); - const ctx = { - params: { - runId: "test-run-2", - session: { messages: [] }, - onBlockReplyFlush: undefined, - }, - state: { - toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentTargets: [] as unknown[], - blockBuffer: "", - }, - log: { debug: vi.fn(), warn: vi.fn() }, - flushBlockReplyBuffer: vi.fn(), - shouldEmitToolResult: () => false, - shouldEmitToolOutput: () => false, - emitToolSummary: vi.fn(), - emitToolOutput: vi.fn(), - trimMessagingToolSent: vi.fn(), - }; + const ctx = createToolHandlerCtx({ runId: "test-run-2" }); await handleToolExecutionStart( ctx as never, @@ -163,26 +151,7 @@ describe("after_tool_call hook wiring", () => { const { handleToolExecutionEnd } = await import("../agents/pi-embedded-subscribe.handlers.tools.js"); - const ctx = { - params: { runId: "r", session: { messages: [] } }, - state: { - toolMetaById: new Map(), - toolMetas: [] as Array<{ toolName?: string; meta?: string }>, - toolSummaryById: new Set(), - lastToolError: undefined, - pendingMessagingTexts: new Map(), - pendingMessagingTargets: new Map(), - messagingToolSentTexts: [] as string[], - messagingToolSentTextsNormalized: [] as string[], - messagingToolSentTargets: [] as unknown[], - }, - log: { debug: vi.fn(), warn: vi.fn() }, - shouldEmitToolResult: () => false, - shouldEmitToolOutput: () => false, - emitToolSummary: vi.fn(), - emitToolOutput: vi.fn(), - trimMessagingToolSent: vi.fn(), - }; + const ctx = createToolHandlerCtx({ runId: "r" }); await handleToolExecutionEnd( ctx as never, From 8e7b7a2b220bc0a4aa71a6d9c92022d36acabbab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:08:13 +0000 Subject: [PATCH 025/178] refactor(test): dedupe commands e2e wizard setup --- src/commands/auth-choice.moonshot.e2e.test.ts | 82 ++++++++-------- src/commands/onboard-channels.e2e.test.ts | 95 ++++++++----------- 2 files changed, 77 insertions(+), 100 deletions(-) diff --git a/src/commands/auth-choice.moonshot.e2e.test.ts b/src/commands/auth-choice.moonshot.e2e.test.ts index 8bddbd7a6f6..2e467ae7f53 100644 --- a/src/commands/auth-choice.moonshot.e2e.test.ts +++ b/src/commands/auth-choice.moonshot.e2e.test.ts @@ -17,6 +17,30 @@ const requireAgentDir = () => { return agentDir; }; +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + ...overrides, + }; +} + describe("applyAuthChoice (moonshot)", () => { const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; @@ -24,6 +48,14 @@ describe("applyAuthChoice (moonshot)", () => { const previousMoonshotKey = process.env.MOONSHOT_API_KEY; let tempStateDir: string | null = null; + async function setupTempState() { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + delete process.env.MOONSHOT_API_KEY; + } + afterEach(async () => { if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); @@ -52,30 +84,11 @@ describe("applyAuthChoice (moonshot)", () => { }); it("keeps the .cn baseUrl when setDefaultModel is false", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", @@ -107,30 +120,11 @@ describe("applyAuthChoice (moonshot)", () => { }); it("sets the default model when setDefaultModel is true", async () => { - tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); - process.env.OPENCLAW_STATE_DIR = tempStateDir; - process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; - delete process.env.MOONSHOT_API_KEY; + await setupTempState(); const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test"); - const prompter: WizardPrompter = { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => "" as never), - multiselect: vi.fn(async () => []), - text, - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - }; - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] }); + const runtime = createRuntime(); const result = await applyAuthChoice({ authChoice: "moonshot-api-key-cn", diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 25c1c6fc220..210ef5b7ad1 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -12,6 +12,32 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { setupChannels } from "./onboard-channels.js"; +const noopAsync = async () => {}; + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => "__done__" as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + vi.mock("node:fs/promises", () => ({ default: { access: vi.fn(async () => { @@ -56,24 +82,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -97,24 +112,14 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), + const prompter = createPrompter({ note, select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels({} as OpenClawConfig, runtime, prompter, { skipConfirm: true, @@ -146,24 +151,13 @@ describe("setupChannels", () => { throw new Error(`unexpected text prompt: ${message}`); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, text: text as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { @@ -209,24 +203,13 @@ describe("setupChannels", () => { const multiselect = vi.fn(async () => { throw new Error("unexpected multiselect"); }); - const prompter: WizardPrompter = { - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), + const prompter = createPrompter({ select, multiselect, - text: vi.fn(async () => ""), - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }; + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + }); - const runtime: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; + const runtime = createRuntime(); await setupChannels( { From a1ff0e476755aae04673026c116ffe3dfeee9f8a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:12:02 +0000 Subject: [PATCH 026/178] refactor(test): dedupe sessions_spawn thinking assertions --- ...spawn-applies-thinking-default.e2e.test.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts index c9b7175717a..ecd32cab749 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.e2e.test.ts @@ -33,21 +33,37 @@ vi.mock("../gateway/call.js", () => { }; }); +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + describe("sessions_spawn thinking defaults", () => { it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); const result = await tool.execute("call-1", { task: "hello" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("high"); expect(thinkingPatch?.params?.thinkingLevel).toBe("high"); @@ -58,15 +74,12 @@ describe("sessions_spawn thinking defaults", () => { const result = await tool.execute("call-2", { task: "hello", thinking: "low" }); expect(result.details).toMatchObject({ status: "accepted" }); - const { callGateway } = await import("../gateway/call.js"); - const calls = (callGateway as unknown as ReturnType).mock.calls; - - const agentCall = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "agent"); - const thinkingPatch = calls - .map((call) => call[0] as { method: string; params?: Record }) - .findLast((call) => call.method === "sessions.patch" && call.params?.thinkingLevel); + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + const thinkingPatch = findLastCall( + calls, + (call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined, + ); expect(agentCall?.params?.thinking).toBe("low"); expect(thinkingPatch?.params?.thinkingLevel).toBe("low"); From e58884925aee4ea6dd002018d18c4775b1143f73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:12:07 +0000 Subject: [PATCH 027/178] refactor(test): reuse pi embedded subscribe session harness --- .../pi-embedded-subscribe.e2e-harness.ts | 10 +++++ ...esses-output-without-start-tag.e2e.test.ts | 44 +++++++------------ ...ion.subscribeembeddedpisession.e2e.test.ts | 22 ++++------ 3 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.e2e-harness.ts b/src/agents/pi-embedded-subscribe.e2e-harness.ts index 88370693841..64975e8c72c 100644 --- a/src/agents/pi-embedded-subscribe.e2e-harness.ts +++ b/src/agents/pi-embedded-subscribe.e2e-harness.ts @@ -16,3 +16,13 @@ export function createStubSessionHarness(): { return { session, emit: (evt: unknown) => handler?.(evt) }; } + +export function extractAgentEventPayloads(calls: Array): Array> { + return calls + .map((call) => { + const first = call?.[0] as { data?: unknown } | undefined; + const data = first?.data; + return data && typeof data === "object" ? (data as Record) : undefined; + }) + .filter((value): value is Record => Boolean(value)); +} diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts index 1dad92b6ce8..76a51a89197 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.e2e.test.ts @@ -1,34 +1,28 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; -type StubSession = { - subscribe: (fn: (evt: unknown) => void) => () => void; -}; - describe("subscribeEmbeddedPiSession", () => { it("filters to and suppresses output without a start tag", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onPartialReply = vi.fn(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onPartialReply, onAgentEvent, }); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -43,8 +37,8 @@ describe("subscribeEmbeddedPiSession", () => { onPartialReply.mockReset(); - handler?.({ type: "message_start", message: { role: "assistant" } }); - handler?.({ + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { @@ -56,18 +50,12 @@ describe("subscribeEmbeddedPiSession", () => { expect(onPartialReply).not.toHaveBeenCalled(); }); it("emits agent events on message_end even without tags", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", enforceFinalTag: true, onAgentEvent, @@ -78,12 +66,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index b53ffa62e53..1371a697d75 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -1,5 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { + createStubSessionHarness, + extractAgentEventPayloads, +} from "./pi-embedded-subscribe.e2e-harness.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; type StubSession = { @@ -186,18 +190,12 @@ describe("subscribeEmbeddedPiSession", () => { }); it("emits agent events on message_end for non-streaming assistant text", () => { - let handler: ((evt: unknown) => void) | undefined; - const session: StubSession = { - subscribe: (fn) => { - handler = fn; - return () => {}; - }, - }; + const { session, emit } = createStubSessionHarness(); const onAgentEvent = vi.fn(); subscribeEmbeddedPiSession({ - session: session as unknown as Parameters[0]["session"], + session, runId: "run", onAgentEvent, }); @@ -207,12 +205,10 @@ describe("subscribeEmbeddedPiSession", () => { content: [{ type: "text", text: "Hello world" }], } as AssistantMessage; - handler?.({ type: "message_start", message: assistantMessage }); - handler?.({ type: "message_end", message: assistantMessage }); + emit({ type: "message_start", message: assistantMessage }); + emit({ type: "message_end", message: assistantMessage }); - const payloads = onAgentEvent.mock.calls - .map((call) => call[0]?.data as Record | undefined) - .filter((value): value is Record => Boolean(value)); + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); expect(payloads).toHaveLength(1); expect(payloads[0]?.text).toBe("Hello world"); expect(payloads[0]?.delta).toBe("Hello world"); From d491c789a3664e4ac7965fbf31beebfbfbf1942d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:19:08 +0000 Subject: [PATCH 028/178] refactor(test): share gateway ws e2e harness --- src/gateway/server.e2e-ws-harness.ts | 39 ++++++++ src/gateway/server.health.e2e.test.ts | 97 +++++-------------- ...ions.gateway-server-sessions-a.e2e.test.ts | 29 ++---- 3 files changed, 69 insertions(+), 96 deletions(-) create mode 100644 src/gateway/server.e2e-ws-harness.ts diff --git a/src/gateway/server.e2e-ws-harness.ts b/src/gateway/server.e2e-ws-harness.ts new file mode 100644 index 00000000000..c3775e53ce6 --- /dev/null +++ b/src/gateway/server.e2e-ws-harness.ts @@ -0,0 +1,39 @@ +import { WebSocket } from "ws"; +import { connectOk, getFreePort, startGatewayServer } from "./test-helpers.js"; + +export type GatewayWsClient = { + ws: WebSocket; + hello: unknown; +}; + +export type GatewayServerHarness = { + port: number; + server: Awaited>; + openClient: (opts?: Parameters[1]) => Promise; + close: () => Promise; +}; + +export async function startGatewayServerHarness(): Promise { + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + const port = await getFreePort(); + const server = await startGatewayServer(port); + + const openClient = async (opts?: Parameters[1]): Promise => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + const hello = await connectOk(ws, opts); + return { ws, hello }; + }; + + const close = async () => { + await server.close(); + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + }; + + return { port, server, openClient, close }; +} diff --git a/src/gateway/server.health.e2e.test.ts b/src/gateway/server.health.e2e.test.ts index adab0dfd1a5..f42e7b78b3e 100644 --- a/src/gateway/server.health.e2e.test.ts +++ b/src/gateway/server.health.e2e.test.ts @@ -1,58 +1,26 @@ import { randomUUID } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { WebSocket } from "ws"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { - loadOrCreateDeviceIdentity, - publicKeyRawBase64UrlFromPem, - signDevicePayload, -} from "../infra/device-identity.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { buildDeviceAuthPayload } from "./device-auth.js"; -import { - connectOk, - getFreePort, - installGatewayTestHooks, - onceMessage, - startGatewayServer, - startServerWithClient, -} from "./test-helpers.js"; +import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js"; +import { installGatewayTestHooks, onceMessage } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); -let server: Awaited>; -let port = 0; -let previousToken: string | undefined; +let harness: GatewayServerHarness; beforeAll(async () => { - previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - port = await getFreePort(); - server = await startGatewayServer(port); + harness = await startGatewayServerHarness(); }); afterAll(async () => { - await server.close(); - if (previousToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; - } + await harness.close(); }); -const openClient = async (opts?: Parameters[1]) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - await connectOk(ws, opts); - return ws; -}; - describe("gateway server health/presence", () => { test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => { - const ws = await openClient(); + const { ws } = await harness.openClient(); const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1"); const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1"); @@ -101,7 +69,7 @@ describe("gateway server health/presence", () => { payload?: unknown; }; - const ws = await openClient(); + const { ws } = await harness.openClient(); const waitHeartbeat = onceMessage( ws, @@ -144,7 +112,7 @@ describe("gateway server health/presence", () => { }); test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => { - const ws = await openClient(); + const { ws } = await harness.openClient(); const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence"); ws.send( @@ -165,7 +133,7 @@ describe("gateway server health/presence", () => { }); test("agent events stream with seq", { timeout: 8000 }, async () => { - const ws = await openClient(); + const { ws } = await harness.openClient(); const runId = randomUUID(); const evtPromise = onceMessage( @@ -186,21 +154,24 @@ describe("gateway server health/presence", () => { }); test("shutdown event is broadcast on close", { timeout: 8000 }, async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); - + const localHarness = await startGatewayServerHarness(); + const { ws } = await localHarness.openClient(); const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000); - await server.close(); + await localHarness.close(); const evt = await shutdownP; expect(evt.payload?.reason).toBeDefined(); }); test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => { - const clients = await Promise.all([openClient(), openClient(), openClient()]); - const waits = clients.map((c) => - onceMessage(c, (o) => o.type === "event" && o.event === "presence"), + const clients = await Promise.all([ + harness.openClient(), + harness.openClient(), + harness.openClient(), + ]); + const waits = clients.map(({ ws }) => + onceMessage(ws, (o) => o.type === "event" && o.event === "presence"), ); - clients[0].send( + clients[0].ws.send( JSON.stringify({ type: "req", id: "broadcast", @@ -213,31 +184,17 @@ describe("gateway server health/presence", () => { expect(evt.payload?.presence?.length).toBeGreaterThan(0); expect(typeof evt.seq).toBe("number"); } - for (const c of clients) { - c.close(); + for (const { ws } of clients) { + ws.close(); } }); test("presence includes client fingerprint", async () => { - const identityPath = path.join(os.tmpdir(), `openclaw-device-${randomUUID()}.json`); - const identity = loadOrCreateDeviceIdentity(identityPath); - const token = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; const role = "operator"; const scopes: string[] = ["operator.admin"]; - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: GATEWAY_CLIENT_NAMES.FINGERPRINT, - clientMode: GATEWAY_CLIENT_MODES.UI, + const { ws } = await harness.openClient({ role, scopes, - signedAtMs, - token: token ?? null, - }); - const ws = await openClient({ - role, - scopes, - token, client: { id: GATEWAY_CLIENT_NAMES.FINGERPRINT, version: "9.9.9", @@ -247,12 +204,6 @@ describe("gateway server health/presence", () => { mode: GATEWAY_CLIENT_MODES.UI, instanceId: "abc", }, - device: { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - }, }); const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "fingerprint", 4000); @@ -286,7 +237,7 @@ describe("gateway server health/presence", () => { test("cli connections are not tracked as instances", async () => { const cliId = `cli-${randomUUID()}`; - const ws = await openClient({ + const { ws } = await harness.openClient({ client: { id: GATEWAY_CLIENT_NAMES.CLI, version: "dev", diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts index 9d387c8ac6e..16df39522c1 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts @@ -2,16 +2,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; -import { WebSocket } from "ws"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js"; import { connectOk, embeddedRunMock, - getFreePort, installGatewayTestHooks, piSdkMock, rpcReq, - startGatewayServer, testState, writeSessionStore, } from "./test-helpers.js"; @@ -57,32 +55,17 @@ vi.mock("../hooks/internal-hooks.js", async () => { installGatewayTestHooks({ scope: "suite" }); -let server: Awaited>; -let port = 0; -let previousToken: string | undefined; +let harness: GatewayServerHarness; beforeAll(async () => { - previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - port = await getFreePort(); - server = await startGatewayServer(port); + harness = await startGatewayServerHarness(); }); afterAll(async () => { - await server.close(); - if (previousToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; - } + await harness.close(); }); -const openClient = async (opts?: Parameters[1]) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - const hello = await connectOk(ws, opts); - return { ws, hello }; -}; +const openClient = async (opts?: Parameters[1]) => await harness.openClient(opts); describe("gateway server sessions", () => { beforeEach(() => { @@ -143,7 +126,7 @@ describe("gateway server sessions", () => { }); const { ws, hello } = await openClient(); - expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual( + expect((hello as { features?: { methods?: string[] } }).features?.methods).toEqual( expect.arrayContaining([ "sessions.list", "sessions.preview", From a948212ca7ddc01250d374f2d6a2b1efdece7463 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sun, 15 Feb 2026 14:19:54 -0800 Subject: [PATCH 029/178] fix(ui): show session labels in selector and standardize session key prefixes - Display session labels in the session selector - Cap selector width to 300px - Standardize key prefixes and fallback names for subagent and cron job sessions Co-Authored-By: Claude Opus 4.6 --- ui/src/styles/chat/layout.css | 4 +- ui/src/ui/app-render.helpers.node.test.ts | 224 +++++++++++++++++++--- ui/src/ui/app-render.helpers.ts | 93 ++++++++- 3 files changed, 282 insertions(+), 39 deletions(-) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 821ecbd3abc..3b330cacef9 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -333,7 +333,7 @@ .chat-controls__session { min-width: 140px; - max-width: 420px; + max-width: 300px; } .chat-controls__thinking { @@ -400,7 +400,7 @@ .chat-controls__session select { padding: 6px 10px; font-size: 13px; - max-width: 420px; + max-width: 300px; overflow: hidden; text-overflow: ellipsis; } diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index c386ccc0f71..ab6ba84396d 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SessionsListResult } from "./types.ts"; -import { resolveSessionDisplayName } from "./app-render.helpers.ts"; +import { parseSessionKey, resolveSessionDisplayName } from "./app-render.helpers.ts"; type SessionRow = SessionsListResult["sessions"][number]; @@ -8,72 +8,238 @@ function row(overrides: Partial & { key: string }): SessionRow { return { kind: "direct", updatedAt: 0, ...overrides }; } -describe("resolveSessionDisplayName", () => { - it("returns key when no row is provided", () => { - expect(resolveSessionDisplayName("agent:main:main")).toBe("agent:main:main"); +/* ================================================================ + * parseSessionKey – low-level key → type / fallback mapping + * ================================================================ */ + +describe("parseSessionKey", () => { + it("identifies main session (bare 'main')", () => { + expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" }); }); - it("returns key when row has no label or displayName", () => { - expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe( - "agent:main:main", + it("identifies main session (agent:main:main)", () => { + expect(parseSessionKey("agent:main:main")).toEqual({ + prefix: "", + fallbackName: "Main Session", + }); + }); + + it("identifies subagent sessions", () => { + expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({ + prefix: "Subagent:", + fallbackName: "Subagent:", + }); + }); + + it("identifies cron sessions", () => { + expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({ + prefix: "Cron:", + fallbackName: "Cron Job:", + }); + }); + + it("identifies direct chat with known channel", () => { + expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({ + prefix: "", + fallbackName: "iMessage · +19257864429", + }); + }); + + it("identifies direct chat with telegram", () => { + expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({ + prefix: "", + fallbackName: "Telegram · user123", + }); + }); + + it("identifies group chat with known channel", () => { + expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({ + prefix: "", + fallbackName: "Discord Group", + }); + }); + + it("capitalises unknown channels in direct/group patterns", () => { + expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({ + prefix: "", + fallbackName: "Mychannel · user1", + }); + }); + + it("identifies channel-prefixed legacy keys", () => { + expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({ + prefix: "", + fallbackName: "iMessage Session", + }); + expect(parseSessionKey("discord:123:456")).toEqual({ + prefix: "", + fallbackName: "Discord Session", + }); + }); + + it("handles bare channel name as key", () => { + expect(parseSessionKey("telegram")).toEqual({ + prefix: "", + fallbackName: "Telegram Session", + }); + }); + + it("returns raw key for unknown patterns", () => { + expect(parseSessionKey("something-unknown")).toEqual({ + prefix: "", + fallbackName: "something-unknown", + }); + }); +}); + +/* ================================================================ + * resolveSessionDisplayName – full resolution with row data + * ================================================================ */ + +describe("resolveSessionDisplayName", () => { + // ── Key-only fallbacks (no row) ────────────────── + + it("returns 'Main Session' for agent:main:main key", () => { + expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session"); + }); + + it("returns 'Main Session' for bare 'main' key", () => { + expect(resolveSessionDisplayName("main")).toBe("Main Session"); + }); + + it("returns 'Subagent:' for subagent key without row", () => { + expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:"); + }); + + it("returns 'Cron Job:' for cron key without row", () => { + expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:"); + }); + + it("parses direct chat key with channel", () => { + expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe( + "iMessage · +19257864429", ); }); - it("returns key when displayName matches key", () => { + it("parses channel-prefixed legacy key", () => { + expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session"); + }); + + it("returns raw key for unknown patterns", () => { + expect(resolveSessionDisplayName("something-custom")).toBe("something-custom"); + }); + + // ── With row data (label / displayName) ────────── + + it("returns parsed fallback when row has no label or displayName", () => { + expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe( + "Main Session", + ); + }); + + it("returns parsed fallback when displayName matches key", () => { expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe( "mykey", ); }); - it("returns key when label matches key", () => { + it("returns parsed fallback when label matches key", () => { expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey"); }); - it("uses displayName prominently when available", () => { - expect( - resolveSessionDisplayName( - "discord:123:456", - row({ key: "discord:123:456", displayName: "My Chat" }), - ), - ).toBe("My Chat (discord:123:456)"); - }); - - it("falls back to label when displayName is absent", () => { + it("uses label alone when available", () => { expect( resolveSessionDisplayName( "discord:123:456", row({ key: "discord:123:456", label: "General" }), ), - ).toBe("General (discord:123:456)"); + ).toBe("General"); }); - it("prefers displayName over label when both are present", () => { + it("falls back to displayName when label is absent", () => { + expect( + resolveSessionDisplayName( + "discord:123:456", + row({ key: "discord:123:456", displayName: "My Chat" }), + ), + ).toBe("My Chat"); + }); + + it("prefers label over displayName when both are present", () => { expect( resolveSessionDisplayName( "discord:123:456", row({ key: "discord:123:456", displayName: "My Chat", label: "General" }), ), - ).toBe("My Chat (discord:123:456)"); + ).toBe("General"); }); - it("ignores whitespace-only displayName", () => { + it("ignores whitespace-only label and falls back to displayName", () => { expect( resolveSessionDisplayName( "discord:123:456", - row({ key: "discord:123:456", displayName: " ", label: "General" }), + row({ key: "discord:123:456", displayName: "My Chat", label: " " }), ), - ).toBe("General (discord:123:456)"); + ).toBe("My Chat"); }); - it("ignores whitespace-only label", () => { + it("uses parsed fallback when whitespace-only label and no displayName", () => { expect( resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })), - ).toBe("discord:123:456"); + ).toBe("Discord Session"); }); - it("trims displayName and label", () => { + it("trims label and displayName", () => { + expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General"); expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe( - "My Chat (k)", + "My Chat", ); }); + + // ── Type prefixes applied to labels / displayNames ── + + it("prefixes subagent label with Subagent:", () => { + expect( + resolveSessionDisplayName( + "agent:main:subagent:abc-123", + row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }), + ), + ).toBe("Subagent: maintainer-v2"); + }); + + it("prefixes subagent displayName with Subagent:", () => { + expect( + resolveSessionDisplayName( + "agent:main:subagent:abc-123", + row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }), + ), + ).toBe("Subagent: Task Runner"); + }); + + it("prefixes cron label with Cron:", () => { + expect( + resolveSessionDisplayName( + "agent:main:cron:abc-123", + row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }), + ), + ).toBe("Cron: daily-briefing"); + }); + + it("prefixes cron displayName with Cron:", () => { + expect( + resolveSessionDisplayName( + "agent:main:cron:abc-123", + row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }), + ), + ).toBe("Cron: Nightly Sync"); + }); + + it("does not prefix non-typed sessions with labels", () => { + expect( + resolveSessionDisplayName( + "agent:main:bluebubbles:direct:+19257864429", + row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }), + ), + ).toBe("Tyler"); + }); }); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a7e0b9aa2b3..dcc8843bae2 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -159,7 +159,7 @@ export function renderChatControls(state: AppViewState) { sessionOptions, (entry) => entry.key, (entry) => - html``, )} @@ -256,19 +256,96 @@ function resolveMainSessionKey( return null; } +/* ── Channel display labels ────────────────────────────── */ +const CHANNEL_LABELS: Record = { + bluebubbles: "iMessage", + telegram: "Telegram", + discord: "Discord", + signal: "Signal", + slack: "Slack", + whatsapp: "WhatsApp", + matrix: "Matrix", + email: "Email", + sms: "SMS", +}; + +const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS); + +/** Parsed type / context extracted from a session key. */ +export type SessionKeyInfo = { + /** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */ + prefix: string; + /** Human-readable fallback when no label / displayName is available. */ + fallbackName: string; +}; + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Parse a session key to extract type information and a human-readable + * fallback display name. Exported for testing. + */ +export function parseSessionKey(key: string): SessionKeyInfo { + // ── Main session ───────────────────────────────── + if (key === "main" || key === "agent:main:main") { + return { prefix: "", fallbackName: "Main Session" }; + } + + // ── Subagent ───────────────────────────────────── + if (key.includes(":subagent:")) { + return { prefix: "Subagent:", fallbackName: "Subagent:" }; + } + + // ── Cron job ───────────────────────────────────── + if (key.includes(":cron:")) { + return { prefix: "Cron:", fallbackName: "Cron Job:" }; + } + + // ── Direct chat (agent:::direct:) ── + const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/); + if (directMatch) { + const channel = directMatch[1]; + const identifier = directMatch[2]; + const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel); + return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` }; + } + + // ── Group chat (agent:::group:) ──── + const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/); + if (groupMatch) { + const channel = groupMatch[1]; + const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel); + return { prefix: "", fallbackName: `${channelLabel} Group` }; + } + + // ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ── + for (const ch of KNOWN_CHANNEL_KEYS) { + if (key === ch || key.startsWith(`${ch}:`)) { + return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` }; + } + } + + // ── Unknown — return key as-is ─────────────────── + return { prefix: "", fallbackName: key }; +} + export function resolveSessionDisplayName( key: string, row?: SessionsListResult["sessions"][number], -) { - const displayName = row?.displayName?.trim() || ""; +): string { const label = row?.label?.trim() || ""; - if (displayName && displayName !== key) { - return `${displayName} (${key})`; - } + const displayName = row?.displayName?.trim() || ""; + const { prefix, fallbackName } = parseSessionKey(key); + if (label && label !== key) { - return `${label} (${key})`; + return prefix ? `${prefix} ${label}` : label; } - return key; + if (displayName && displayName !== key) { + return prefix ? `${prefix} ${displayName}` : displayName; + } + return fallbackName; } function resolveSessionOptions( From 6b4590be069c3628273d3392fa4131c6a85e4de9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:39:40 +0000 Subject: [PATCH 030/178] fix(agents): stabilize sessions_spawn e2e suite --- ...gents.sessions-spawn.allowlist.e2e.test.ts | 3 +- ...gents.sessions-spawn.lifecycle.e2e.test.ts | 372 ++++++++---------- ...subagents.sessions-spawn.model.e2e.test.ts | 10 +- src/agents/tools/sessions-spawn-tool.ts | 7 +- 4 files changed, 188 insertions(+), 204 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index b1c697064f5..937d6f2826a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -9,6 +9,7 @@ import { import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +const setConfigOverride = setSessionsSpawnConfigOverride; describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 002683386be..faac951d7b4 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,16 +1,117 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { sleep } from "../utils.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +vi.mock("./pi-embedded.js", () => ({ + isEmbeddedPiRunActive: () => false, + isEmbeddedPiRunStreaming: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, +})); + const callGatewayMock = getCallGatewayMock(); +type GatewayRequest = { method?: string; params?: unknown }; +type AgentWaitCall = { runId?: string; timeoutMs?: number }; + +function setupSessionsSpawnGatewayMock(opts: { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && opts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params?.sessionKey ?? ""; + opts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; + return { + runId: params?.runId ?? "run-1", + ...res, + }; + } + + if (request.method === "sessions.patch") { + opts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + opts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && opts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,75 +120,17 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; let patchParams: { key?: string; label?: string } = {}; - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.list") { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; + const ctx = setupSessionsSpawnGatewayMock({ + includeSessionsList: true, + includeChatHistory: true, + onSessionsPatch: (params) => { + const rec = params as { key?: string; label?: string } | undefined; + if (typeof rec?.label === "string" && rec.label.trim()) { + patchParams = { key: rec.key, label: rec.label }; } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.patch") { - const params = request.params as { key?: string; label?: string } | undefined; - patchParams = { key: params?.key, label: params?.label }; - return { ok: true }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; + }, }); const tool = createOpenClawTools({ @@ -108,11 +151,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -121,18 +165,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - await sleep(0); - await sleep(0); - await sleep(0); + vi.useFakeTimers(); + try { + await vi.advanceTimersByTimeAsync(500); + } finally { + vi.useRealTimers(); + } - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); // Cleanup should patch the label - expect(patchParams.key).toBe(childSessionKey); + expect(patchParams.key).toBe(child.sessionKey); expect(patchParams.label).toBe("my-task"); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((c) => c.method === "agent"); + const agentCalls = ctx.calls.filter((c) => c.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -145,62 +192,25 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.message).toContain("subagent task"); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn runs cleanup via lifecycle events", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, }); const tool = createOpenClawTools({ @@ -221,13 +231,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } vi.useFakeTimers(); try { emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -241,10 +252,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } - const childWait = waitCalls.find((call) => call.runId === childRunId); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const first = agentCalls[0]?.params as @@ -259,7 +270,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.deliver).toBe(false); expect(first?.channel).toBe("discord"); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); const second = agentCalls[1]?.params as | { @@ -272,7 +283,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); expect(second?.message).toContain("subagent task"); - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); @@ -281,65 +292,19 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 3000, - endedAt: 4000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeChatHistory: true, + onAgentSubagentSpawn: (params) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params) => { + const rec = params as { key?: string } | undefined; + deletedKey = rec?.key; + }, + agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); const tool = createOpenClawTools({ @@ -360,16 +325,20 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + vi.useFakeTimers(); + try { + await vi.advanceTimersByTimeAsync(500); + } finally { + vi.useRealTimers(); + } - const childWait = waitCalls.find((call) => call.runId === childRunId); + const child = ctx.getChild(); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); // First call: subagent spawn @@ -382,7 +351,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(second?.deliver).toBe(true); // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); + const sendCalls = ctx.calls.filter((c) => c.method === "send"); expect(sendCalls.length).toBe(0); // Session should be deleted @@ -446,9 +415,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await sleep(0); - await sleep(0); - await sleep(0); + vi.useFakeTimers(); + try { + await vi.advanceTimersByTimeAsync(500); + } finally { + vi.useRealTimers(); + } const mainAgentCall = calls .filter((call) => call.method === "agent") diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 7d3cd00d62d..91aa41c494d 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -271,7 +271,9 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { modelApplied: true, }); - const patchCall = calls.find((call) => call.method === "sessions.patch"); + const patchCall = calls.find( + (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, + ); expect(patchCall?.params).toMatchObject({ model: "opencode/claude", }); @@ -287,7 +289,11 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "sessions.patch") { - throw new Error("invalid model: bad-model"); + const params = request.params as { model?: unknown } | undefined; + if (typeof params?.model === "string" && params.model.trim()) { + throw new Error("invalid model: bad-model"); + } + return { ok: true }; } if (request.method === "agent") { agentCallCount += 1; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 11486c025e3..1e28861a9dc 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -28,6 +28,7 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -97,10 +98,14 @@ export function createSessionsSpawnTool(opts?: { }); // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived // by default and should not inherit the main agent 600s timeout. + const legacyTimeoutSeconds = + typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) + ? Math.max(0, Math.floor(params.timeoutSeconds)) + : undefined; const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : 0; + : (legacyTimeoutSeconds ?? 0); let modelWarning: string | undefined; let modelApplied = false; From 1b455b6d9fc5ddb99e0a15e62754ad0149af34d5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:43:27 +0000 Subject: [PATCH 031/178] refactor(test): dedupe gateway hooks server setup --- src/gateway/server.hooks.e2e.test.ts | 58 +++++++++++----------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/src/gateway/server.hooks.e2e.test.ts b/src/gateway/server.hooks.e2e.test.ts index 3056858496f..a1946dab5ce 100644 --- a/src/gateway/server.hooks.e2e.test.ts +++ b/src/gateway/server.hooks.e2e.test.ts @@ -14,15 +14,23 @@ installGatewayTestHooks({ scope: "suite" }); const resolveMainKey = () => resolveMainSessionKeyFromConfig(); +async function withGatewayServer(fn: (ctx: { port: number }) => Promise): Promise { + const port = await getFreePort(); + const server = await startGatewayServer(port); + try { + return await fn({ port }); + } finally { + await server.close(); + } +} + describe("gateway server hooks", () => { test("handles auth, wake, and agent flows", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "hooks" }], }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { const resNoAuth = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -194,16 +202,12 @@ describe("gateway server hooks", () => { body: "{", }); expect(resBadJson.status).toBe(400); - } finally { - await server.close(); - } + }); }); test("rejects request sessionKey unless hooks.allowRequestSessionKey is enabled", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { const denied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { method: "POST", headers: { @@ -218,9 +222,7 @@ describe("gateway server hooks", () => { expect(denied.status).toBe(400); const deniedBody = (await denied.json()) as { error?: string }; expect(deniedBody.error).toContain("hooks.allowRequestSessionKey"); - } finally { - await server.close(); - } + }); }); test("respects hooks session policy for request + mapping session keys", async () => { @@ -245,9 +247,7 @@ describe("gateway server hooks", () => { }, ], }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { cronIsolatedRun.mockReset(); cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" }); @@ -303,9 +303,7 @@ describe("gateway server hooks", () => { body: JSON.stringify({ subject: "hello" }), }); expect(mappedBadPrefix.status).toBe(400); - } finally { - await server.close(); - } + }); }); test("enforces hooks.allowedAgentIds for explicit agent routing", async () => { @@ -325,9 +323,7 @@ describe("gateway server hooks", () => { testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "hooks" }], }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { cronIsolatedRun.mockReset(); cronIsolatedRun.mockResolvedValueOnce({ status: "ok", @@ -394,9 +390,7 @@ describe("gateway server hooks", () => { const mappedDeniedBody = (await resMappedDenied.json()) as { error?: string }; expect(mappedDeniedBody.error).toContain("hooks.allowedAgentIds"); expect(peekSystemEvents(resolveMainKey()).length).toBe(0); - } finally { - await server.close(); - } + }); }); test("denies explicit agentId when hooks.allowedAgentIds is empty", async () => { @@ -408,9 +402,7 @@ describe("gateway server hooks", () => { testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "hooks" }], }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { const resDenied = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { method: "POST", headers: { @@ -423,16 +415,12 @@ describe("gateway server hooks", () => { const deniedBody = (await resDenied.json()) as { error?: string }; expect(deniedBody.error).toContain("hooks.allowedAgentIds"); expect(peekSystemEvents(resolveMainKey()).length).toBe(0); - } finally { - await server.close(); - } + }); }); test("throttles repeated hook auth failures and resets after success", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { + await withGatewayServer(async ({ port }) => { const firstFail = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { method: "POST", headers: { @@ -478,8 +466,6 @@ describe("gateway server hooks", () => { body: JSON.stringify({ text: "blocked" }), }); expect(failAfterSuccess.status).toBe(401); - } finally { - await server.close(); - } + }); }); }); From 99909f7bc795b9837235dc010f38927e55a41561 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:02:27 +0000 Subject: [PATCH 032/178] refactor(test): share gateway server start helper --- ...r.agent.gateway-server-agent-b.e2e.test.ts | 89 ++++----- src/gateway/server.hooks.e2e.test.ts | 13 +- src/gateway/server.reload.e2e.test.ts | 186 +++++++++--------- src/gateway/test-helpers.server.ts | 56 ++++-- 4 files changed, 175 insertions(+), 169 deletions(-) diff --git a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts index f191f23f826..362f3fb7d17 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts @@ -13,13 +13,12 @@ import { createRegistry } from "./server.e2e-registry-helpers.js"; import { agentCommand, connectOk, - getFreePort, installGatewayTestHooks, onceMessage, rpcReq, - startGatewayServer, startServerWithClient, testState, + withGatewayServer, writeSessionStore, } from "./test-helpers.js"; @@ -326,52 +325,50 @@ describe("gateway server agent", () => { }); test("agent dedupe survives reconnect", { timeout: 60_000 }, async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); + await withGatewayServer(async ({ port }) => { + const dial = async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + await connectOk(ws); + return ws; + }; - const dial = async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - await connectOk(ws); - return ws; - }; + const idem = "reconnect-agent"; + const ws1 = await dial(); + const final1P = onceMessage( + ws1, + (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", + 6000, + ); + ws1.send( + JSON.stringify({ + type: "req", + id: "ag1", + method: "agent", + params: { message: "hi", idempotencyKey: idem }, + }), + ); + const final1 = await final1P; + ws1.close(); - const idem = "reconnect-agent"; - const ws1 = await dial(); - const final1P = onceMessage( - ws1, - (o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted", - 6000, - ); - ws1.send( - JSON.stringify({ - type: "req", - id: "ag1", - method: "agent", - params: { message: "hi", idempotencyKey: idem }, - }), - ); - const final1 = await final1P; - ws1.close(); - - const ws2 = await dial(); - const final2P = onceMessage( - ws2, - (o) => o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted", - 6000, - ); - ws2.send( - JSON.stringify({ - type: "req", - id: "ag2", - method: "agent", - params: { message: "hi again", idempotencyKey: idem }, - }), - ); - const res = await final2P; - expect(res.payload).toEqual(final1.payload); - ws2.close(); - await server.close(); + const ws2 = await dial(); + const final2P = onceMessage( + ws2, + (o) => o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted", + 6000, + ); + ws2.send( + JSON.stringify({ + type: "req", + id: "ag2", + method: "agent", + params: { message: "hi again", idempotencyKey: idem }, + }), + ); + const res = await final2P; + expect(res.payload).toEqual(final1.payload); + ws2.close(); + }); }); test("agent events stream to webchat clients when run context is registered", async () => { diff --git a/src/gateway/server.hooks.e2e.test.ts b/src/gateway/server.hooks.e2e.test.ts index a1946dab5ce..7e0a8a484da 100644 --- a/src/gateway/server.hooks.e2e.test.ts +++ b/src/gateway/server.hooks.e2e.test.ts @@ -3,10 +3,9 @@ import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { cronIsolatedRun, - getFreePort, installGatewayTestHooks, - startGatewayServer, testState, + withGatewayServer, waitForSystemEvent, } from "./test-helpers.js"; @@ -14,16 +13,6 @@ installGatewayTestHooks({ scope: "suite" }); const resolveMainKey = () => resolveMainSessionKeyFromConfig(); -async function withGatewayServer(fn: (ctx: { port: number }) => Promise): Promise { - const port = await getFreePort(); - const server = await startGatewayServer(port); - try { - return await fn({ port }); - } finally { - await server.close(); - } -} - describe("gateway server hooks", () => { test("handles auth, wake, and agent flows", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; diff --git a/src/gateway/server.reload.e2e.test.ts b/src/gateway/server.reload.e2e.test.ts index 7092d130e76..f3ddec1d113 100644 --- a/src/gateway/server.reload.e2e.test.ts +++ b/src/gateway/server.reload.e2e.test.ts @@ -1,11 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { connectOk, - getFreePort, installGatewayTestHooks, rpcReq, - startGatewayServer, startServerWithClient, + withGatewayServer, } from "./test-helpers.js"; const hoisted = vi.hoisted(() => { @@ -200,110 +199,107 @@ describe("gateway hot reload", () => { }); it("applies hot reload actions and emits restart signal", async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); + await withGatewayServer(async () => { + const onHotReload = hoisted.getOnHotReload(); + expect(onHotReload).toBeTypeOf("function"); - const onHotReload = hoisted.getOnHotReload(); - expect(onHotReload).toBeTypeOf("function"); + const nextConfig = { + hooks: { + enabled: true, + token: "secret", + gmail: { account: "me@example.com" }, + }, + cron: { enabled: true, store: "/tmp/cron.json" }, + agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } }, + browser: { enabled: true }, + web: { enabled: true }, + channels: { + telegram: { botToken: "token" }, + discord: { token: "token" }, + signal: { account: "+15550000000" }, + imessage: { enabled: true }, + }, + }; - const nextConfig = { - hooks: { - enabled: true, - token: "secret", - gmail: { account: "me@example.com" }, - }, - cron: { enabled: true, store: "/tmp/cron.json" }, - agents: { defaults: { heartbeat: { every: "1m" }, maxConcurrent: 2 } }, - browser: { enabled: true }, - web: { enabled: true }, - channels: { - telegram: { botToken: "token" }, - discord: { token: "token" }, - signal: { account: "+15550000000" }, - imessage: { enabled: true }, - }, - }; + await onHotReload?.( + { + changedPaths: [ + "hooks.gmail.account", + "cron.enabled", + "agents.defaults.heartbeat.every", + "browser.enabled", + "web.enabled", + "channels.telegram.botToken", + "channels.discord.token", + "channels.signal.account", + "channels.imessage.enabled", + ], + restartGateway: false, + restartReasons: [], + hotReasons: ["web.enabled"], + reloadHooks: true, + restartGmailWatcher: true, + restartBrowserControl: true, + restartCron: true, + restartHeartbeat: true, + restartChannels: new Set(["whatsapp", "telegram", "discord", "signal", "imessage"]), + noopPaths: [], + }, + nextConfig, + ); - await onHotReload?.( - { - changedPaths: [ - "hooks.gmail.account", - "cron.enabled", - "agents.defaults.heartbeat.every", - "browser.enabled", - "web.enabled", - "channels.telegram.botToken", - "channels.discord.token", - "channels.signal.account", - "channels.imessage.enabled", - ], - restartGateway: false, - restartReasons: [], - hotReasons: ["web.enabled"], - reloadHooks: true, - restartGmailWatcher: true, - restartBrowserControl: true, - restartCron: true, - restartHeartbeat: true, - restartChannels: new Set(["whatsapp", "telegram", "discord", "signal", "imessage"]), - noopPaths: [], - }, - nextConfig, - ); + expect(hoisted.stopGmailWatcher).toHaveBeenCalled(); + expect(hoisted.startGmailWatcher).toHaveBeenCalledWith(nextConfig); - expect(hoisted.stopGmailWatcher).toHaveBeenCalled(); - expect(hoisted.startGmailWatcher).toHaveBeenCalledWith(nextConfig); + expect(hoisted.browserStop).toHaveBeenCalledTimes(1); + expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); - expect(hoisted.browserStop).toHaveBeenCalledTimes(1); - expect(hoisted.startBrowserControlServerIfEnabled).toHaveBeenCalledTimes(2); + expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1); + expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1); + expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig); - expect(hoisted.startHeartbeatRunner).toHaveBeenCalledTimes(1); - expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledTimes(1); - expect(hoisted.heartbeatUpdateConfig).toHaveBeenCalledWith(nextConfig); + expect(hoisted.cronInstances.length).toBe(2); + expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); + expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1); - expect(hoisted.cronInstances.length).toBe(2); - expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); - expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledTimes(5); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledTimes(5); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("whatsapp"); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("whatsapp"); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("telegram"); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("telegram"); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("discord"); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("discord"); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("signal"); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("signal"); + expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage"); + expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("imessage"); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledTimes(5); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledTimes(5); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("whatsapp"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("whatsapp"); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("telegram"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("telegram"); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("discord"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("discord"); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("signal"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("signal"); - expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage"); - expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("imessage"); + const onRestart = hoisted.getOnRestart(); + expect(onRestart).toBeTypeOf("function"); - const onRestart = hoisted.getOnRestart(); - expect(onRestart).toBeTypeOf("function"); + const signalSpy = vi.fn(); + process.once("SIGUSR1", signalSpy); - const signalSpy = vi.fn(); - process.once("SIGUSR1", signalSpy); + onRestart?.( + { + changedPaths: ["gateway.port"], + restartGateway: true, + restartReasons: ["gateway.port"], + hotReasons: [], + reloadHooks: false, + restartGmailWatcher: false, + restartBrowserControl: false, + restartCron: false, + restartHeartbeat: false, + restartChannels: new Set(), + noopPaths: [], + }, + {}, + ); - onRestart?.( - { - changedPaths: ["gateway.port"], - restartGateway: true, - restartReasons: ["gateway.port"], - hotReasons: [], - reloadHooks: false, - restartGmailWatcher: false, - restartBrowserControl: false, - restartCron: false, - restartHeartbeat: false, - restartChannels: new Set(), - noopPaths: [], - }, - {}, - ); - - expect(signalSpy).toHaveBeenCalledTimes(1); - - await server.close(); + expect(signalSpy).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 29683bcafac..64c7ffd3302 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -331,6 +331,43 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio return await mod.startGatewayServer(port, resolvedOpts); } +async function startGatewayServerWithRetries(params: { + port: number; + opts?: GatewayServerOptions; +}): Promise<{ port: number; server: Awaited> }> { + let port = params.port; + for (let attempt = 0; attempt < 10; attempt++) { + try { + return { + port, + server: await startGatewayServer(port, params.opts), + }; + } catch (err) { + const code = (err as { cause?: { code?: string } }).cause?.code; + if (code !== "EADDRINUSE") { + throw err; + } + port = await getFreePort(); + } + } + throw new Error("failed to start gateway server after retries"); +} + +export async function withGatewayServer( + fn: (ctx: { port: number; server: Awaited> }) => Promise, + opts?: { port?: number; serverOptions?: GatewayServerOptions }, +): Promise { + const started = await startGatewayServerWithRetries({ + port: opts?.port ?? (await getFreePort()), + opts: opts?.serverOptions, + }); + try { + return await fn({ port: started.port, server: started.server }); + } finally { + await started.server.close(); + } +} + export async function startServerWithClient( token?: string, opts?: GatewayServerOptions & { wsHeaders?: Record }, @@ -352,22 +389,9 @@ export async function startServerWithClient( process.env.OPENCLAW_GATEWAY_TOKEN = fallbackToken; } - let server: Awaited> | null = null; - for (let attempt = 0; attempt < 10; attempt++) { - try { - server = await startGatewayServer(port, gatewayOpts); - break; - } catch (err) { - const code = (err as { cause?: { code?: string } }).cause?.code; - if (code !== "EADDRINUSE") { - throw err; - } - port = await getFreePort(); - } - } - if (!server) { - throw new Error("failed to start gateway server after retries"); - } + const started = await startGatewayServerWithRetries({ port, opts: gatewayOpts }); + port = started.port; + const server = started.server; const ws = new WebSocket( `ws://127.0.0.1:${port}`, From 8ba16a894f465c4bc4be9c0ae6a7231ebb9fd797 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:06:34 +0000 Subject: [PATCH 033/178] refactor(test): reuse withGatewayServer in auth/http suites --- src/gateway/openai-http.e2e.test.ts | 72 +++++----- src/gateway/server.auth.e2e.test.ts | 197 ++++++++++++++-------------- 2 files changed, 140 insertions(+), 129 deletions(-) diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index 154b771d683..e1a3af00dc5 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -2,7 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; -import { agentCommand, getFreePort, installGatewayTestHooks, testState } from "./test-helpers.js"; +import { + agentCommand, + getFreePort, + installGatewayTestHooks, + testState, + withGatewayServer, +} from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -345,46 +351,46 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { }); it("returns 429 for repeated failed auth when gateway.auth.rateLimit is configured", async () => { - const { startGatewayServer } = await import("./server.js"); testState.gatewayAuth = { mode: "token", token: "secret", rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, // oxlint-disable-next-line typescript/no-explicit-any } as any; - const port = await getFreePort(); - const server = await startGatewayServer(port, { - host: "127.0.0.1", - controlUiEnabled: false, - openAiChatCompletionsEnabled: true, - }); - try { - const headers = { - "content-type": "application/json", - authorization: "Bearer wrong", - }; - const body = { - model: "openclaw", - messages: [{ role: "user", content: "hi" }], - }; + await withGatewayServer( + async ({ port }) => { + const headers = { + "content-type": "application/json", + authorization: "Bearer wrong", + }; + const body = { + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }; - const first = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - expect(first.status).toBe(401); + const first = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + expect(first.status).toBe(401); - const second = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - expect(second.status).toBe(429); - expect(second.headers.get("retry-after")).toBeTruthy(); - } finally { - await server.close({ reason: "rate-limit auth test done" }); - } + const second = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + expect(second.status).toBe(429); + expect(second.headers.get("retry-after")).toBeTruthy(); + }, + { + serverOptions: { + host: "127.0.0.1", + controlUiEnabled: false, + openAiChatCompletionsEnabled: true, + }, + }, + ); }); it("streams SSE chunks when stream=true", async () => { diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 91fb4cefcd9..3f480f0dead 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -14,6 +14,7 @@ import { startServerWithClient, testTailscaleWhois, testState, + withGatewayServer, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -596,62 +597,64 @@ describe("gateway server auth/connect", () => { } as any); const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - }, - }); - const challengePromise = onceMessage<{ payload?: unknown }>( - ws, - (o) => o.type === "event" && o.event === "connect.challenge", - ); - await new Promise((resolve) => ws.once("open", resolve)); - const challenge = await challengePromise; - const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); - const identity = loadOrCreateDeviceIdentity(); - const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; - const signedAtMs = Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - role: "operator", - scopes, - signedAtMs, - token: "secret", - nonce: String(nonce), - }); - const device = { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce: String(nonce), - }; - const res = await connectReq(ws, { - token: "secret", - scopes, - device, - client: { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "1.0.0", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, - }); - expect(res.ok).toBe(true); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + try { + await withGatewayServer(async ({ port }) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + }, + }); + const challengePromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const challenge = await challengePromise; + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes, + signedAtMs, + token: "secret", + nonce: String(nonce), + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: String(nonce), + }; + const res = await connectReq(ws, { + token: "secret", + scopes, + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + ws.close(); + }); + } finally { + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } } }); @@ -660,46 +663,48 @@ describe("gateway server auth/connect", () => { testState.gatewayAuth = { mode: "token", token: "secret" }; const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = await openWs(port, { origin: originForPort(port) }); - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); - const identity = loadOrCreateDeviceIdentity(); - const signedAtMs = Date.now() - 60 * 60 * 1000; - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - role: "operator", - scopes: [], - signedAtMs, - token: "secret", - }); - const device = { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - }; - const res = await connectReq(ws, { - token: "secret", - device, - client: { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "1.0.0", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, - }); - expect(res.ok).toBe(true); - expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + try { + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin: originForPort(port) }); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now() - 60 * 60 * 1000; + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes: [], + signedAtMs, + token: "secret", + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + const res = await connectReq(ws, { + token: "secret", + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); + ws.close(); + }); + } finally { + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } } }); From 0b56472cf52c800a165e18705123568aaa389bf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:07:50 +0000 Subject: [PATCH 034/178] refactor(test): dedupe ios/android gateway client id tests --- src/gateway/server.ios-client-id.e2e.test.ts | 26 ++++---------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/gateway/server.ios-client-id.e2e.test.ts b/src/gateway/server.ios-client-id.e2e.test.ts index 37966798db7..2dfba6b42ce 100644 --- a/src/gateway/server.ios-client-id.e2e.test.ts +++ b/src/gateway/server.ios-client-id.e2e.test.ts @@ -61,30 +61,14 @@ function connectReq( ); } -test("accepts openclaw-ios as a valid gateway client id", async () => { +test.each([ + { clientId: "openclaw-ios", platform: "ios" }, + { clientId: "openclaw-android", platform: "android" }, +])("accepts $clientId as a valid gateway client id", async ({ clientId, platform }) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); - const res = await connectReq(ws, { clientId: "openclaw-ios", platform: "ios" }); - // We don't care if auth fails here; we only care that schema validation accepts the client id. - // A schema rejection would close the socket before sending a response. - if (!res.ok) { - // allow unauthorized error when gateway requires auth - // but reject schema validation errors - const message = String(res.error?.message ?? ""); - if (message.includes("invalid connect params")) { - throw new Error(message); - } - } - - ws.close(); -}); - -test("accepts openclaw-android as a valid gateway client id", async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - - const res = await connectReq(ws, { clientId: "openclaw-android", platform: "android" }); + const res = await connectReq(ws, { clientId, platform }); // We don't care if auth fails here; we only care that schema validation accepts the client id. // A schema rejection would close the socket before sending a response. if (!res.ok) { From 65ea200c31863f249cdf780cdfa0f9587790c67e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:12:57 +0000 Subject: [PATCH 035/178] refactor(test): share env var helpers --- src/agents/tools/web-search.e2e.test.ts | 25 +------ ...-non-interactive.provider-auth.e2e.test.ts | 69 ++++--------------- src/test-utils/env.ts | 35 ++++++++++ src/tts/tts.test.ts | 33 +-------- 4 files changed, 52 insertions(+), 110 deletions(-) diff --git a/src/agents/tools/web-search.e2e.test.ts b/src/agents/tools/web-search.e2e.test.ts index e8896f908b4..975f92be877 100644 --- a/src/agents/tools/web-search.e2e.test.ts +++ b/src/agents/tools/web-search.e2e.test.ts @@ -1,30 +1,7 @@ import { describe, expect, it } from "vitest"; +import { withEnv } from "../../test-utils/env.js"; import { __testing } from "./web-search.js"; -function withEnv(env: Record, fn: () => T): T { - const prev: Record = {}; - for (const [key, value] of Object.entries(env)) { - prev[key] = process.env[key]; - if (value === undefined) { - // Make tests hermetic even on machines with real keys set. - delete process.env[key]; - } else { - process.env[key] = value; - } - } - try { - return fn(); - } finally { - for (const [key, value] of Object.entries(prev)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } -} - const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, diff --git a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts index 188bfae6aa1..ea2a4199307 100644 --- a/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "./onboard-auth.js"; import { OPENAI_DEFAULT_MODEL } from "./openai-model-default.js"; @@ -12,20 +13,6 @@ type RuntimeMock = { exit: (code: number) => never; }; -type EnvSnapshot = { - home: string | undefined; - stateDir: string | undefined; - configPath: string | undefined; - skipChannels: string | undefined; - skipGmail: string | undefined; - skipCron: string | undefined; - skipCanvas: string | undefined; - token: string | undefined; - password: string | undefined; - customApiKey: string | undefined; - disableConfigCache: string | undefined; -}; - type OnboardEnv = { configPath: string; runtime: RuntimeMock; @@ -47,49 +34,23 @@ async function removeDirWithRetry(dir: string): Promise { } } -function captureEnv(): EnvSnapshot { - return { - home: process.env.HOME, - stateDir: process.env.OPENCLAW_STATE_DIR, - configPath: process.env.OPENCLAW_CONFIG_PATH, - skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, - skipGmail: process.env.OPENCLAW_SKIP_GMAIL_WATCHER, - skipCron: process.env.OPENCLAW_SKIP_CRON, - skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, - token: process.env.OPENCLAW_GATEWAY_TOKEN, - password: process.env.OPENCLAW_GATEWAY_PASSWORD, - customApiKey: process.env.CUSTOM_API_KEY, - disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE, - }; -} - -function restoreEnvVar(key: keyof NodeJS.ProcessEnv, value: string | undefined): void { - if (value == null) { - delete process.env[key]; - return; - } - process.env[key] = value; -} - -function restoreEnv(prev: EnvSnapshot): void { - restoreEnvVar("HOME", prev.home); - restoreEnvVar("OPENCLAW_STATE_DIR", prev.stateDir); - restoreEnvVar("OPENCLAW_CONFIG_PATH", prev.configPath); - restoreEnvVar("OPENCLAW_SKIP_CHANNELS", prev.skipChannels); - restoreEnvVar("OPENCLAW_SKIP_GMAIL_WATCHER", prev.skipGmail); - restoreEnvVar("OPENCLAW_SKIP_CRON", prev.skipCron); - restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas); - restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token); - restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password); - restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey); - restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache); -} - async function withOnboardEnv( prefix: string, run: (ctx: OnboardEnv) => Promise, ): Promise { - const prev = captureEnv(); + const prev = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "CUSTOM_API_KEY", + "OPENCLAW_DISABLE_CONFIG_CACHE", + ]); process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1"; @@ -120,7 +81,7 @@ async function withOnboardEnv( await run({ configPath, runtime }); } finally { await removeDirWithRetry(tempHome); - restoreEnv(prev); + prev.restore(); } } diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts index 0976987c272..9e813dcff4f 100644 --- a/src/test-utils/env.ts +++ b/src/test-utils/env.ts @@ -16,3 +16,38 @@ export function captureEnv(keys: string[]) { }, }; } + +export function withEnv(env: Record, fn: () => T): T { + const snapshot = captureEnv(Object.keys(env)); + try { + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return fn(); + } finally { + snapshot.restore(); + } +} + +export async function withEnvAsync( + env: Record, + fn: () => Promise, +): Promise { + const snapshot = captureEnv(Object.keys(env)); + try { + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await fn(); + } finally { + snapshot.restore(); + } +} diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index a7fc0daec54..2a134b421b5 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -2,6 +2,7 @@ import { completeSimple } from "@mariozechner/pi-ai"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { getApiKeyForModel } from "../agents/model-auth.js"; import { resolveModel } from "../agents/pi-embedded-runner/model.js"; +import { withEnv } from "../test-utils/env.js"; import * as tts from "./tts.js"; vi.mock("@mariozechner/pi-ai", () => ({ @@ -367,38 +368,6 @@ describe("tts", () => { messages: { tts: {} }, }; - const restoreEnv = (snapshot: Record) => { - const keys = ["OPENAI_API_KEY", "ELEVENLABS_API_KEY", "XI_API_KEY"] as const; - for (const key of keys) { - const value = snapshot[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - const withEnv = (env: Record, run: () => void) => { - const snapshot = { - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - ELEVENLABS_API_KEY: process.env.ELEVENLABS_API_KEY, - XI_API_KEY: process.env.XI_API_KEY, - }; - try { - for (const [key, value] of Object.entries(env)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - run(); - } finally { - restoreEnv(snapshot); - } - }; - it("prefers OpenAI when no provider is configured and API key exists", () => { withEnv( { From a91553c7cf69688b1246d0e4e6774be0fb0ca734 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:02:52 +0000 Subject: [PATCH 036/178] perf(slack): consolidate slash tests --- src/slack/monitor/provider.ts | 2 +- .../monitor/slash.command-arg-menus.test.ts | 211 ---------------- .../{slash.policy.test.ts => slash.test.ts} | 231 +++++++++++++++++- src/slack/monitor/slash.ts | 89 ++++--- 4 files changed, 281 insertions(+), 252 deletions(-) delete mode 100644 src/slack/monitor/slash.command-arg-menus.test.ts rename src/slack/monitor/{slash.policy.test.ts => slash.test.ts} (54%) diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 0533647abd6..6362cfcc8a0 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -227,7 +227,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const handleSlackMessage = createSlackMessageHandler({ ctx, account }); registerSlackMonitorEvents({ ctx, account, handleSlackMessage }); - registerSlackMonitorSlashCommands({ ctx, account }); + await registerSlackMonitorSlashCommands({ ctx, account }); if (slackMode === "http" && slackHttpHandler) { unregisterHttpHandler = registerSlackHttpHandler({ path: slackWebhookPath, diff --git a/src/slack/monitor/slash.command-arg-menus.test.ts b/src/slack/monitor/slash.command-arg-menus.test.ts deleted file mode 100644 index 15931947973..00000000000 --- a/src/slack/monitor/slash.command-arg-menus.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; - -const { dispatchMock } = getSlackSlashMocks(); - -beforeEach(() => { - resetSlackSlashMocks(); -}); - -async function registerCommands(ctx: unknown, account: unknown) { - const { registerSlackMonitorSlashCommands } = await import("./slash.js"); - registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never }); -} - -function encodeValue(parts: { command: string; arg: string; value: string; userId: string }) { - return [ - "cmdarg", - encodeURIComponent(parts.command), - encodeURIComponent(parts.arg), - encodeURIComponent(parts.value), - encodeURIComponent(parts.userId), - ].join("|"); -} - -function createHarness() { - const commands = new Map Promise>(); - const actions = new Map Promise>(); - - const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); - const app = { - client: { chat: { postEphemeral } }, - command: (name: string, handler: (args: unknown) => Promise) => { - commands.set(name, handler); - }, - action: (id: string, handler: (args: unknown) => Promise) => { - actions.set(id, handler); - }, - }; - - const ctx = { - cfg: { commands: { native: true } }, - runtime: {}, - botToken: "bot-token", - botUserId: "bot", - teamId: "T1", - allowFrom: ["*"], - dmEnabled: true, - dmPolicy: "open", - groupDmEnabled: false, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open", - useAccessGroups: false, - channelsConfig: undefined, - slashCommand: { - enabled: true, - name: "openclaw", - ephemeral: true, - sessionPrefix: "slack:slash", - }, - textLimit: 4000, - app, - isChannelAllowed: () => true, - resolveChannelName: async () => ({ name: "dm", type: "im" }), - resolveUserName: async () => ({ name: "Ada" }), - } as unknown; - - const account = { accountId: "acct", config: { commands: { native: true } } } as unknown; - - return { commands, actions, postEphemeral, ctx, account }; -} - -describe("Slack native command argument menus", () => { - it("shows a button menu when required args are omitted", async () => { - const { commands, ctx, account } = createHarness(); - await registerCommands(ctx, account); - - const handler = commands.get("/usage"); - if (!handler) { - throw new Error("Missing /usage handler"); - } - - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await handler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); - - expect(respond).toHaveBeenCalledTimes(1); - const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; - expect(payload.blocks?.[0]?.type).toBe("section"); - expect(payload.blocks?.[1]?.type).toBe("actions"); - }); - - it("dispatches the command when a menu button is clicked", async () => { - const { actions, ctx, account } = createHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - const respond = vi.fn().mockResolvedValue(undefined); - await handler({ - ack: vi.fn().mockResolvedValue(undefined), - action: { - value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), - }, - body: { - user: { id: "U1", name: "Ada" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, - }); - - expect(dispatchMock).toHaveBeenCalledTimes(1); - const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; - expect(call.ctx?.Body).toBe("/usage tokens"); - }); - - it("rejects menu clicks from other users", async () => { - const { actions, ctx, account } = createHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - const respond = vi.fn().mockResolvedValue(undefined); - await handler({ - ack: vi.fn().mockResolvedValue(undefined), - action: { - value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), - }, - body: { - user: { id: "U2", name: "Eve" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, - }); - - expect(dispatchMock).not.toHaveBeenCalled(); - expect(respond).toHaveBeenCalledWith({ - text: "That menu is for another user.", - response_type: "ephemeral", - }); - }); - - it("falls back to postEphemeral with token when respond is unavailable", async () => { - const { actions, postEphemeral, ctx, account } = createHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - await handler({ - ack: vi.fn().mockResolvedValue(undefined), - action: { value: "garbage" }, - body: { user: { id: "U1" }, channel: { id: "C1" } }, - }); - - expect(postEphemeral).toHaveBeenCalledWith( - expect.objectContaining({ - token: "bot-token", - channel: "C1", - user: "U1", - }), - ); - }); - - it("treats malformed percent-encoding as an invalid button (no throw)", async () => { - const { actions, postEphemeral, ctx, account } = createHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - await handler({ - ack: vi.fn().mockResolvedValue(undefined), - action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, - body: { user: { id: "U1" }, channel: { id: "C1" } }, - }); - - expect(postEphemeral).toHaveBeenCalledWith( - expect.objectContaining({ - token: "bot-token", - channel: "C1", - user: "U1", - text: "Sorry, that button is no longer valid.", - }), - ); - }); -}); diff --git a/src/slack/monitor/slash.policy.test.ts b/src/slack/monitor/slash.test.ts similarity index 54% rename from src/slack/monitor/slash.policy.test.ts rename to src/slack/monitor/slash.test.ts index 108ed91f266..352b74d9021 100644 --- a/src/slack/monitor/slash.policy.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -1,18 +1,227 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; +type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise; +let registerSlackMonitorSlashCommands: RegisterFn; + const { dispatchMock } = getSlackSlashMocks(); +beforeAll(async () => { + ({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as { + registerSlackMonitorSlashCommands: RegisterFn; + }); +}); + beforeEach(() => { resetSlackSlashMocks(); }); async function registerCommands(ctx: unknown, account: unknown) { - const { registerSlackMonitorSlashCommands } = await import("./slash.js"); - registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never }); + await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never }); } -function createHarness(overrides?: { +function encodeValue(parts: { command: string; arg: string; value: string; userId: string }) { + return [ + "cmdarg", + encodeURIComponent(parts.command), + encodeURIComponent(parts.arg), + encodeURIComponent(parts.value), + encodeURIComponent(parts.userId), + ].join("|"); +} + +function createArgMenusHarness() { + const commands = new Map Promise>(); + const actions = new Map Promise>(); + + const postEphemeral = vi.fn().mockResolvedValue({ ok: true }); + const app = { + client: { chat: { postEphemeral } }, + command: (name: string, handler: (args: unknown) => Promise) => { + commands.set(name, handler); + }, + action: (id: string, handler: (args: unknown) => Promise) => { + actions.set(id, handler); + }, + }; + + const ctx = { + cfg: { commands: { native: true, nativeSkills: false } }, + runtime: {}, + botToken: "bot-token", + botUserId: "bot", + teamId: "T1", + allowFrom: ["*"], + dmEnabled: true, + dmPolicy: "open", + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + channelsConfig: undefined, + slashCommand: { + enabled: true, + name: "openclaw", + ephemeral: true, + sessionPrefix: "slack:slash", + }, + textLimit: 4000, + app, + isChannelAllowed: () => true, + resolveChannelName: async () => ({ name: "dm", type: "im" }), + resolveUserName: async () => ({ name: "Ada" }), + } as unknown; + + const account = { + accountId: "acct", + config: { commands: { native: true, nativeSkills: false } }, + } as unknown; + + return { commands, actions, postEphemeral, ctx, account }; +} + +describe("Slack native command argument menus", () => { + it("shows a button menu when required args are omitted", async () => { + const { commands, ctx, account } = createArgMenusHarness(); + await registerCommands(ctx, account); + + const handler = commands.get("/usage"); + if (!handler) { + throw new Error("Missing /usage handler"); + } + + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + + await handler({ + command: { + user_id: "U1", + user_name: "Ada", + channel_id: "C1", + channel_name: "directmessage", + text: "", + trigger_id: "t1", + }, + ack, + respond, + }); + + expect(respond).toHaveBeenCalledTimes(1); + const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; + expect(payload.blocks?.[0]?.type).toBe("section"); + expect(payload.blocks?.[1]?.type).toBe("actions"); + }); + + it("dispatches the command when a menu button is clicked", async () => { + const { actions, ctx, account } = createArgMenusHarness(); + await registerCommands(ctx, account); + + const handler = actions.get("openclaw_cmdarg"); + if (!handler) { + throw new Error("Missing arg-menu action handler"); + } + + const respond = vi.fn().mockResolvedValue(undefined); + await handler({ + ack: vi.fn().mockResolvedValue(undefined), + action: { + value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), + }, + body: { + user: { id: "U1", name: "Ada" }, + channel: { id: "C1", name: "directmessage" }, + trigger_id: "t1", + }, + respond, + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const call = dispatchMock.mock.calls[0]?.[0] as { ctx?: { Body?: string } }; + expect(call.ctx?.Body).toBe("/usage tokens"); + }); + + it("rejects menu clicks from other users", async () => { + const { actions, ctx, account } = createArgMenusHarness(); + await registerCommands(ctx, account); + + const handler = actions.get("openclaw_cmdarg"); + if (!handler) { + throw new Error("Missing arg-menu action handler"); + } + + const respond = vi.fn().mockResolvedValue(undefined); + await handler({ + ack: vi.fn().mockResolvedValue(undefined), + action: { + value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), + }, + body: { + user: { id: "U2", name: "Eve" }, + channel: { id: "C1", name: "directmessage" }, + trigger_id: "t1", + }, + respond, + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith({ + text: "That menu is for another user.", + response_type: "ephemeral", + }); + }); + + it("falls back to postEphemeral with token when respond is unavailable", async () => { + const { actions, postEphemeral, ctx, account } = createArgMenusHarness(); + await registerCommands(ctx, account); + + const handler = actions.get("openclaw_cmdarg"); + if (!handler) { + throw new Error("Missing arg-menu action handler"); + } + + await handler({ + ack: vi.fn().mockResolvedValue(undefined), + action: { value: "garbage" }, + body: { user: { id: "U1" }, channel: { id: "C1" } }, + }); + + expect(postEphemeral).toHaveBeenCalledWith( + expect.objectContaining({ + token: "bot-token", + channel: "C1", + user: "U1", + }), + ); + }); + + it("treats malformed percent-encoding as an invalid button (no throw)", async () => { + const { actions, postEphemeral, ctx, account } = createArgMenusHarness(); + await registerCommands(ctx, account); + + const handler = actions.get("openclaw_cmdarg"); + if (!handler) { + throw new Error("Missing arg-menu action handler"); + } + + await handler({ + ack: vi.fn().mockResolvedValue(undefined), + action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, + body: { user: { id: "U1" }, channel: { id: "C1" } }, + }); + + expect(postEphemeral).toHaveBeenCalledWith( + expect.objectContaining({ + token: "bot-token", + channel: "C1", + user: "U1", + text: "Sorry, that button is no longer valid.", + }), + ); + }); +}); + +function createPolicyHarness(overrides?: { groupPolicy?: "open" | "allowlist"; channelsConfig?: Record; channelId?: string; @@ -104,7 +313,7 @@ async function runSlashHandler(params: { describe("slack slash commands channel policy", () => { it("allows unlisted channels when groupPolicy is open", async () => { - const { commands, ctx, account, channelId, channelName } = createHarness({ + const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ groupPolicy: "open", channelsConfig: { C_LISTED: { requireMention: true } }, channelId: "C_UNLISTED", @@ -127,7 +336,7 @@ describe("slack slash commands channel policy", () => { }); it("blocks explicitly denied channels when groupPolicy is open", async () => { - const { commands, ctx, account, channelId, channelName } = createHarness({ + const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ groupPolicy: "open", channelsConfig: { C_DENIED: { allow: false } }, channelId: "C_DENIED", @@ -151,7 +360,7 @@ describe("slack slash commands channel policy", () => { }); it("blocks unlisted channels when groupPolicy is allowlist", async () => { - const { commands, ctx, account, channelId, channelName } = createHarness({ + const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ groupPolicy: "allowlist", channelsConfig: { C_LISTED: { requireMention: true } }, channelId: "C_UNLISTED", @@ -177,7 +386,7 @@ describe("slack slash commands channel policy", () => { describe("slack slash commands access groups", () => { it("fails closed when channel type lookup returns empty for channels", async () => { - const { commands, ctx, account, channelId, channelName } = createHarness({ + const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ allowFrom: [], channelId: "C_UNKNOWN", channelName: "unknown", @@ -201,7 +410,7 @@ describe("slack slash commands access groups", () => { }); it("still treats D-prefixed channel ids as DMs when lookup fails", async () => { - const { commands, ctx, account } = createHarness({ + const { commands, ctx, account } = createPolicyHarness({ allowFrom: [], channelId: "D123", channelName: "notdirectmessage", @@ -228,7 +437,7 @@ describe("slack slash commands access groups", () => { }); it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => { - const { commands, ctx, account } = createHarness({ + const { commands, ctx, account } = createPolicyHarness({ allowFrom: ["U_OWNER"], channelId: "D999", channelName: "directmessage", @@ -254,7 +463,7 @@ describe("slack slash commands access groups", () => { }); it("enforces access-group gating when lookup fails for private channels", async () => { - const { commands, ctx, account, channelId, channelName } = createHarness({ + const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ allowFrom: [], channelId: "G123", channelName: "private", diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 61046ede922..b7db5115bcf 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -2,30 +2,15 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@sla import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js"; import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMonitorContext } from "./context.js"; -import { resolveChunkMode } from "../../auto-reply/chunk.js"; -import { - buildCommandTextFromArgs, - findCommandByNativeName, - listNativeCommandSpecsForConfig, - parseCommandArgs, - resolveCommandArgMenu, -} from "../../auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { resolveConversationLabel } from "../../channels/conversation-label.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.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 { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { normalizeAllowList, normalizeAllowListLower, @@ -36,7 +21,6 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import { normalizeSlackChannelType } from "./context.js"; import { isSlackChannelAllowedByPolicy } from "./policy.js"; -import { deliverSlackSlashReplies } from "./replies.js"; import { resolveSlackRoomContextHints } from "./room-context.js"; type SlackBlock = { type: string; [key: string]: unknown }; @@ -44,6 +28,15 @@ type SlackBlock = { type: string; [key: string]: unknown }; const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg"; const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg"; +type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js"); +let commandsRegistry: CommandsRegistry | undefined; +async function getCommandsRegistry(): Promise { + if (!commandsRegistry) { + commandsRegistry = await import("../../auto-reply/commands-registry.js"); + } + return commandsRegistry; +} + function chunkItems(items: T[], size: number): T[][] { if (size <= 0) { return [items]; @@ -139,10 +132,10 @@ function buildSlackCommandArgMenuBlocks(params: { ]; } -export function registerSlackMonitorSlashCommands(params: { +export async function registerSlackMonitorSlashCommands(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; -}) { +}): Promise { const { ctx, account } = params; const cfg = ctx.cfg; const runtime = ctx.runtime; @@ -349,7 +342,8 @@ export function registerSlackMonitorSlashCommands(params: { } if (commandDefinition && supportsInteractiveArgMenus) { - const menu = resolveCommandArgMenu({ + const reg = await getCommandsRegistry(); + const menu = reg.resolveCommandArgMenu({ command: commandDefinition, args: commandArgs, cfg, @@ -376,6 +370,17 @@ export function registerSlackMonitorSlashCommands(params: { const channelName = channelInfo?.name; const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`; + const [{ resolveAgentRoute }, { finalizeInboundContext }, { dispatchReplyWithDispatcher }] = + await Promise.all([ + import("../../routing/resolve-route.js"), + import("../../auto-reply/reply/inbound-context.js"), + import("../../auto-reply/reply/provider-dispatcher.js"), + ]); + const [{ resolveConversationLabel }, { createReplyPrefixOptions }] = await Promise.all([ + import("../../channels/conversation-label.js"), + import("../../channels/reply-prefix.js"), + ]); + const route = resolveAgentRoute({ cfg, channel: "slack", @@ -450,6 +455,15 @@ export function registerSlackMonitorSlashCommands(params: { dispatcherOptions: { ...prefixOptions, deliver: async (payload) => { + const [ + { deliverSlackSlashReplies }, + { resolveChunkMode }, + { resolveMarkdownTableMode }, + ] = await Promise.all([ + import("./replies.js"), + import("../../auto-reply/chunk.js"), + import("../../config/markdown-tables.js"), + ]); await deliverSlackSlashReplies({ replies: [payload], respond, @@ -473,6 +487,12 @@ export function registerSlackMonitorSlashCommands(params: { }, }); if (counts.final + counts.tool + counts.block === 0) { + const [{ deliverSlackSlashReplies }, { resolveChunkMode }, { resolveMarkdownTableMode }] = + await Promise.all([ + import("./replies.js"), + import("../../auto-reply/chunk.js"), + import("../../config/markdown-tables.js"), + ]); await deliverSlackSlashReplies({ replies: [], respond, @@ -505,25 +525,35 @@ export function registerSlackMonitorSlashCommands(params: { providerSetting: account.config.commands?.nativeSkills, globalSetting: cfg.commands?.nativeSkills, }); - const skillCommands = - nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; - const nativeCommands = nativeEnabled - ? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" }) - : []; + + let reg: CommandsRegistry | undefined; + let nativeCommands: Array<{ name: string }> = []; + if (nativeEnabled) { + reg = await getCommandsRegistry(); + const skillCommands = nativeSkillsEnabled + ? (await import("../../auto-reply/skill-commands.js")).listSkillCommandsForAgents({ cfg }) + : []; + nativeCommands = reg.listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" }); + } + if (nativeCommands.length > 0) { + const registry = reg; + if (!registry) { + throw new Error("Missing commands registry for native Slack commands."); + } for (const command of nativeCommands) { ctx.app.command( `/${command.name}`, async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => { - const commandDefinition = findCommandByNativeName(command.name, "slack"); + const commandDefinition = registry.findCommandByNativeName(command.name, "slack"); const rawText = cmd.text?.trim() ?? ""; const commandArgs = commandDefinition - ? parseCommandArgs(commandDefinition, rawText) + ? registry.parseCommandArgs(commandDefinition, rawText) : rawText ? ({ raw: rawText } satisfies CommandArgs) : undefined; const prompt = commandDefinition - ? buildCommandTextFromArgs(commandDefinition, commandArgs) + ? registry.buildCommandTextFromArgs(commandDefinition, commandArgs) : rawText ? `/${command.name} ${rawText}` : `/${command.name}`; @@ -596,12 +626,13 @@ export function registerSlackMonitorSlashCommands(params: { }); return; } - const commandDefinition = findCommandByNativeName(parsed.command, "slack"); + const reg = await getCommandsRegistry(); + const commandDefinition = reg.findCommandByNativeName(parsed.command, "slack"); const commandArgs: CommandArgs = { values: { [parsed.arg]: parsed.value }, }; const prompt = commandDefinition - ? buildCommandTextFromArgs(commandDefinition, commandArgs) + ? reg.buildCommandTextFromArgs(commandDefinition, commandArgs) : `/${parsed.command} ${parsed.value}`; const user = body.user; const userName = From def74465eb8f689300e253c1237a1069d6469cfa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:16:03 +0000 Subject: [PATCH 037/178] perf(test): consolidate runReplyAgent suites --- ...ing.runreplyagent-typing-heartbeat.test.ts | 583 -------- ...nt-runner.heartbeat-typing.test-harness.ts | 135 -- ...y-flush.runreplyagent-memory-flush.test.ts | 423 ------ .../agent-runner.memory-flush.test-harness.ts | 121 -- .../reply/agent-runner.runreplyagent.test.ts | 1175 +++++++++++++++++ .../reply/agent-runner.test-harness.mocks.ts | 51 - 6 files changed, 1175 insertions(+), 1313 deletions(-) delete mode 100644 src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts delete mode 100644 src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts delete mode 100644 src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts delete mode 100644 src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts create mode 100644 src/auto-reply/reply/agent-runner.runreplyagent.test.ts delete mode 100644 src/auto-reply/reply/agent-runner.test-harness.mocks.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts deleted file mode 100644 index 9c14f82c77f..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as sessions from "../../config/sessions.js"; -import { - createMinimalRun, - getRunEmbeddedPiAgentMock, - installRunReplyAgentTypingHeartbeatTestHooks, -} from "./agent-runner.heartbeat-typing.test-harness.js"; - -type AgentRunParams = { - onPartialReply?: (payload: { text?: string }) => Promise | void; - onAssistantMessageStart?: () => Promise | void; - onReasoningStream?: (payload: { text?: string }) => Promise | void; - onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onAgentEvent?: (evt: { stream: string; data: Record }) => void; -}; - -const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - -let fixtureRoot = ""; -let caseId = 0; - -type StateEnvSnapshot = { - OPENCLAW_STATE_DIR: string | undefined; -}; - -function snapshotStateEnv(): StateEnvSnapshot { - return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; -} - -function restoreStateEnv(snapshot: StateEnvSnapshot) { - if (snapshot.OPENCLAW_STATE_DIR === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; - } -} - -async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { - const stateDir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(stateDir, { recursive: true }); - const envSnapshot = snapshotStateEnv(); - process.env.OPENCLAW_STATE_DIR = stateDir; - try { - return await fn(stateDir); - } finally { - restoreStateEnv(envSnapshot); - } -} - -async function writeCorruptGeminiSessionFixture(params: { - stateDir: string; - sessionId: string; - persistStore: boolean; -}) { - const storePath = path.join(params.stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - if (params.persistStore) { - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - } - - const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "bad", "utf-8"); - - return { storePath, sessionEntry, sessionStore, transcriptPath }; -} - -describe("runReplyAgent typing (heartbeat)", () => { - installRunReplyAgentTypingHeartbeatTestHooks(); - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } - }); - - beforeEach(() => { - vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - }); - - it("signals typing for normal runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).toHaveBeenCalled(); - }); - - it("signals typing even without consumer partial handler", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("never signals typing for heartbeat runs", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: true, onPartialReply }, - }); - await run(); - - expect(onPartialReply).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("suppresses partial streaming for NO_REPLY", async () => { - const onPartialReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "NO_REPLY" }); - return { payloads: [{ text: "NO_REPLY" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); - - expect(onPartialReply).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("does not start typing on assistant message start without prior text in message mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onAssistantMessageStart?.(); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - }); - await run(); - - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("starts typing from reasoning stream in thinking mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "thinking", - }); - await run(); - - expect(typing.startTypingLoop).toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - }); - - it("suppresses typing in never mode", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "hi" }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "never", - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("signals typing on normalized block replies", async () => { - const onBlockReply = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - blockStreamingEnabled: true, - opts: { onBlockReply }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); - expect(onBlockReply).toHaveBeenCalled(); - const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; - expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); - expect(blockOpts).toMatchObject({ - abortSignal: expect.any(AbortSignal), - timeoutMs: expect.any(Number), - }); - }); - - it("signals typing on tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); - expect(onToolResult).toHaveBeenCalledWith({ - text: "tooling", - mediaUrls: [], - }); - }); - - it("skips typing for silent tool results", async () => { - const onToolResult = vi.fn(); - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(onToolResult).not.toHaveBeenCalled(); - }); - - it("announces auto-compaction in verbose mode and tracks count", async () => { - await withTempStateDir(async (stateDir) => { - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }); - - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - expect(Array.isArray(res)).toBe(true); - const payloads = res as { text?: string }[]; - expect(payloads[0]?.text).toContain("Auto-compaction complete"); - expect(payloads[0]?.text).toContain("count 1"); - expect(sessionStore.main.compactionCount).toBe(1); - }); - }); - - it("retries after compaction failure by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded during compaction"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("retries after context overflow payload by resetting the session", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Context overflow: prompt too large", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "context_overflow", - message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Context limit exceeded"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets the session after role ordering payloads", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "role_ordering", - message: 'messages: roles must alternate between "user" and "assistant"', - }, - }, - })); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(payload.text?.toLowerCase()).toContain("reset"); - expect(sessionStore.main.sessionId).not.toBe(sessionId); - await expect(fs.access(transcriptPath)).rejects.toBeDefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); - }); - }); - - it("resets corrupted Gemini sessions and deletes transcripts", async () => { - await withTempStateDir(async (stateDir) => { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: true, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeUndefined(); - }); - }); - - it("keeps sessions intact on other errors", async () => { - await withTempStateDir(async (stateDir) => { - const sessionId = "session-ok"; - const storePath = path.join(stateDir, "sessions", "sessions.json"); - const sessionEntry = { sessionId, updatedAt: Date.now() }; - const sessionStore = { main: sessionEntry }; - - await fs.mkdir(path.dirname(storePath), { recursive: true }); - await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); - - const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); - await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); - await fs.writeFile(transcriptPath, "ok", "utf-8"); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("INVALID_ARGUMENT: some other failure"); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Agent failed before reply"), - }); - expect(sessionStore.main).toBeDefined(); - await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); - - const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(persisted.main).toBeDefined(); - }); - }); - - it("still replies even if session reset fails to persist", async () => { - await withTempStateDir(async (stateDir) => { - const saveSpy = vi - .spyOn(sessions, "saveSessionStore") - .mockRejectedValueOnce(new Error("boom")); - try { - const { storePath, sessionEntry, sessionStore, transcriptPath } = - await writeCorruptGeminiSessionFixture({ - stateDir, - sessionId: "session-corrupt", - persistStore: false, - }); - - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error( - "function call turn comes immediately after a user turn or after a function response turn", - ); - }); - - const { run } = createMinimalRun({ - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - }); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Session history was corrupted"), - }); - expect(sessionStore.main).toBeUndefined(); - await expect(fs.access(transcriptPath)).rejects.toThrow(); - } finally { - saveSpy.mockRestore(); - } - }); - }); - - it("returns friendly message for role ordering errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error("400 Incorrect role information"); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - expect(res).toMatchObject({ - text: expect.not.stringContaining("400"), - }); - }); - - it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => { - throw new Error('messages: roles must alternate between "user" and "assistant"'); - }); - - const { run } = createMinimalRun({}); - const res = await run(); - - expect(res).toMatchObject({ - text: expect.stringContaining("Message ordering conflict"), - }); - }); - - it("rewrites Bun socket errors into friendly text", async () => { - runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ - payloads: [ - { - text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", - isError: true, - }, - ], - meta: {}, - })); - - const { run } = createMinimalRun(); - const res = await run(); - const payloads = Array.isArray(res) ? res : res ? [res] : []; - expect(payloads.length).toBe(1); - expect(payloads[0]?.text).toContain("LLM connection failed"); - expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); - expect(payloads[0]?.text).toContain("```"); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts deleted file mode 100644 index 80e1e37c8f7..00000000000 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test-harness.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeAll, beforeEach, vi } from "vitest"; -import type { SessionEntry } from "../../config/sessions.js"; -import type { TypingMode } from "../../config/types.js"; -import type { TemplateContext } from "../templating.js"; -import type { GetReplyOptions } from "../types.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), -})); - -let runReplyAgentPromise: - | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> - | undefined; - -async function getRunReplyAgent() { - if (!runReplyAgentPromise) { - runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); - } - return await runReplyAgentPromise; -} - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function installRunReplyAgentTypingHeartbeatTestHooks() { - beforeAll(async () => { - // Avoid attributing the initial agent-runner import cost to the first test case. - await getRunReplyAgent(); - }); - beforeEach(() => { - state.runEmbeddedPiAgentMock.mockReset(); - }); -} - -async function loadHarnessMocks() { - const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js"); - return await loadAgentRunnerHarnessMockBundle(state); -} - -vi.mock("../../agents/model-fallback.js", async () => { - return (await loadHarnessMocks()).modelFallback; -}); - -vi.mock("../../agents/pi-embedded.js", async () => { - return (await loadHarnessMocks()).embeddedPi; -}); - -vi.mock("./queue.js", async () => { - return (await loadHarnessMocks()).queue; -}); - -export function createMinimalRun(params?: { - opts?: GetReplyOptions; - resolvedVerboseLevel?: "off" | "on"; - sessionStore?: Record; - sessionEntry?: SessionEntry; - sessionKey?: string; - storePath?: string; - typingMode?: TypingMode; - blockStreamingEnabled?: boolean; -}) { - const typing = createMockTypingController(); - const opts = params?.opts; - const sessionCtx = { - Provider: "whatsapp", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const sessionKey = params?.sessionKey ?? "main"; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - sessionId: "session", - sessionKey, - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: params?.resolvedVerboseLevel ?? "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - - return { - typing, - opts, - run: async () => { - const runReplyAgent = await getRunReplyAgent(); - return runReplyAgent({ - commandBody: "hello", - followupRun, - queueKey: "main", - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - opts, - typing, - sessionEntry: params?.sessionEntry, - sessionStore: params?.sessionStore, - sessionKey, - storePath: params?.storePath, - sessionCtx, - defaultModel: "anthropic/claude-opus-4-5", - resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", - isNewSession: false, - blockStreamingEnabled: params?.blockStreamingEnabled ?? false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params?.typingMode ?? "instant", - }); - }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts deleted file mode 100644 index e13de88c54d..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { - createBaseRun, - getRunCliAgentMock, - getRunEmbeddedPiAgentMock, - seedSessionStore, - type EmbeddedRunParams, -} from "./agent-runner.memory-flush.test-harness.js"; -import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; - -let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent; - -let fixtureRoot = ""; -let caseId = 0; - -async function withTempStore(fn: (storePath: string) => Promise): Promise { - const dir = path.join(fixtureRoot, `case-${++caseId}`); - await fs.mkdir(dir, { recursive: true }); - return await fn(path.join(dir, "sessions.json")); -} - -async function runReplyAgentWithBase(params: { - baseRun: ReturnType; - storePath: string; - sessionKey: string; - sessionEntry: Record; - commandBody: string; - typingMode?: "instant"; -}): Promise { - const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; - await runReplyAgent({ - commandBody: params.commandBody, - followupRun, - queueKey: params.sessionKey, - resolvedQueue, - shouldSteer: false, - shouldFollowup: false, - isActive: false, - isStreaming: false, - typing, - sessionCtx, - sessionEntry: params.sessionEntry, - sessionStore: { [params.sessionKey]: params.sessionEntry }, - sessionKey: params.sessionKey, - storePath: params.storePath, - defaultModel: "anthropic/claude-opus-4-5", - agentCfgContextTokens: 100_000, - resolvedVerboseLevel: "off", - isNewSession: false, - blockStreamingEnabled: false, - resolvedBlockStreamingBreak: "message_end", - shouldInjectGroupIntro: false, - typingMode: params.typingMode ?? "instant", - }); -} - -async function expectMemoryFlushSkippedWithWorkspaceAccess( - workspaceAccess: "ro" | "none", -): Promise { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - sandbox: { mode: "all", workspaceAccess }, - }, - }, - }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); -} - -beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-flush-")); - ({ runReplyAgent } = await import("./agent-runner.js")); -}); - -afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - } -}); - -describe("runReplyAgent memory flush", () => { - it("skips memory flush for CLI providers", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const runCliAgentMock = getRunCliAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - runCliAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - runCliAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - runOverrides: { provider: "codex-cli" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - const call = runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - - it("uses configured prompts for memory flush runs", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push(params); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write notes.", - systemPrompt: "Flush memory now.", - }, - }, - }, - }, - }, - runOverrides: { extraSystemPrompt: "extra system" }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const flushCall = calls[0]; - expect(flushCall?.prompt).toContain("Write notes."); - expect(flushCall?.prompt).toContain("NO_REPLY"); - expect(flushCall?.extraSystemPrompt).toContain("extra system"); - expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); - expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); - expect(calls[1]?.prompt).toBe("hello"); - }); - }); - - it("runs a memory flush turn and updates session metadata", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); - }); - }); - - it("skips memory flush when disabled in config", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async () => ({ - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - })); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; - expect(call?.prompt).toBe("hello"); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); - }); - }); - - it("skips memory flush after a prior flush in the same compaction cycle", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - const calls: Array<{ prompt?: string }> = []; - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - calls.push({ prompt: params.prompt }); - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - expect(calls.map((call) => call.prompt)).toEqual(["hello"]); - }); - }); - - it("skips memory flush when the sandbox workspace is read-only", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("ro"); - }); - - it("skips memory flush when the sandbox workspace is none", async () => { - await expectMemoryFlushSkippedWithWorkspaceAccess("none"); - }); - - it("increments compaction count when flush compaction completes", async () => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockReset(); - - await withTempStore(async (storePath) => { - const sessionKey = "main"; - const sessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 80_000, - compactionCount: 1, - }; - - await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); - - runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { - if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { payloads: [], meta: {} }; - } - return { - payloads: [{ text: "ok" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const baseRun = createBaseRun({ - storePath, - sessionEntry, - }); - - await runReplyAgentWithBase({ - baseRun, - storePath, - sessionKey, - sessionEntry, - commandBody: "hello", - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(2); - expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); - }); - }); -}); diff --git a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts b/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts deleted file mode 100644 index 74204b9f7f9..00000000000 --- a/src/auto-reply/reply/agent-runner.memory-flush.test-harness.ts +++ /dev/null @@ -1,121 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { vi } from "vitest"; -import type { TemplateContext } from "../templating.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { createMockTypingController } from "./test-helpers.js"; - -// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). -// oxlint-disable-next-line typescript/no-explicit-any -type AnyMock = any; - -type EmbeddedRunParams = { - prompt?: string; - extraSystemPrompt?: string; - onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; -}; - -const state = vi.hoisted(() => ({ - runEmbeddedPiAgentMock: vi.fn(), - runCliAgentMock: vi.fn(), -})); - -export function getRunEmbeddedPiAgentMock(): AnyMock { - return state.runEmbeddedPiAgentMock; -} - -export function getRunCliAgentMock(): AnyMock { - return state.runCliAgentMock; -} - -export type { EmbeddedRunParams }; - -async function loadHarnessMocks() { - const { loadAgentRunnerHarnessMockBundle } = await import("./agent-runner.test-harness.mocks.js"); - return await loadAgentRunnerHarnessMockBundle(state); -} - -vi.mock("../../agents/model-fallback.js", async () => { - return (await loadHarnessMocks()).modelFallback; -}); - -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: (params: unknown) => state.runCliAgentMock(params), -})); - -vi.mock("../../agents/pi-embedded.js", async () => { - return (await loadHarnessMocks()).embeddedPi; -}); - -vi.mock("./queue.js", async () => { - return (await loadHarnessMocks()).queue; -}); - -export async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -export function createBaseRun(params: { - storePath: string; - sessionEntry: Record; - config?: Record; - runOverrides?: Partial; -}) { - const typing = createMockTypingController(); - const sessionCtx = { - Provider: "whatsapp", - OriginatingTo: "+15550001111", - AccountId: "primary", - MessageSid: "msg", - } as unknown as TemplateContext; - const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; - const followupRun = { - prompt: "hello", - summaryLine: "hello", - enqueuedAt: Date.now(), - run: { - agentId: "main", - agentDir: "/tmp/agent", - sessionId: "session", - sessionKey: "main", - messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - config: params.config ?? {}, - skillsSnapshot: {}, - provider: "anthropic", - model: "claude", - thinkLevel: "low", - verboseLevel: "off", - elevatedLevel: "off", - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - timeoutMs: 1_000, - blockReplyBreak: "message_end", - }, - } as unknown as FollowupRun; - const run = { - ...followupRun.run, - ...params.runOverrides, - config: params.config ?? followupRun.run.config, - }; - - return { - typing, - sessionCtx, - resolvedQueue, - followupRun: { ...followupRun, run }, - }; -} diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts new file mode 100644 index 00000000000..ec7fb1161ff --- /dev/null +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -0,0 +1,1175 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { TypingMode } from "../../config/types.js"; +import type { TemplateContext } from "../templating.js"; +import type { GetReplyOptions } from "../types.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import * as sessions from "../../config/sessions.js"; +import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; +import { createMockTypingController } from "./test-helpers.js"; + +type AgentRunParams = { + onPartialReply?: (payload: { text?: string }) => Promise | void; + onAssistantMessageStart?: () => Promise | void; + onReasoningStream?: (payload: { text?: string }) => Promise | void; + onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onAgentEvent?: (evt: { stream: string; data: Record }) => void; +}; + +type EmbeddedRunParams = { + prompt?: string; + extraSystemPrompt?: string; + onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void; +}; + +const state = vi.hoisted(() => ({ + runEmbeddedPiAgentMock: vi.fn(), + runCliAgentMock: vi.fn(), +})); + +let runReplyAgentPromise: + | Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]> + | undefined; + +async function getRunReplyAgent() { + if (!runReplyAgentPromise) { + runReplyAgentPromise = import("./agent-runner.js").then((m) => m.runReplyAgent); + } + return await runReplyAgentPromise; +} + +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", () => ({ + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), +})); + +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: (params: unknown) => state.runCliAgentMock(params), +})); + +vi.mock("./queue.js", () => ({ + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), +})); + +beforeAll(async () => { + // Avoid attributing the initial agent-runner import cost to the first test case. + await getRunReplyAgent(); +}); + +beforeEach(() => { + state.runEmbeddedPiAgentMock.mockReset(); + state.runCliAgentMock.mockReset(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); +}); + +function createMinimalRun(params?: { + opts?: GetReplyOptions; + resolvedVerboseLevel?: "off" | "on"; + sessionStore?: Record; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; + typingMode?: TypingMode; + blockStreamingEnabled?: boolean; +}) { + const typing = createMockTypingController(); + const opts = params?.opts; + const sessionCtx = { + Provider: "whatsapp", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const sessionKey = params?.sessionKey ?? "main"; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey, + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: params?.resolvedVerboseLevel ?? "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return { + typing, + opts, + run: async () => { + const runReplyAgent = await getRunReplyAgent(); + return runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + opts, + typing, + sessionEntry: params?.sessionEntry, + sessionStore: params?.sessionStore, + sessionKey, + storePath: params?.storePath, + sessionCtx, + defaultModel: "anthropic/claude-opus-4-5", + resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", + isNewSession: false, + blockStreamingEnabled: params?.blockStreamingEnabled ?? false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params?.typingMode ?? "instant", + }); + }, + }; +} + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +function createBaseRun(params: { + storePath: string; + sessionEntry: Record; + config?: Record; + runOverrides?: Partial; +}) { + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "whatsapp", + OriginatingTo: "+15550001111", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + agentId: "main", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: params.config ?? {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + const run = { + ...followupRun.run, + ...params.runOverrides, + config: params.config ?? followupRun.run.config, + }; + + return { + typing, + sessionCtx, + resolvedQueue, + followupRun: { ...followupRun, run }, + }; +} + +async function runReplyAgentWithBase(params: { + baseRun: ReturnType; + storePath: string; + sessionKey: string; + sessionEntry: Record; + commandBody: string; + typingMode?: "instant"; +}): Promise { + const runReplyAgent = await getRunReplyAgent(); + const { typing, sessionCtx, resolvedQueue, followupRun } = params.baseRun; + await runReplyAgent({ + commandBody: params.commandBody, + followupRun, + queueKey: params.sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry: params.sessionEntry, + sessionStore: { [params.sessionKey]: params.sessionEntry } as Record, + sessionKey: params.sessionKey, + storePath: params.storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: params.typingMode ?? "instant", + }); +} + +describe("runReplyAgent typing (heartbeat)", () => { + let fixtureRoot = ""; + let caseId = 0; + + type StateEnvSnapshot = { + OPENCLAW_STATE_DIR: string | undefined; + }; + + function snapshotStateEnv(): StateEnvSnapshot { + return { OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR }; + } + + function restoreStateEnv(snapshot: StateEnvSnapshot) { + if (snapshot.OPENCLAW_STATE_DIR === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = snapshot.OPENCLAW_STATE_DIR; + } + } + + async function withTempStateDir(fn: (stateDir: string) => Promise): Promise { + const stateDir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(stateDir, { recursive: true }); + const envSnapshot = snapshotStateEnv(); + process.env.OPENCLAW_STATE_DIR = stateDir; + try { + return await fn(stateDir); + } finally { + restoreStateEnv(envSnapshot); + } + } + + async function writeCorruptGeminiSessionFixture(params: { + stateDir: string; + sessionId: string; + persistStore: boolean; + }) { + const storePath = path.join(params.stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: params.sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + if (params.persistStore) { + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + } + + const transcriptPath = sessions.resolveSessionTranscriptPath(params.sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "bad", "utf-8"); + + return { storePath, sessionEntry, sessionStore, transcriptPath }; + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-typing-heartbeat-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("signals typing for normal runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); + expect(typing.startTypingLoop).toHaveBeenCalled(); + }); + + it("signals typing even without consumer partial handler", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("hi"); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("never signals typing for heartbeat runs", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: true, onPartialReply }, + }); + await run(); + + expect(onPartialReply).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("suppresses partial streaming for NO_REPLY", async () => { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "NO_REPLY" }); + return { payloads: [{ text: "NO_REPLY" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + typingMode: "message", + }); + await run(); + + expect(onPartialReply).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("does not start typing on assistant message start without prior text in message mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onAssistantMessageStart?.(); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + }); + await run(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("starts typing from reasoning stream in thinking mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "thinking", + }); + await run(); + + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + }); + + it("suppresses typing in never mode", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onPartialReply?.({ text: "hi" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "never", + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + }); + + it("signals typing on normalized block replies", async () => { + const onBlockReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onBlockReply?.({ text: "\n\nchunk", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + blockStreamingEnabled: true, + opts: { onBlockReply }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("chunk"); + expect(onBlockReply).toHaveBeenCalled(); + const [blockPayload, blockOpts] = onBlockReply.mock.calls[0] ?? []; + expect(blockPayload).toMatchObject({ text: "chunk", audioAsVoice: false }); + expect(blockOpts).toMatchObject({ + abortSignal: expect.any(AbortSignal), + timeoutMs: expect.any(Number), + }); + }); + + it("signals typing on tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); + expect(onToolResult).toHaveBeenCalledWith({ + text: "tooling", + mediaUrls: [], + }); + }); + + it("skips typing for silent tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(onToolResult).not.toHaveBeenCalled(); + }); + + it("announces auto-compaction in verbose mode and tracks count", async () => { + await withTempStateDir(async (stateDir) => { + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + resolvedVerboseLevel: "on", + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + expect(Array.isArray(res)).toBe(true); + const payloads = res as { text?: string }[]; + expect(payloads[0]?.text).toContain("Auto-compaction complete"); + expect(payloads[0]?.text).toContain("count 1"); + expect(sessionStore.main.compactionCount).toBe(1); + }); + }); + + it("retries after compaction failure by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded during compaction"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("retries after context overflow payload by resetting the session", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Context overflow: prompt too large", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets the session after role ordering payloads", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "role_ordering", + message: 'messages: roles must alternate between "user" and "assistant"', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + await expect(fs.access(transcriptPath)).rejects.toBeDefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + }); + }); + + it("resets corrupted Gemini sessions and deletes transcripts", async () => { + await withTempStateDir(async (stateDir) => { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: true, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeUndefined(); + }); + }); + + it("keeps sessions intact on other errors", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session-ok"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("INVALID_ARGUMENT: some other failure"); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Agent failed before reply"), + }); + expect(sessionStore.main).toBeDefined(); + await expect(fs.access(transcriptPath)).resolves.toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main).toBeDefined(); + }); + }); + + it("still replies even if session reset fails to persist", async () => { + await withTempStateDir(async (stateDir) => { + const saveSpy = vi + .spyOn(sessions, "saveSessionStore") + .mockRejectedValueOnce(new Error("boom")); + try { + const { storePath, sessionEntry, sessionStore, transcriptPath } = + await writeCorruptGeminiSessionFixture({ + stateDir, + sessionId: "session-corrupt", + persistStore: false, + }); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + "function call turn comes immediately after a user turn or after a function response turn", + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Session history was corrupted"), + }); + expect(sessionStore.main).toBeUndefined(); + await expect(fs.access(transcriptPath)).rejects.toThrow(); + } finally { + saveSpy.mockRestore(); + } + }); + }); + + it("returns friendly message for role ordering errors thrown as exceptions", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("400 Incorrect role information"); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(res).toMatchObject({ + text: expect.not.stringContaining("400"), + }); + }); + + it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error('messages: roles must alternate between "user" and "assistant"'); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + }); + + it("rewrites Bun socket errors into friendly text", async () => { + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [ + { + text: "TypeError: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()", + isError: true, + }, + ], + meta: {}, + })); + + const { run } = createMinimalRun(); + const res = await run(); + const payloads = Array.isArray(res) ? res : res ? [res] : []; + expect(payloads.length).toBe(1); + expect(payloads[0]?.text).toContain("LLM connection failed"); + expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); + expect(payloads[0]?.text).toContain("```"); + }); +}); + +describe("runReplyAgent memory flush", () => { + let fixtureRoot = ""; + let caseId = 0; + + async function withTempStore(fn: (storePath: string) => Promise): Promise { + const dir = path.join(fixtureRoot, `case-${++caseId}`); + await fs.mkdir(dir, { recursive: true }); + return await fn(path.join(dir, "sessions.json")); + } + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(tmpdir(), "openclaw-memory-flush-")); + }); + + afterAll(async () => { + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } + }); + + async function expectMemoryFlushSkippedWithWorkspaceAccess( + workspaceAccess: "ro" | "none", + ): Promise { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess }, + }, + }, + }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); + } + + it("skips memory flush for CLI providers", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + state.runCliAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + runOverrides: { provider: "codex-cli" }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runCliAgentMock).toHaveBeenCalledTimes(1); + const call = state.runCliAgentMock.mock.calls[0]?.[0] as { prompt?: string } | undefined; + expect(call?.prompt).toBe("hello"); + expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("uses configured prompts for memory flush runs", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push(params); + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { + agents: { + defaults: { + compaction: { + memoryFlush: { + prompt: "Write notes.", + systemPrompt: "Flush memory now.", + }, + }, + }, + }, + }, + runOverrides: { extraSystemPrompt: "extra system" }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + const flushCall = calls[0]; + expect(flushCall?.prompt).toContain("Write notes."); + expect(flushCall?.prompt).toContain("NO_REPLY"); + expect(flushCall?.extraSystemPrompt).toContain("extra system"); + expect(flushCall?.extraSystemPrompt).toContain("Flush memory now."); + expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY"); + expect(calls[1]?.prompt).toBe("hello"); + }); + }); + + it("runs a memory flush turn and updates session metadata", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual([DEFAULT_MEMORY_FLUSH_PROMPT, "hello"]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); + }); + }); + + it("skips memory flush when disabled in config", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async () => ({ + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + })); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + config: { agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } } }, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(state.runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const call = state.runEmbeddedPiAgentMock.mock.calls[0]?.[0] as + | { prompt?: string } + | undefined; + expect(call?.prompt).toBe("hello"); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); + }); + + it("skips memory flush after a prior flush in the same compaction cycle", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + }); + }); + + it("skips memory flush when the sandbox workspace is read-only", async () => { + await expectMemoryFlushSkippedWithWorkspaceAccess("ro"); + }); + + it("skips memory flush when the sandbox workspace is none", async () => { + await expectMemoryFlushSkippedWithWorkspaceAccess("none"); + }); + + it("increments compaction count when flush compaction completes", async () => { + await withTempStore(async (storePath) => { + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }); + + const baseRun = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgentWithBase({ + baseRun, + storePath, + sessionKey, + sessionEntry, + commandBody: "hello", + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(2); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(2); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts b/src/auto-reply/reply/agent-runner.test-harness.mocks.ts deleted file mode 100644 index 6d5d952414b..00000000000 --- a/src/auto-reply/reply/agent-runner.test-harness.mocks.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { vi } from "vitest"; - -export type AgentRunnerEmbeddedState = { - runEmbeddedPiAgentMock: (params: unknown) => unknown; -}; - -export function modelFallbackMockFactory(): Record { - return { - runWithModelFallback: async ({ - provider, - model, - run, - }: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - }) => ({ - result: await run(provider, model), - provider, - model, - }), - }; -} - -export function embeddedPiMockFactory(state: AgentRunnerEmbeddedState): Record { - return { - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: (params: unknown) => state.runEmbeddedPiAgentMock(params), - }; -} - -export async function queueMockFactory(): Promise> { - const actual = await vi.importActual("./queue.js"); - return { - ...actual, - enqueueFollowupRun: vi.fn(), - scheduleFollowupDrain: vi.fn(), - }; -} - -export async function loadAgentRunnerHarnessMockBundle(state: AgentRunnerEmbeddedState): Promise<{ - modelFallback: Record; - embeddedPi: Record; - queue: Record; -}> { - return { - modelFallback: modelFallbackMockFactory(), - embeddedPi: embeddedPiMockFactory(state), - queue: await queueMockFactory(), - }; -} From 4fc72226fa68e24354ac806b1284558a10ce3814 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:20:32 +0000 Subject: [PATCH 038/178] perf(test): speed up slack slash suite --- src/slack/monitor/slash.test.ts | 119 ++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 44 deletions(-) diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 352b74d9021..f637847c116 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -1,6 +1,52 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js"; +vi.mock("../../auto-reply/commands-registry.js", () => { + const usageCommand = { key: "usage", nativeName: "usage" }; + + return { + buildCommandTextFromArgs: ( + cmd: { nativeName?: string; key: string }, + args?: { values?: Record }, + ) => { + const name = cmd.nativeName ?? cmd.key; + const mode = args?.values?.mode; + return typeof mode === "string" && mode.trim() ? `/${name} ${mode.trim()}` : `/${name}`; + }, + findCommandByNativeName: (name: string) => { + return name.trim().toLowerCase() === "usage" ? usageCommand : undefined; + }, + listNativeCommandSpecsForConfig: () => [ + { + name: "usage", + description: "Usage", + acceptsArgs: true, + args: [], + }, + ], + parseCommandArgs: () => ({ values: {} }), + resolveCommandArgMenu: (params: { + command?: { key?: string }; + args?: { values?: unknown }; + }) => { + if (params.command?.key !== "usage") { + return null; + } + const values = (params.args?.values ?? {}) as Record; + if (typeof values.mode === "string" && values.mode.trim()) { + return null; + } + return { + arg: { name: "mode", description: "mode" }, + choices: [ + { value: "tokens", label: "tokens" }, + { value: "cost", label: "cost" }, + ], + }; + }, + }; +}); + type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise; let registerSlackMonitorSlashCommands: RegisterFn; @@ -82,19 +128,36 @@ function createArgMenusHarness() { } describe("Slack native command argument menus", () => { - it("shows a button menu when required args are omitted", async () => { - const { commands, ctx, account } = createArgMenusHarness(); - await registerCommands(ctx, account); + let harness: ReturnType; + let usageHandler: (args: unknown) => Promise; + let argMenuHandler: (args: unknown) => Promise; - const handler = commands.get("/usage"); - if (!handler) { + beforeAll(async () => { + harness = createArgMenusHarness(); + await registerCommands(harness.ctx, harness.account); + + const usage = harness.commands.get("/usage"); + if (!usage) { throw new Error("Missing /usage handler"); } + usageHandler = usage; + const argMenu = harness.actions.get("openclaw_cmdarg"); + if (!argMenu) { + throw new Error("Missing arg-menu action handler"); + } + argMenuHandler = argMenu; + }); + + beforeEach(() => { + harness.postEphemeral.mockClear(); + }); + + it("shows a button menu when required args are omitted", async () => { const respond = vi.fn().mockResolvedValue(undefined); const ack = vi.fn().mockResolvedValue(undefined); - await handler({ + await usageHandler({ command: { user_id: "U1", user_name: "Ada", @@ -114,16 +177,8 @@ describe("Slack native command argument menus", () => { }); it("dispatches the command when a menu button is clicked", async () => { - const { actions, ctx, account } = createArgMenusHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - const respond = vi.fn().mockResolvedValue(undefined); - await handler({ + await argMenuHandler({ ack: vi.fn().mockResolvedValue(undefined), action: { value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), @@ -142,16 +197,8 @@ describe("Slack native command argument menus", () => { }); it("rejects menu clicks from other users", async () => { - const { actions, ctx, account } = createArgMenusHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - const respond = vi.fn().mockResolvedValue(undefined); - await handler({ + await argMenuHandler({ ack: vi.fn().mockResolvedValue(undefined), action: { value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), @@ -172,21 +219,13 @@ describe("Slack native command argument menus", () => { }); it("falls back to postEphemeral with token when respond is unavailable", async () => { - const { actions, postEphemeral, ctx, account } = createArgMenusHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - await handler({ + await argMenuHandler({ ack: vi.fn().mockResolvedValue(undefined), action: { value: "garbage" }, body: { user: { id: "U1" }, channel: { id: "C1" } }, }); - expect(postEphemeral).toHaveBeenCalledWith( + expect(harness.postEphemeral).toHaveBeenCalledWith( expect.objectContaining({ token: "bot-token", channel: "C1", @@ -196,21 +235,13 @@ describe("Slack native command argument menus", () => { }); it("treats malformed percent-encoding as an invalid button (no throw)", async () => { - const { actions, postEphemeral, ctx, account } = createArgMenusHarness(); - await registerCommands(ctx, account); - - const handler = actions.get("openclaw_cmdarg"); - if (!handler) { - throw new Error("Missing arg-menu action handler"); - } - - await handler({ + await argMenuHandler({ ack: vi.fn().mockResolvedValue(undefined), action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, body: { user: { id: "U1" }, channel: { id: "C1" } }, }); - expect(postEphemeral).toHaveBeenCalledWith( + expect(harness.postEphemeral).toHaveBeenCalledWith( expect.objectContaining({ token: "bot-token", channel: "C1", From f749365b1ce2aaed4eab7297cc4ffb203a37e5e5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:34:31 +0000 Subject: [PATCH 039/178] perf(test): consolidate telegram create bot suites --- ...patterns-match-without-botusername.test.ts | 286 --- ...topic-skill-filters-system-prompts.test.ts | 187 -- ...-all-group-messages-grouppolicy-is.test.ts | 216 -- ...e-callback-query-updates-by-update.test.ts | 98 - ...gram-bot.installs-grammy-throttler.test.ts | 324 --- ...lowfrom-entries-case-insensitively.test.ts | 197 -- ...-case-insensitively-grouppolicy-is.test.ts | 266 --- ...-dms-by-telegram-accountid-binding.test.ts | 340 --- ...ies-without-native-reply-threading.test.ts | 224 -- src/telegram/bot.create-telegram-bot.test.ts | 1997 +++++++++++++++++ 10 files changed, 1997 insertions(+), 2138 deletions(-) delete mode 100644 src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts delete mode 100644 src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts create mode 100644 src/telegram/bot.create-telegram-bot.test.ts 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 deleted file mode 100644 index 5768753b4f9..00000000000 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { - getOnHandler, - getLoadConfigMock, - onSpy, - replySpy, - sendMessageSpy, - setMessageReactionSpy, - setMyCommandsSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -const ORIGINAL_TZ = process.env.TZ; -describe("createTelegramBot", () => { - function resetHarnessSpies() { - onSpy.mockReset(); - replySpy.mockReset(); - sendMessageSpy.mockReset(); - setMessageReactionSpy.mockReset(); - setMyCommandsSpy.mockReset(); - } - - function getMessageHandler() { - createTelegramBot({ token: "tok" }); - return getOnHandler("message") as (ctx: Record) => Promise; - } - - async function dispatchMessage(params: { - message: Record; - me?: Record; - }) { - const handler = getMessageHandler(); - await handler({ - message: params.message, - me: params.me ?? { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - } - - beforeEach(() => { - process.env.TZ = "UTC"; - }); - afterEach(() => { - process.env.TZ = ORIGINAL_TZ; - }); - - // groupPolicy tests - - it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: introduce yourself", - date: 1736380800, - message_id: 1, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.WasMentioned).toBe(true); - expect(payload.SenderName).toBe("Ada"); - expect(payload.SenderId).toBe("9"); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - }); - - it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: hello @alice", - entities: [{ type: "mention", offset: 12, length: 6 }], - date: 1736380800, - message_id: 3, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); - }); - - it("keeps group envelope headers stable (sender identity is separate)", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 42, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - message_id: 2, - from: { - id: 99, - first_name: "Ada", - last_name: "Lovelace", - username: "ada", - }, - }, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SenderName).toBe("Ada Lovelace"); - expect(payload.SenderId).toBe("99"); - expect(payload.SenderUsername).toBe("ada"); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - }); - it("reacts to mention-gated group messages when ackReaction is enabled", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - messages: { - ackReaction: "👀", - ackReactionScope: "group-mentions", - groupChat: { mentionPatterns: ["\\bbert\\b"] }, - }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert hello", - date: 1736380800, - message_id: 123, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [{ type: "emoji", emoji: "👀" }]); - }); - it("clears native commands when disabled", () => { - resetHarnessSpies(); - loadConfig.mockReturnValue({ - commands: { native: false }, - }); - - createTelegramBot({ token: "tok" }); - - expect(setMyCommandsSpy).toHaveBeenCalledWith([]); - }); - it("skips group messages when requireMention is enabled and no mention matches", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "hello everyone", - date: 1736380800, - message_id: 2, - from: { id: 9, first_name: "Ada" }, - }, - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { - resetHarnessSpies(); - - loadConfig.mockReturnValue({ - messages: { groupChat: { mentionPatterns: [] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "hello everyone", - date: 1736380800, - message_id: 3, - from: { id: 9, first_name: "Ada" }, - }, - me: {}, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.WasMentioned).toBe(false); - }); - it("includes reply-to context when a Telegram reply is received", async () => { - resetHarnessSpies(); - - await dispatchMessage({ - message: { - chat: { id: 7, type: "private" }, - text: "Sure, see below", - date: 1736380800, - reply_to_message: { - message_id: 9001, - text: "Can you summarize this?", - from: { first_name: "Ada" }, - }, - }, - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("[Replying to Ada id:9001]"); - expect(payload.Body).toContain("Can you summarize this?"); - expect(payload.ReplyToId).toBe("9001"); - expect(payload.ReplyToBody).toBe("Can you summarize this?"); - expect(payload.ReplyToSender).toBe("Ada"); - }); -}); 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 deleted file mode 100644 index bf7486644bb..00000000000 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - commandSpy, - getOnHandler, - getLoadConfigMock, - makeForumGroupMessageCtx, - onSpy, - replySpy, - sendMessageSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("applies topic skill filters and system prompts", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "-1001234567890": { - requireMention: false, - systemPrompt: "Group prompt", - skills: ["group-skill"], - topics: { - "99": { - skills: [], - systemPrompt: "Topic prompt", - }, - }, - }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler(makeForumGroupMessageCtx({ threadId: 99 })); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); - const opts = replySpy.mock.calls[0][1]; - expect(opts?.skillFilter).toEqual([]); - }); - it("passes message_thread_id to topic replies", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - commandSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "response" }); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler(makeForumGroupMessageCtx({ threadId: 99 })); - - expect(sendMessageSpy).toHaveBeenCalledWith( - "-1001234567890", - expect.any(String), - expect.objectContaining({ message_thread_id: 99 }), - ); - }); - it("threads native command replies inside topics", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - commandSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "response" }); - - loadConfig.mockReturnValue({ - commands: { native: true }, - channels: { - telegram: { - dmPolicy: "open", - allowFrom: ["*"], - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - expect(commandSpy).toHaveBeenCalled(); - const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise; - - await handler({ - ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }), - match: "", - }); - - expect(sendMessageSpy).toHaveBeenCalledWith( - "-1001234567890", - expect.any(String), - expect.objectContaining({ message_thread_id: 99 }), - ); - }); - it("skips tool summaries for native slash commands", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - commandSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockImplementation(async (_ctx, opts) => { - await opts?.onToolResult?.({ text: "tool update" }); - return { text: "final reply" }; - }); - - loadConfig.mockReturnValue({ - commands: { native: true }, - channels: { - telegram: { - dmPolicy: "open", - allowFrom: ["*"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const verboseHandler = commandSpy.mock.calls.find((call) => call[0] === "verbose")?.[1] as - | ((ctx: Record) => Promise) - | undefined; - if (!verboseHandler) { - throw new Error("verbose command handler missing"); - } - - await verboseHandler({ - message: { - chat: { id: 12345, type: "private" }, - from: { id: 12345, username: "testuser" }, - text: "/verbose on", - date: 1736380800, - message_id: 42, - }, - match: "on", - }); - - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply"); - }); - it("dedupes duplicate message updates by update_id", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - const ctx = { - update: { update_id: 111 }, - message: { - chat: { id: 123, type: "private" }, - from: { id: 456, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }; - - await handler(ctx); - await handler(ctx); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); -}); 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 deleted file mode 100644 index 40bee194b61..00000000000 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getLoadConfigMock, - getOnHandler, - onSpy, - replySpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("blocks all group messages when groupPolicy is 'disabled'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "disabled", - allowFrom: ["123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, - text: "@openclaw_bot hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - // Should NOT call getReplyFromConfig because groupPolicy is disabled - expect(replySpy).not.toHaveBeenCalled(); - }); - it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], // Does not include sender 999999 - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 999999, username: "notallowed" }, // Not in allowFrom - text: "@openclaw_bot hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], - groups: { "*": { requireMention: false } }, // Skip mention check - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, // In allowFrom - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("blocks group messages when allowFrom is configured with @username entries (numeric IDs required)", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["@testuser"], // By username - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 12345, username: "testuser" }, // Username matches @testuser - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(0); - }); - it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["telegram:77112533"], - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 77112533, username: "mneves" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["TG:77112533"], - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 77112533, username: "mneves" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows all group messages when groupPolicy is 'open'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 999999, username: "random" }, // Random sender - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); -}); 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 deleted file mode 100644 index 00e60d85118..00000000000 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getOnHandler, - getLoadConfigMock, - onSpy, - replySpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("dedupes duplicate callback_query updates by update_id", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("callback_query") as ( - ctx: Record, - ) => Promise; - - const ctx = { - update: { update_id: 222 }, - callbackQuery: { - id: "cb-1", - data: "ping", - from: { id: 789, username: "testuser" }, - message: { - chat: { id: 123, type: "private" }, - date: 1736380800, - message_id: 9001, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }; - - await handler(ctx); - await handler(ctx); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows distinct callback_query ids without update_id", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("callback_query") as ( - ctx: Record, - ) => Promise; - - await handler({ - callbackQuery: { - id: "cb-1", - data: "ping", - from: { id: 789, username: "testuser" }, - message: { - chat: { id: 123, type: "private" }, - date: 1736380800, - message_id: 9001, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - await handler({ - callbackQuery: { - id: "cb-2", - data: "ping", - from: { id: 789, username: "testuser" }, - message: { - chat: { id: 123, type: "private" }, - date: 1736380800, - message_id: 9001, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({}), - }); - - expect(replySpy).toHaveBeenCalledTimes(2); - }); -}); 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 deleted file mode 100644 index d0f02368840..00000000000 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { - answerCallbackQuerySpy, - botCtorSpy, - getLoadConfigMock, - getLoadWebMediaMock, - getOnHandler, - getReadChannelAllowFromStoreMock, - getUpsertChannelPairingRequestMock, - middlewareUseSpy, - onSpy, - replySpy, - sendAnimationSpy, - sendChatActionSpy, - sendMessageSpy, - sendPhotoSpy, - sequentializeKey, - sequentializeSpy, - setMessageReactionSpy, - setMyCommandsSpy, - throttlerSpy, - useSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; -import { resolveTelegramFetch } from "./fetch.js"; - -const loadConfig = getLoadConfigMock(); -const loadWebMedia = getLoadWebMediaMock(); -const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); -const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); - -const ORIGINAL_TZ = process.env.TZ; - -describe("createTelegramBot", () => { - beforeEach(() => { - process.env.TZ = "UTC"; - loadConfig.mockReturnValue({ - agents: { - defaults: { - envelopeTimezone: "utc", - }, - }, - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - loadWebMedia.mockReset(); - sendAnimationSpy.mockReset(); - sendPhotoSpy.mockReset(); - setMessageReactionSpy.mockReset(); - answerCallbackQuerySpy.mockReset(); - setMyCommandsSpy.mockReset(); - middlewareUseSpy.mockReset(); - sequentializeSpy.mockReset(); - botCtorSpy.mockReset(); - }); - afterEach(() => { - process.env.TZ = ORIGINAL_TZ; - }); - - // groupPolicy tests - - it("installs grammY throttler", () => { - createTelegramBot({ token: "tok" }); - expect(throttlerSpy).toHaveBeenCalledTimes(1); - expect(useSpy).toHaveBeenCalledWith("throttler"); - }); - it("uses wrapped fetch when global fetch is available", () => { - const originalFetch = globalThis.fetch; - const fetchSpy = vi.fn() as unknown as typeof fetch; - globalThis.fetch = fetchSpy; - try { - createTelegramBot({ token: "tok" }); - const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBeTypeOf("function"); - expect(fetchImpl).not.toBe(fetchSpy); - const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) - ?.client?.fetch; - expect(clientFetch).toBeTypeOf("function"); - expect(clientFetch).not.toBe(fetchSpy); - } finally { - globalThis.fetch = originalFetch; - } - }); - it("passes timeoutSeconds even without a custom fetch", () => { - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 60 }, - }, - }); - createTelegramBot({ token: "tok" }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ timeoutSeconds: 60 }), - }), - ); - }); - it("prefers per-account timeoutSeconds overrides", () => { - loadConfig.mockReturnValue({ - channels: { - telegram: { - dmPolicy: "open", - allowFrom: ["*"], - timeoutSeconds: 60, - accounts: { - foo: { timeoutSeconds: 61 }, - }, - }, - }, - }); - createTelegramBot({ token: "tok", accountId: "foo" }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ timeoutSeconds: 61 }), - }), - ); - }); - it("sequentializes updates by chat and thread", () => { - createTelegramBot({ token: "tok" }); - expect(sequentializeSpy).toHaveBeenCalledTimes(1); - expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); - expect(sequentializeKey).toBe(getTelegramSequentialKey); - expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, - }), - ).toBe("telegram:123:topic:9"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, - }), - ).toBe("telegram:123"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123, type: "supergroup", is_forum: true } }, - }), - ).toBe("telegram:123:topic:1"); - expect( - getTelegramSequentialKey({ - update: { message: { chat: { id: 555 } } }, - }), - ).toBe("telegram:555"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123 }, text: "/stop" }, - }), - ).toBe("telegram:123:control"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123 }, text: "/status" }, - }), - ).toBe("telegram:123:control"); - expect( - getTelegramSequentialKey({ - message: { chat: { id: 123 }, text: "stop" }, - }), - ).toBe("telegram:123:control"); - }); - it("routes callback_query payloads as messages and answers callbacks", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - createTelegramBot({ token: "tok" }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( - ctx: Record, - ) => Promise; - expect(callbackHandler).toBeDefined(); - - await callbackHandler({ - callbackQuery: { - id: "cbq-1", - data: "cmd:option_a", - from: { id: 9, first_name: "Ada", username: "ada_bot" }, - message: { - chat: { id: 1234, type: "private" }, - date: 1736380800, - message_id: 10, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.Body).toContain("cmd:option_a"); - expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); - }); - it("wraps inbound message with Telegram envelope", async () => { - const originalTz = process.env.TZ; - process.env.TZ = "Europe/Vienna"; - - try { - onSpy.mockReset(); - replySpy.mockReset(); - - createTelegramBot({ token: "tok" }); - expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - const message = { - chat: { id: 1234, type: "private" }, - text: "hello world", - date: 1736380800, // 2025-01-09T00:00:00Z - from: { - first_name: "Ada", - last_name: "Lovelace", - username: "ada_bot", - }, - }; - await handler({ - message, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp( - `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, - ), - ); - expect(payload.Body).toContain("hello world"); - } finally { - process.env.TZ = originalTz; - } - }); - it("requests pairing by default for unknown DM senders", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest.mockResolvedValue({ - code: "PAIRME12", - created: true, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - text: "hello", - date: 1736380800, - from: { id: 999, username: "random" }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); - const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); - expect(pairingText).toContain("Your Telegram user id: 999"); - expect(pairingText).toContain("Pairing code:"); - expect(pairingText).toContain("PAIRME12"); - expect(pairingText).toContain("openclaw pairing approve telegram PAIRME12"); - expect(pairingText).not.toContain(""); - }); - it("does not resend pairing code when a request is already pending", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { telegram: { dmPolicy: "pairing" } }, - }); - readChannelAllowFromStore.mockResolvedValue([]); - upsertChannelPairingRequest - .mockResolvedValueOnce({ code: "PAIRME12", created: true }) - .mockResolvedValueOnce({ code: "PAIRME12", created: false }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - const message = { - chat: { id: 1234, type: "private" }, - text: "hello", - date: 1736380800, - from: { id: 999, username: "random" }, - }; - - await handler({ - message, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - await handler({ - message: { ...message, text: "hello again" }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - }); - it("triggers typing cue via onReplyStart", async () => { - onSpy.mockReset(); - sendChatActionSpy.mockReset(); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { chat: { id: 42, type: "private" }, text: "hi" }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); - }); -}); 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 deleted file mode 100644 index 8e3717949fe..00000000000 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getLoadConfigMock, - getOnHandler, - makeForumGroupMessageCtx, - onSpy, - replySpy, - sendChatActionSpy, - sendMessageSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, // Matches after stripping tg: prefix - text: "hello from prefixed user", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - // Should call reply because sender ID matches after stripping tg: prefix - expect(replySpy).toHaveBeenCalled(); - }); - it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - groupAllowFrom: [" TG:123456789 "], - groups: { "*": { requireMention: true } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, - text: "/status", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("isolates forum topic sessions and carries thread metadata", async () => { - onSpy.mockReset(); - sendChatActionSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler(makeForumGroupMessageCtx({ threadId: 99 })); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); - expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); - expect(payload.MessageThreadId).toBe(99); - expect(payload.IsForum).toBe(true); - expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { - message_thread_id: 99, - }); - }); - it("falls back to General topic thread id for typing in forums", async () => { - onSpy.mockReset(); - sendChatActionSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler(makeForumGroupMessageCtx({ threadId: undefined })); - - expect(replySpy).toHaveBeenCalledTimes(1); - expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { - message_thread_id: 1, - }); - }); - it("routes General topic replies using thread id 1", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "response" }); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - from: { id: 12345, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number }; - expect(sendParams?.message_thread_id).toBeUndefined(); - }); -}); 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 deleted file mode 100644 index c4d434249ff..00000000000 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getLoadConfigMock, - getOnHandler, - onSpy, - replySpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("blocks @username allowFrom entries when groupPolicy is 'allowlist' (numeric IDs required)", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["@TestUser"], // Uppercase in config - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 12345, username: "testuser" }, // Lowercase in message - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(0); - }); - it("allows direct messages regardless of groupPolicy", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "disabled", // Even with disabled, DMs should work - allowFrom: ["123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123456789, type: "private" }, // Direct message - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: [" TG:123456789 "], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123456789, type: "private" }, // Direct message - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: ["telegram:123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123456789, type: "private" }, - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("matches direct message allowFrom against sender user id when chat id differs", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: ["123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 777777777, type: "private" }, - from: { id: 123456789, username: "testuser" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("falls back to direct message chat id when sender user id is missing", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - allowFrom: ["123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123456789, type: "private" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["*"], // Wildcard allows everyone - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 999999, username: "random" }, // Random sender, but wildcard allows - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["123456789"], - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - // No `from` field (e.g., channel post or anonymous admin) - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "allowlist", - allowFrom: ["telegram:123456789"], // Prefixed format - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: -100123456789, type: "group", title: "Test Group" }, - from: { id: 123456789, username: "testuser" }, // Matches after stripping prefix - text: "hello from prefixed user", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - // Should call reply because sender ID matches after stripping telegram: prefix - expect(replySpy).toHaveBeenCalled(); - }); -}); 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 deleted file mode 100644 index 44f11894953..00000000000 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getLoadConfigMock, - getLoadWebMediaMock, - getOnHandler, - onSpy, - replySpy, - sendAnimationSpy, - sendPhotoSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); -const loadWebMedia = getLoadWebMediaMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("routes DMs by telegram accountId binding", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { - accounts: { - opie: { - botToken: "tok-opie", - dmPolicy: "open", - }, - }, - }, - }, - bindings: [ - { - agentId: "opie", - match: { channel: "telegram", accountId: "opie" }, - }, - ], - }); - - createTelegramBot({ token: "tok", accountId: "opie" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "private" }, - from: { id: 999, username: "testuser" }, - text: "hello", - date: 1736380800, - message_id: 42, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expect(payload.AccountId).toBe("opie"); - expect(payload.SessionKey).toBe("agent:opie:main"); - }); - it("allows per-group requireMention override", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "*": { requireMention: true }, - "123": { requireMention: false }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Dev Chat" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("allows per-topic requireMention override", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { - "*": { requireMention: true }, - "-1001234567890": { - requireMention: true, - topics: { - "99": { requireMention: false }, - }, - }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - text: "hello", - date: 1736380800, - message_thread_id: 99, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("honors groups default when no explicit group override exists", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 456, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("does not block group messages when bot username is unknown", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 789, type: "group", title: "No Me" }, - text: "hello", - date: 1736380800, - }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); - it("routes forum topic messages using parent group binding", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - // Binding specifies the base group ID without topic suffix. - // The fix passes parentPeer to resolveAgentRoute so the binding matches - // even when the actual peer id includes the topic suffix. - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - agents: { - list: [{ id: "forum-agent" }], - }, - bindings: [ - { - agentId: "forum-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890" }, - }, - }, - ], - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - // Message comes from a forum topic (has message_thread_id and is_forum=true) - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - text: "hello from topic", - date: 1736380800, - message_id: 42, - message_thread_id: 99, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - // Should route to forum-agent via parent peer binding inheritance - expect(payload.SessionKey).toContain("agent:forum-agent:"); - }); - - it("prefers specific topic binding over parent group binding", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - // Both a specific topic binding and a parent group binding are configured. - // The specific topic binding should take precedence. - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, - }, - }, - agents: { - list: [{ id: "topic-agent" }, { id: "group-agent" }], - }, - bindings: [ - { - agentId: "topic-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890:topic:99" }, - }, - }, - { - agentId: "group-agent", - match: { - channel: "telegram", - peer: { kind: "group", id: "-1001234567890" }, - }, - }, - ], - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - // Message from topic 99 - should match the specific topic binding - await handler({ - message: { - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum Group", - is_forum: true, - }, - text: "hello from topic 99", - date: 1736380800, - message_id: 42, - message_thread_id: 99, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - // Should route to topic-agent (exact match) not group-agent (parent) - expect(payload.SessionKey).toContain("agent:topic-agent:"); - }); - - it("sends GIF replies as animations", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - - replySpy.mockResolvedValueOnce({ - text: "caption", - mediaUrl: "https://example.com/fun", - }); - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("GIF89a"), - contentType: "image/gif", - fileName: "fun.gif", - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 1234, type: "private" }, - text: "hello world", - date: 1736380800, - message_id: 5, - from: { first_name: "Ada" }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendAnimationSpy).toHaveBeenCalledTimes(1); - expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { - caption: "caption", - parse_mode: "HTML", - reply_to_message_id: undefined, - }); - expect(sendPhotoSpy).not.toHaveBeenCalled(); - }); -}); 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 deleted file mode 100644 index c2ac3b9ed5c..00000000000 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { - getOnHandler, - getLoadConfigMock, - onSpy, - replySpy, - sendMessageSpy, -} from "./bot.create-telegram-bot.test-harness.js"; -import { createTelegramBot } from "./bot.js"; - -const loadConfig = getLoadConfigMock(); - -describe("createTelegramBot", () => { - // groupPolicy tests - - it("sends replies without native reply threading", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "a".repeat(4500) }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 5, type: "private" }, - text: "hi", - date: 1736380800, - message_id: 101, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); - for (const call of sendMessageSpy.mock.calls) { - expect(call[2]?.reply_to_message_id).toBeUndefined(); - } - }); - it("honors replyToMode=first for threaded replies", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ - text: "a".repeat(4500), - replyToId: "101", - }); - - createTelegramBot({ token: "tok", replyToMode: "first" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 5, type: "private" }, - text: "hi", - date: 1736380800, - message_id: 101, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); - const [first, ...rest] = sendMessageSpy.mock.calls; - expect(first?.[2]?.reply_to_message_id).toBe(101); - for (const call of rest) { - expect(call[2]?.reply_to_message_id).toBeUndefined(); - } - }); - it("prefixes final replies with responsePrefix", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ text: "final reply" }); - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - messages: { responsePrefix: "PFX" }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 5, type: "private" }, - text: "hi", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply"); - }); - it("honors replyToMode=all for threaded replies", async () => { - onSpy.mockReset(); - sendMessageSpy.mockReset(); - replySpy.mockReset(); - replySpy.mockResolvedValue({ - text: "a".repeat(4500), - replyToId: "101", - }); - - createTelegramBot({ token: "tok", replyToMode: "all" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 5, type: "private" }, - text: "hi", - date: 1736380800, - message_id: 101, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); - for (const call of sendMessageSpy.mock.calls) { - expect(call[2]?.reply_to_message_id).toBe(101); - } - }); - it("blocks group messages when telegram.groups is set without a wildcard", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groups: { - "123": { requireMention: false }, - }, - }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 456, type: "group", title: "Ops" }, - text: "@openclaw_bot hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("skips group messages without mention when requireMention is enabled", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { groups: { "*": { requireMention: true } } }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Dev Chat" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).not.toHaveBeenCalled(); - }); - it("honors routed group activation from session store", async () => { - onSpy.mockReset(); - replySpy.mockReset(); - const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-")); - const storePath = path.join(storeDir, "sessions.json"); - fs.writeFileSync( - storePath, - JSON.stringify({ - "agent:ops:telegram:group:123": { groupActivation: "always" }, - }), - "utf-8", - ); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, - }, - }, - bindings: [ - { - agentId: "ops", - match: { - channel: "telegram", - peer: { kind: "group", id: "123" }, - }, - }, - ], - session: { store: storePath }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - - await handler({ - message: { - chat: { id: 123, type: "group", title: "Routing" }, - text: "hello", - date: 1736380800, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); - - expect(replySpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts new file mode 100644 index 00000000000..a30d391dd75 --- /dev/null +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -0,0 +1,1997 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; +import { + answerCallbackQuerySpy, + botCtorSpy, + commandSpy, + getLoadConfigMock, + getLoadWebMediaMock, + getOnHandler, + getReadChannelAllowFromStoreMock, + getUpsertChannelPairingRequestMock, + makeForumGroupMessageCtx, + middlewareUseSpy, + onSpy, + replySpy, + sendAnimationSpy, + sendChatActionSpy, + sendMessageSpy, + sendPhotoSpy, + sequentializeKey, + sequentializeSpy, + setMessageReactionSpy, + setMyCommandsSpy, + throttlerSpy, + useSpy, +} from "./bot.create-telegram-bot.test-harness.js"; +import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +const loadConfig = getLoadConfigMock(); +const loadWebMedia = getLoadWebMediaMock(); +const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); +const upsertChannelPairingRequest = getUpsertChannelPairingRequestMock(); + +const ORIGINAL_TZ = process.env.TZ; + +describe("createTelegramBot", () => { + beforeEach(() => { + process.env.TZ = "UTC"; + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + readChannelAllowFromStore.mockReset().mockResolvedValue([]); + upsertChannelPairingRequest + .mockReset() + .mockResolvedValue({ code: "PAIRCODE", created: true } as const); + + // Some tests override reply behavior; keep a stable baseline between tests. + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return undefined; + }); + }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); + + // groupPolicy tests + + it("installs grammY throttler", () => { + createTelegramBot({ token: "tok" }); + expect(throttlerSpy).toHaveBeenCalledTimes(1); + expect(useSpy).toHaveBeenCalledWith("throttler"); + }); + it("uses wrapped fetch when global fetch is available", () => { + const originalFetch = globalThis.fetch; + const fetchSpy = vi.fn() as unknown as typeof fetch; + globalThis.fetch = fetchSpy; + try { + createTelegramBot({ token: "tok" }); + const fetchImpl = resolveTelegramFetch(); + expect(fetchImpl).toBeTypeOf("function"); + expect(fetchImpl).not.toBe(fetchSpy); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch; + expect(clientFetch).toBeTypeOf("function"); + expect(clientFetch).not.toBe(fetchSpy); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("passes timeoutSeconds even without a custom fetch", () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"], timeoutSeconds: 60 }, + }, + }); + createTelegramBot({ token: "tok" }); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ timeoutSeconds: 60 }), + }), + ); + }); + it("prefers per-account timeoutSeconds overrides", () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + timeoutSeconds: 60, + accounts: { + foo: { timeoutSeconds: 61 }, + }, + }, + }, + }); + createTelegramBot({ token: "tok", accountId: "foo" }); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ timeoutSeconds: 61 }), + }), + ); + }); + it("sequentializes updates by chat and thread", () => { + createTelegramBot({ token: "tok" }); + expect(sequentializeSpy).toHaveBeenCalledTimes(1); + expect(middlewareUseSpy).toHaveBeenCalledWith(sequentializeSpy.mock.results[0]?.value); + expect(sequentializeKey).toBe(getTelegramSequentialKey); + expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "private" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123:topic:9"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 }, + }), + ).toBe("telegram:123"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123, type: "supergroup", is_forum: true } }, + }), + ).toBe("telegram:123:topic:1"); + expect( + getTelegramSequentialKey({ + update: { message: { chat: { id: 555 } } }, + }), + ).toBe("telegram:555"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/stop" }, + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "/status" }, + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: { chat: { id: 123 }, text: "stop" }, + }), + ).toBe("telegram:123:control"); + }); + it("routes callback_query payloads as messages and answers callbacks", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-1", + data: "cmd:option_a", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("cmd:option_a"); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); + }); + it("wraps inbound message with Telegram envelope", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "Europe/Vienna"; + + try { + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + expect(onSpy).toHaveBeenCalledWith("message", expect.any(Function)); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, // 2025-01-09T00:00:00Z + from: { + first_name: "Ada", + last_name: "Lovelace", + username: "ada_bot", + }, + }; + await handler({ + message, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp( + `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, + ), + ); + expect(payload.Body).toContain("hello world"); + } finally { + process.env.TZ = originalTz; + } + }); + it("requests pairing by default for unknown DM senders", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest.mockResolvedValue({ + code: "PAIRME12", + created: true, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234); + const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]); + expect(pairingText).toContain("Your Telegram user id: 999"); + expect(pairingText).toContain("Pairing code:"); + expect(pairingText).toContain("PAIRME12"); + expect(pairingText).toContain("openclaw pairing approve telegram PAIRME12"); + expect(pairingText).not.toContain(""); + }); + it("does not resend pairing code when a request is already pending", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { telegram: { dmPolicy: "pairing" } }, + }); + readChannelAllowFromStore.mockResolvedValue([]); + upsertChannelPairingRequest + .mockResolvedValueOnce({ code: "PAIRME12", created: true }) + .mockResolvedValueOnce({ code: "PAIRME12", created: false }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const message = { + chat: { id: 1234, type: "private" }, + text: "hello", + date: 1736380800, + from: { id: 999, username: "random" }, + }; + + await handler({ + message, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + await handler({ + message: { ...message, text: "hello again" }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + }); + it("triggers typing cue via onReplyStart", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { chat: { id: 42, type: "private" }, text: "hi" }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing", undefined); + }); + + it("dedupes duplicate callback_query updates by update_id", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + const ctx = { + update: { update_id: 222 }, + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows distinct callback_query ids without update_id", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await handler({ + callbackQuery: { + id: "cb-1", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + await handler({ + callbackQuery: { + id: "cb-2", + data: "ping", + from: { id: 789, username: "testuser" }, + message: { + chat: { id: 123, type: "private" }, + date: 1736380800, + message_id: 9001, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + expect(replySpy).toHaveBeenCalledTimes(2); + }); + + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "@openclaw_bot hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "notallowed" }, + text: "@openclaw_bot hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("blocks group messages when allowFrom is configured with @username entries (numeric IDs required)", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(0); + }); + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows all group messages when groupPolicy is 'open'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("routes DMs by telegram accountId binding", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + accounts: { + opie: { + botToken: "tok-opie", + dmPolicy: "open", + }, + }, + }, + }, + bindings: [ + { + agentId: "opie", + match: { channel: "telegram", accountId: "opie" }, + }, + ], + }); + + createTelegramBot({ token: "tok", accountId: "opie" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "private" }, + from: { id: 999, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.AccountId).toBe("opie"); + expect(payload.SessionKey).toBe("agent:opie:main"); + }); + it("allows per-group requireMention override", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows per-topic requireMention override", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "*": { requireMention: true }, + "-1001234567890": { + requireMention: true, + topics: { + "99": { requireMention: false }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello", + date: 1736380800, + message_thread_id: 99, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("honors groups default when no explicit group override exists", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("does not block group messages when bot username is unknown", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 789, type: "group", title: "No Me" }, + text: "hello", + date: 1736380800, + }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("routes forum topic messages using parent group binding", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + agents: { + list: [{ id: "forum-agent" }], + }, + bindings: [ + { + agentId: "forum-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello from topic", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain("agent:forum-agent:"); + }); + it("prefers specific topic binding over parent group binding", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + agents: { + list: [{ id: "topic-agent" }, { id: "group-agent" }], + }, + bindings: [ + { + agentId: "topic-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890:topic:99" }, + }, + }, + { + agentId: "group-agent", + match: { + channel: "telegram", + peer: { kind: "group", id: "-1001234567890" }, + }, + }, + ], + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + text: "hello from topic 99", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain("agent:topic-agent:"); + }); + + it("sends GIF replies as animations", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + replySpy.mockResolvedValueOnce({ + text: "caption", + mediaUrl: "https://example.com/fun", + }); + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + parse_mode: "HTML", + reply_to_message_id: undefined, + }); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + }); + + function resetHarnessSpies() { + onSpy.mockReset(); + replySpy.mockReset(); + sendMessageSpy.mockReset(); + setMessageReactionSpy.mockReset(); + setMyCommandsSpy.mockReset(); + } + function getMessageHandler() { + createTelegramBot({ token: "tok" }); + return getOnHandler("message") as (ctx: Record) => Promise; + } + async function dispatchMessage(params: { + message: Record; + me?: Record; + }) { + const handler = getMessageHandler(); + await handler({ + message: params.message, + me: params.me ?? { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + } + + it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(true); + expect(payload.SenderName).toBe("Ada"); + expect(payload.SenderId).toBe("9"); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + }); + it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: hello @alice", + entities: [{ type: "mention", offset: 12, length: 6 }], + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true); + }); + it("keeps group envelope headers stable (sender identity is separate)", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SenderName).toBe("Ada Lovelace"); + expect(payload.SenderId).toBe("99"); + expect(payload.SenderUsername).toBe("ada"); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + }); + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + messages: { + ackReaction: "👀", + ackReactionScope: "group-mentions", + groupChat: { mentionPatterns: ["\\bbert\\b"] }, + }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert hello", + date: 1736380800, + message_id: 123, + from: { id: 9, first_name: "Ada" }, + }, + }); + + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [{ type: "emoji", emoji: "👀" }]); + }); + it("clears native commands when disabled", () => { + resetHarnessSpies(); + loadConfig.mockReturnValue({ + commands: { native: false }, + }); + + createTelegramBot({ token: "tok" }); + + expect(setMyCommandsSpy).toHaveBeenCalledWith([]); + }); + it("skips group messages when requireMention is enabled and no mention matches", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 2, + from: { id: 9, first_name: "Ada" }, + }, + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { + resetHarnessSpies(); + + loadConfig.mockReturnValue({ + messages: { groupChat: { mentionPatterns: [] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + }); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: {}, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(false); + }); + it("includes reply-to context when a Telegram reply is received", async () => { + resetHarnessSpies(); + + await dispatchMessage({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + }, + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Replying to Ada id:9001]"); + expect(payload.Body).toContain("Can you summarize this?"); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("Can you summarize this?"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalled(); + }); + it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("allows control commands with TG-prefixed groupAllowFrom entries", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + groupAllowFrom: [" TG:123456789 "], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "/status", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("isolates forum topic sessions and carries thread metadata", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler(makeForumGroupMessageCtx({ threadId: 99 })); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); + expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); + expect(payload.MessageThreadId).toBe(99); + expect(payload.IsForum).toBe(true); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 99, + }); + }); + it("falls back to General topic thread id for typing in forums", async () => { + onSpy.mockReset(); + sendChatActionSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler(makeForumGroupMessageCtx({ threadId: undefined })); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", { + message_thread_id: 1, + }); + }); + it("routes General topic replies using thread id 1", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + const sendParams = sendMessageSpy.mock.calls[0]?.[2] as { message_thread_id?: number }; + expect(sendParams?.message_thread_id).toBeUndefined(); + }); + + it("blocks @username allowFrom entries when groupPolicy is 'allowlist' (numeric IDs required)", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(0); + }); + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: [" TG:123456789 "], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: ["telegram:123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("matches direct message allowFrom against sender user id when chat id differs", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 777777777, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("falls back to direct message chat id when sender user id is missing", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalled(); + }); + + it("sends replies without native reply threading", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "a".repeat(4500) }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect( + (call[2] as { reply_to_message_id?: number } | undefined)?.reply_to_message_id, + ).toBeUndefined(); + } + }); + it("honors replyToMode=first for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "first" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + const [first, ...rest] = sendMessageSpy.mock.calls; + expect((first?.[2] as { reply_to_message_id?: number } | undefined)?.reply_to_message_id).toBe( + 101, + ); + for (const call of rest) { + expect( + (call[2] as { reply_to_message_id?: number } | undefined)?.reply_to_message_id, + ).toBeUndefined(); + } + }); + it("prefixes final replies with responsePrefix", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "final reply" }); + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + messages: { responsePrefix: "PFX" }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0][1]).toBe("PFX final reply"); + }); + it("honors replyToMode=all for threaded replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ + text: "a".repeat(4500), + replyToId: "101", + }); + + createTelegramBot({ token: "tok", replyToMode: "all" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + await handler({ + message: { + chat: { id: 5, type: "private" }, + text: "hi", + date: 1736380800, + message_id: 101, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); + for (const call of sendMessageSpy.mock.calls) { + expect((call[2] as { reply_to_message_id?: number } | undefined)?.reply_to_message_id).toBe( + 101, + ); + } + }); + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@openclaw_bot hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("skips group messages without mention when requireMention is enabled", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + loadConfig.mockReturnValue({ + channels: { + telegram: { groups: { "*": { requireMention: true } } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Dev Chat" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("honors routed group activation from session store", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-")); + const storePath = path.join(storeDir, "sessions.json"); + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:ops:telegram:group:123": { groupActivation: "always" }, + }), + "utf-8", + ); + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + bindings: [ + { + agentId: "ops", + match: { + channel: "telegram", + peer: { kind: "group", id: "123" }, + }, + }, + ], + session: { store: storePath }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 123, type: "group", title: "Routing" }, + text: "hello", + date: 1736380800, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("applies topic skill filters and system prompts", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-1001234567890": { + requireMention: false, + systemPrompt: "Group prompt", + skills: ["group-skill"], + topics: { + "99": { + skills: [], + systemPrompt: "Topic prompt", + }, + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler(makeForumGroupMessageCtx({ threadId: 99 })); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); + const opts = replySpy.mock.calls[0][1] as { skillFilter?: unknown }; + expect(opts?.skillFilter).toEqual([]); + }); + it("passes message_thread_id to topic replies", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler(makeForumGroupMessageCtx({ threadId: 99 })); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + it("threads native command replies inside topics", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockResolvedValue({ text: "response" }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + expect(commandSpy).toHaveBeenCalled(); + const handler = commandSpy.mock.calls[0][1] as (ctx: Record) => Promise; + + await handler({ + ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }), + match: "", + }); + + expect(sendMessageSpy).toHaveBeenCalledWith( + "-1001234567890", + expect.any(String), + expect.objectContaining({ message_thread_id: 99 }), + ); + }); + it("skips tool summaries for native slash commands", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + commandSpy.mockReset(); + replySpy.mockReset(); + replySpy.mockImplementation(async (_ctx, opts) => { + await opts?.onToolResult?.({ text: "tool update" }); + return { text: "final reply" }; + }); + + loadConfig.mockReturnValue({ + commands: { native: true }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const verboseHandler = commandSpy.mock.calls.find((call) => call[0] === "verbose")?.[1] as + | ((ctx: Record) => Promise) + | undefined; + if (!verboseHandler) { + throw new Error("verbose command handler missing"); + } + + await verboseHandler({ + message: { + chat: { id: 12345, type: "private" }, + from: { id: 12345, username: "testuser" }, + text: "/verbose on", + date: 1736380800, + message_id: 42, + }, + match: "on", + }); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("final reply"); + }); + it("dedupes duplicate message updates by update_id", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { dmPolicy: "open", allowFrom: ["*"] }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + const ctx = { + update: { update_id: 111 }, + message: { + chat: { id: 123, type: "private" }, + from: { id: 456, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }; + + await handler(ctx); + await handler(ctx); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); +}); From ce922915abd1ba9513bb282fa3df541683cebaeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:43:53 +0000 Subject: [PATCH 040/178] perf(test): consolidate telegram send suites --- src/telegram/send.caption-split.test.ts | 357 ------------ src/telegram/send.edit-message.test.ts | 110 ---- src/telegram/send.poll.test.ts | 63 --- ...-thread-params-plain-text-fallback.test.ts | 147 ----- src/telegram/send.test-harness.ts | 6 + ...fined-empty-input.test.ts => send.test.ts} | 525 +++++++++++++++++- src/telegram/send.video-note.test.ts | 174 ------ 7 files changed, 529 insertions(+), 853 deletions(-) delete mode 100644 src/telegram/send.caption-split.test.ts delete mode 100644 src/telegram/send.edit-message.test.ts delete mode 100644 src/telegram/send.poll.test.ts delete mode 100644 src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts rename src/telegram/{send.returns-undefined-empty-input.test.ts => send.test.ts} (61%) delete mode 100644 src/telegram/send.video-note.test.ts diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts deleted file mode 100644 index 564f3138880..00000000000 --- a/src/telegram/send.caption-split.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - getTelegramSendTestMocks, - importTelegramSendModule, - installTelegramSendTestHooks, -} from "./send.test-harness.js"; - -installTelegramSendTestHooks(); - -const { loadWebMedia } = getTelegramSendTestMocks(); -const { sendMessageTelegram } = await importTelegramSendModule(); - -describe("sendMessageTelegram caption splitting", () => { - it("splits long captions into media + text messages when text exceeds 1024 chars", async () => { - const chatId = "123"; - // Generate text longer than 1024 characters - const longText = "A".repeat(1100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 70, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 71, - chat: { id: chatId }, - }); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - const res = await sendMessageTelegram(chatId, longText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - }); - - // Media should be sent first without caption - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: undefined, - }); - // Then text sent as separate message (HTML formatting) - expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { - parse_mode: "HTML", - }); - // Returns the text message ID (the "main" content) - expect(res.messageId).toBe("71"); - }); - - it("uses caption when text is within 1024 char limit", async () => { - const chatId = "123"; - // Text exactly at 1024 characters should still use caption - const shortText = "B".repeat(1024); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 72, - chat: { id: chatId }, - }); - const sendMessage = vi.fn(); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - const res = await sendMessageTelegram(chatId, shortText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - }); - - // Caption should be included with media - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: shortText, - parse_mode: "HTML", - }); - // No separate text message needed - expect(sendMessage).not.toHaveBeenCalled(); - expect(res.messageId).toBe("72"); - }); - - it("renders markdown in media captions", async () => { - const chatId = "123"; - const caption = "hi **boss**"; - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 90, - chat: { id: chatId }, - }); - const api = { sendPhoto } as unknown as { - sendPhoto: typeof sendPhoto; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, caption, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - }); - - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: "hi boss", - parse_mode: "HTML", - }); - }); - - it("preserves thread params when splitting long captions", async () => { - const chatId = "-1001234567890"; - const longText = "C".repeat(1100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 73, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 74, - chat: { id: chatId }, - }); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, longText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - messageThreadId: 271, - replyToMessageId: 500, - }); - - // Media sent with thread params but no caption - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: undefined, - message_thread_id: 271, - reply_to_message_id: 500, - }); - // Text message also includes thread params (HTML formatting) - expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { - parse_mode: "HTML", - message_thread_id: 271, - reply_to_message_id: 500, - }); - }); - - it("puts reply_markup only on follow-up text when splitting", async () => { - const chatId = "123"; - const longText = "D".repeat(1100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 75, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 76, - chat: { id: chatId }, - }); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, longText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - buttons: [[{ text: "Click me", callback_data: "action:click" }]], - }); - - // Media sent WITHOUT reply_markup - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: undefined, - }); - // Follow-up text has the reply_markup - expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { - parse_mode: "HTML", - reply_markup: { - inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]], - }, - }); - }); - - it("includes thread params and reply_markup on follow-up text when splitting", async () => { - const chatId = "-1001234567890"; - const longText = "F".repeat(1100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 78, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 79, - chat: { id: chatId }, - }); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, longText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - messageThreadId: 271, - replyToMessageId: 500, - buttons: [[{ text: "Click me", callback_data: "action:click" }]], - }); - - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: undefined, - message_thread_id: 271, - reply_to_message_id: 500, - }); - expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { - parse_mode: "HTML", - message_thread_id: 271, - reply_to_message_id: 500, - reply_markup: { - inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]], - }, - }); - }); - - it("wraps chat-not-found errors from follow-up message", async () => { - const chatId = "123"; - const longText = "G".repeat(1100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 80, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found")); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await expect( - sendMessageTelegram(chatId, longText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - }), - ).rejects.toThrow(/Telegram send failed: chat not found \(chat_id=123\)\./); - }); - - it("does not send follow-up text when caption is empty", async () => { - const chatId = "123"; - const emptyText = " "; - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 81, - chat: { id: chatId }, - }); - const sendMessage = vi.fn(); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - const res = await sendMessageTelegram(chatId, emptyText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - }); - - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: undefined, - }); - expect(sendMessage).not.toHaveBeenCalled(); - expect(res.messageId).toBe("81"); - }); - - it("keeps reply_markup on media when not splitting", async () => { - const chatId = "123"; - const shortText = "E".repeat(100); - - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 77, - chat: { id: chatId }, - }); - const sendMessage = vi.fn(); - const api = { sendPhoto, sendMessage } as unknown as { - sendPhoto: typeof sendPhoto; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, shortText, { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - buttons: [[{ text: "Click me", callback_data: "action:click" }]], - }); - - // Media sent WITH reply_markup when not splitting - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: shortText, - parse_mode: "HTML", - reply_markup: { - inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]], - }, - }); - expect(sendMessage).not.toHaveBeenCalled(); - }); -}); diff --git a/src/telegram/send.edit-message.test.ts b/src/telegram/send.edit-message.test.ts deleted file mode 100644 index 8ee5a3e56a6..00000000000 --- a/src/telegram/send.edit-message.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { botApi, botCtorSpy } = vi.hoisted(() => ({ - botApi: { - editMessageText: vi.fn(), - }, - botCtorSpy: vi.fn(), -})); - -vi.mock("grammy", () => ({ - Bot: class { - api = botApi; - constructor(public token: string) { - botCtorSpy(token); - } - }, - InputFile: class {}, -})); - -import { editMessageTelegram } from "./send.js"; - -describe("editMessageTelegram", () => { - beforeEach(() => { - botApi.editMessageText.mockReset(); - botCtorSpy.mockReset(); - }); - - it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { - botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); - - await editMessageTelegram("123", 1, "hi", { - token: "tok", - cfg: {}, - }); - - expect(botCtorSpy).toHaveBeenCalledWith("tok"); - expect(botApi.editMessageText).toHaveBeenCalledTimes(1); - const call = botApi.editMessageText.mock.calls[0] ?? []; - const params = call[3] as Record; - expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" })); - expect(params).not.toHaveProperty("reply_markup"); - }); - - it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { - botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); - - await editMessageTelegram("123", 1, "hi", { - token: "tok", - cfg: {}, - buttons: [], - }); - - expect(botApi.editMessageText).toHaveBeenCalledTimes(1); - const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; - expect(params).toEqual( - expect.objectContaining({ - parse_mode: "HTML", - reply_markup: { inline_keyboard: [] }, - }), - ); - }); - - it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => { - botApi.editMessageText - .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) - .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); - - await editMessageTelegram("123", 1, " html", { - token: "tok", - cfg: {}, - buttons: [], - }); - - expect(botApi.editMessageText).toHaveBeenCalledTimes(2); - - const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; - expect(firstParams).toEqual( - expect.objectContaining({ - parse_mode: "HTML", - reply_markup: { inline_keyboard: [] }, - }), - ); - - const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record; - expect(secondParams).toEqual( - expect.objectContaining({ - reply_markup: { inline_keyboard: [] }, - }), - ); - }); - - it("disables link previews when linkPreview is false", async () => { - botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); - - await editMessageTelegram("123", 1, "https://example.com", { - token: "tok", - cfg: {}, - linkPreview: false, - }); - - expect(botApi.editMessageText).toHaveBeenCalledTimes(1); - const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; - expect(params).toEqual( - expect.objectContaining({ - parse_mode: "HTML", - link_preview_options: { is_disabled: true }, - }), - ); - }); -}); diff --git a/src/telegram/send.poll.test.ts b/src/telegram/send.poll.test.ts deleted file mode 100644 index 31bc1dc909a..00000000000 --- a/src/telegram/send.poll.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Bot } from "grammy"; -import { describe, expect, it, vi } from "vitest"; -import { sendPollTelegram } from "./send.js"; - -describe("sendPollTelegram", () => { - it("maps durationSeconds to open_period", async () => { - const api = { - sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })), - }; - - const res = await sendPollTelegram( - "123", - { question: " Q ", options: [" A ", "B "], durationSeconds: 60 }, - { token: "t", api: api as unknown as Bot["api"] }, - ); - - expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" }); - expect(api.sendPoll).toHaveBeenCalledTimes(1); - expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123"); - expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q"); - expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]); - expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ open_period: 60 }); - }); - - it("retries without message_thread_id on thread-not-found", async () => { - const api = { - sendPoll: vi.fn( - async (_chatId: string, _question: string, _options: string[], params: unknown) => { - const p = params as { message_thread_id?: unknown } | undefined; - if (p?.message_thread_id) { - throw new Error("400: Bad Request: message thread not found"); - } - return { message_id: 1, chat: { id: 2 }, poll: { id: "p2" } }; - }, - ), - }; - - const res = await sendPollTelegram( - "123", - { question: "Q", options: ["A", "B"] }, - { token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 }, - ); - - expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" }); - expect(api.sendPoll).toHaveBeenCalledTimes(2); - expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ message_thread_id: 99 }); - expect(api.sendPoll.mock.calls[1]?.[3]?.message_thread_id).toBeUndefined(); - }); - - it("rejects durationHours for Telegram polls", async () => { - const api = { sendPoll: vi.fn() }; - - await expect( - sendPollTelegram( - "123", - { question: "Q", options: ["A", "B"], durationHours: 1 }, - { token: "t", api: api as unknown as Bot["api"] }, - ), - ).rejects.toThrow(/durationHours is not supported/i); - - expect(api.sendPoll).not.toHaveBeenCalled(); - }); -}); diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts deleted file mode 100644 index 2f9e7d05710..00000000000 --- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const { botApi, botCtorSpy } = vi.hoisted(() => ({ - botApi: { - sendMessage: vi.fn(), - setMessageReaction: vi.fn(), - }, - botCtorSpy: vi.fn(), -})); - -const { loadWebMedia } = vi.hoisted(() => ({ - loadWebMedia: vi.fn(), -})); - -vi.mock("../web/media.js", () => ({ - loadWebMedia, -})); - -vi.mock("grammy", () => ({ - Bot: class { - api = botApi; - catch = vi.fn(); - constructor( - public token: string, - public options?: { client?: { fetch?: typeof fetch } }, - ) { - botCtorSpy(token, options); - } - }, - InputFile: class {}, -})); - -import { reactMessageTelegram, sendMessageTelegram } from "./send.js"; - -describe("buildInlineKeyboard", () => { - it("preserves thread params in plain text fallback", async () => { - const chatId = "-1001234567890"; - const parseErr = new Error( - "400: Bad Request: can't parse entities: Can't find end of the entity", - ); - const sendMessage = vi - .fn() - .mockRejectedValueOnce(parseErr) - .mockResolvedValueOnce({ - message_id: 60, - chat: { id: chatId }, - }); - const api = { sendMessage } as unknown as { - sendMessage: typeof sendMessage; - }; - - const res = await sendMessageTelegram(chatId, "_bad markdown_", { - token: "tok", - api, - messageThreadId: 271, - replyToMessageId: 100, - }); - - // First call: with HTML + thread params - expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "bad markdown", { - parse_mode: "HTML", - message_thread_id: 271, - reply_to_message_id: 100, - }); - // Second call: plain text BUT still with thread params (critical!) - expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", { - message_thread_id: 271, - reply_to_message_id: 100, - }); - expect(res.messageId).toBe("60"); - }); - - it("includes thread params in media messages", async () => { - const chatId = "-1001234567890"; - const sendPhoto = vi.fn().mockResolvedValue({ - message_id: 58, - chat: { id: chatId }, - }); - const api = { sendPhoto } as unknown as { - sendPhoto: typeof sendPhoto; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-image"), - contentType: "image/jpeg", - fileName: "photo.jpg", - }); - - await sendMessageTelegram(chatId, "photo in topic", { - token: "tok", - api, - mediaUrl: "https://example.com/photo.jpg", - messageThreadId: 99, - }); - - expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: "photo in topic", - parse_mode: "HTML", - message_thread_id: 99, - }); - }); -}); - -describe("reactMessageTelegram", () => { - it("sends emoji reactions", async () => { - const setMessageReaction = vi.fn().mockResolvedValue(undefined); - const api = { setMessageReaction } as unknown as { - setMessageReaction: typeof setMessageReaction; - }; - - await reactMessageTelegram("telegram:123", "456", "✅", { - token: "tok", - api, - }); - - expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [{ type: "emoji", emoji: "✅" }]); - }); - - it("removes reactions when emoji is empty", async () => { - const setMessageReaction = vi.fn().mockResolvedValue(undefined); - const api = { setMessageReaction } as unknown as { - setMessageReaction: typeof setMessageReaction; - }; - - await reactMessageTelegram("123", 456, "", { - token: "tok", - api, - }); - - expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); - }); - - it("removes reactions when remove flag is set", async () => { - const setMessageReaction = vi.fn().mockResolvedValue(undefined); - const api = { setMessageReaction } as unknown as { - setMessageReaction: typeof setMessageReaction; - }; - - await reactMessageTelegram("123", 456, "✅", { - token: "tok", - api, - remove: true, - }); - - expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); - }); -}); diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts index 528ec2fb5fa..f211d39368d 100644 --- a/src/telegram/send.test-harness.ts +++ b/src/telegram/send.test-harness.ts @@ -3,10 +3,16 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { + deleteMessage: vi.fn(), + editMessageText: vi.fn(), sendMessage: vi.fn(), + sendPoll: vi.fn(), sendPhoto: vi.fn(), + sendVoice: vi.fn(), + sendAudio: vi.fn(), sendVideo: vi.fn(), sendVideoNote: vi.fn(), + sendAnimation: vi.fn(), setMessageReaction: vi.fn(), sendSticker: vi.fn(), }, diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.test.ts similarity index 61% rename from src/telegram/send.returns-undefined-empty-input.test.ts rename to src/telegram/send.test.ts index d0d9eb7cd47..23b87433aaf 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.test.ts @@ -1,3 +1,4 @@ +import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getTelegramSendTestMocks, @@ -8,8 +9,14 @@ import { installTelegramSendTestHooks(); const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks(); -const { buildInlineKeyboard, sendMessageTelegram, sendStickerTelegram } = - await importTelegramSendModule(); +const { + buildInlineKeyboard, + editMessageTelegram, + reactMessageTelegram, + sendMessageTelegram, + sendPollTelegram, + sendStickerTelegram, +} = await importTelegramSendModule(); describe("buildInlineKeyboard", () => { it("returns undefined for empty input", () => { @@ -229,6 +236,322 @@ describe("sendMessageTelegram", () => { ); }); + it("preserves thread params in plain text fallback", async () => { + const chatId = "-1001234567890"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ + message_id: 60, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + const res = await sendMessageTelegram(chatId, "_bad markdown_", { + token: "tok", + api, + messageThreadId: 271, + replyToMessageId: 100, + }); + + expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "bad markdown", { + parse_mode: "HTML", + message_thread_id: 271, + reply_to_message_id: 100, + }); + expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", { + message_thread_id: 271, + reply_to_message_id: 100, + }); + expect(res.messageId).toBe("60"); + }); + + it("includes thread params in media messages", async () => { + const chatId = "-1001234567890"; + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 58, + chat: { id: chatId }, + }); + const api = { sendPhoto } as unknown as { + sendPhoto: typeof sendPhoto; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, "photo in topic", { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + messageThreadId: 99, + }); + + expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "photo in topic", + parse_mode: "HTML", + message_thread_id: 99, + }); + }); + + it("splits long captions into media + text messages when text exceeds 1024 chars", async () => { + const chatId = "123"; + const longText = "A".repeat(1100); + + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 70, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 71, + chat: { id: chatId }, + }); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + const res = await sendMessageTelegram(chatId, longText, { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: undefined, + }); + expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { + parse_mode: "HTML", + }); + expect(res.messageId).toBe("71"); + }); + + it("uses caption when text is within 1024 char limit", async () => { + const chatId = "123"; + const shortText = "B".repeat(1024); + + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 72, + chat: { id: chatId }, + }); + const sendMessage = vi.fn(); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + const res = await sendMessageTelegram(chatId, shortText, { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: shortText, + parse_mode: "HTML", + }); + expect(sendMessage).not.toHaveBeenCalled(); + expect(res.messageId).toBe("72"); + }); + + it("renders markdown in media captions", async () => { + const chatId = "123"; + const caption = "hi **boss**"; + + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 90, + chat: { id: chatId }, + }); + const api = { sendPhoto } as unknown as { + sendPhoto: typeof sendPhoto; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, caption, { + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "hi boss", + parse_mode: "HTML", + }); + }); + + it("sends video as video note when asVideoNote is true", async () => { + const chatId = "123"; + const text = "ignored caption context"; + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 101, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 102, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + const res = await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + }); + + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + }); + expect(res.messageId).toBe("102"); + }); + + it("sends regular video when asVideoNote is false", async () => { + const chatId = "123"; + const text = "my caption"; + + const sendVideo = vi.fn().mockResolvedValue({ + message_id: 201, + chat: { id: chatId }, + }); + const api = { sendVideo } as unknown as { + sendVideo: typeof sendVideo; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + const res = await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: false, + }); + + expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: expect.any(String), + parse_mode: "HTML", + }); + expect(res.messageId).toBe("201"); + }); + + it("adds reply_markup to separate text message for video notes", async () => { + const chatId = "123"; + const text = "Check this out"; + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 301, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 302, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + buttons: [[{ text: "Btn", callback_data: "dat" }]], + }); + + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + reply_markup: { + inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]], + }, + }); + }); + + it("threads video note and text message correctly", async () => { + const chatId = "123"; + const text = "Threaded reply"; + + const sendVideoNote = vi.fn().mockResolvedValue({ + message_id: 401, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 402, + chat: { id: chatId }, + }); + const api = { sendVideoNote, sendMessage } as unknown as { + sendVideoNote: typeof sendVideoNote; + sendMessage: typeof sendMessage; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("fake-video"), + contentType: "video/mp4", + fileName: "video.mp4", + }); + + await sendMessageTelegram(chatId, text, { + token: "tok", + api, + mediaUrl: "https://example.com/video.mp4", + asVideoNote: true, + replyToMessageId: 999, + }); + + expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { + reply_to_message_id: 999, + }); + expect(sendMessage).toHaveBeenCalledWith(chatId, text, { + parse_mode: "HTML", + reply_to_message_id: 999, + }); + }); + it("retries on transient errors with retry_after", async () => { vi.useFakeTimers(); const chatId = "123"; @@ -647,6 +970,51 @@ describe("sendMessageTelegram", () => { }); }); +describe("reactMessageTelegram", () => { + it("sends emoji reactions", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("telegram:123", "456", "✅", { + token: "tok", + api, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [{ type: "emoji", emoji: "✅" }]); + }); + + it("removes reactions when emoji is empty", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("123", 456, "", { + token: "tok", + api, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); + }); + + it("removes reactions when remove flag is set", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("123", 456, "✅", { + token: "tok", + api, + remove: true, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); + }); +}); + describe("sendStickerTelegram", () => { beforeEach(() => { loadConfig.mockReturnValue({}); @@ -849,3 +1217,156 @@ describe("sendStickerTelegram", () => { expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined); }); }); + +describe("editMessageTelegram", () => { + beforeEach(() => { + botApi.editMessageText.mockReset(); + botCtorSpy.mockReset(); + }); + + it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + }); + + expect(botCtorSpy).toHaveBeenCalledTimes(1); + expect(botCtorSpy.mock.calls[0]?.[0]).toBe("tok"); + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" })); + expect(params).not.toHaveProperty("reply_markup"); + }); + + it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(params).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + }); + + it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => { + botApi.editMessageText + .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, " html", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(2); + + const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(firstParams).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + + const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record; + expect(secondParams).toEqual( + expect.objectContaining({ + reply_markup: { inline_keyboard: [] }, + }), + ); + }); + + it("disables link previews when linkPreview is false", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "https://example.com", { + token: "tok", + cfg: {}, + linkPreview: false, + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(params).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + link_preview_options: { is_disabled: true }, + }), + ); + }); +}); + +describe("sendPollTelegram", () => { + it("maps durationSeconds to open_period", async () => { + const api = { + sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })), + }; + + const res = await sendPollTelegram( + "123", + { question: " Q ", options: [" A ", "B "], durationSeconds: 60 }, + { token: "t", api: api as unknown as Bot["api"] }, + ); + + expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" }); + expect(api.sendPoll).toHaveBeenCalledTimes(1); + expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123"); + expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q"); + expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]); + expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ open_period: 60 }); + }); + + it("retries without message_thread_id on thread-not-found", async () => { + const api = { + sendPoll: vi.fn( + async (_chatId: string, _question: string, _options: string[], params: unknown) => { + const p = params as { message_thread_id?: unknown } | undefined; + if (p?.message_thread_id) { + throw new Error("400: Bad Request: message thread not found"); + } + return { message_id: 1, chat: { id: 2 }, poll: { id: "p2" } }; + }, + ), + }; + + const res = await sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"] }, + { token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 }, + ); + + expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" }); + expect(api.sendPoll).toHaveBeenCalledTimes(2); + expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ message_thread_id: 99 }); + expect( + (api.sendPoll.mock.calls[1]?.[3] as { message_thread_id?: unknown } | undefined) + ?.message_thread_id, + ).toBeUndefined(); + }); + + it("rejects durationHours for Telegram polls", async () => { + const api = { sendPoll: vi.fn() }; + + await expect( + sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"], durationHours: 1 }, + { token: "t", api: api as unknown as Bot["api"] }, + ), + ).rejects.toThrow(/durationHours is not supported/i); + + expect(api.sendPoll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/send.video-note.test.ts b/src/telegram/send.video-note.test.ts deleted file mode 100644 index 6924bad2170..00000000000 --- a/src/telegram/send.video-note.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - getTelegramSendTestMocks, - importTelegramSendModule, - installTelegramSendTestHooks, -} from "./send.test-harness.js"; - -installTelegramSendTestHooks(); - -const { loadWebMedia } = getTelegramSendTestMocks(); -const { sendMessageTelegram } = await importTelegramSendModule(); - -describe("sendMessageTelegram video notes", () => { - it("sends video as video note when asVideoNote is true", async () => { - const chatId = "123"; - const text = "ignored caption context"; // Should be sent separately - - const sendVideoNote = vi.fn().mockResolvedValue({ - message_id: 101, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 102, - chat: { id: chatId }, - }); - const api = { sendVideoNote, sendMessage } as unknown as { - sendVideoNote: typeof sendVideoNote; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-video"), - contentType: "video/mp4", - fileName: "video.mp4", - }); - - const res = await sendMessageTelegram(chatId, text, { - token: "tok", - api, - mediaUrl: "https://example.com/video.mp4", - asVideoNote: true, - }); - - // Video note sent WITHOUT caption (video notes cannot have captions) - expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); - - // Text sent as separate message - expect(sendMessage).toHaveBeenCalledWith(chatId, text, { - parse_mode: "HTML", - }); - - // Returns the text message ID as it is the "main" content with text - expect(res.messageId).toBe("102"); - }); - - it("sends regular video when asVideoNote is false", async () => { - const chatId = "123"; - const text = "my caption"; - - const sendVideo = vi.fn().mockResolvedValue({ - message_id: 201, - chat: { id: chatId }, - }); - const api = { sendVideo } as unknown as { - sendVideo: typeof sendVideo; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-video"), - contentType: "video/mp4", - fileName: "video.mp4", - }); - - const res = await sendMessageTelegram(chatId, text, { - token: "tok", - api, - mediaUrl: "https://example.com/video.mp4", - asVideoNote: false, - }); - - // Regular video sent WITH caption - expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: expect.any(String), - parse_mode: "HTML", - }); - expect(res.messageId).toBe("201"); - }); - - it("adds reply_markup to separate text message for video notes", async () => { - const chatId = "123"; - const text = "Check this out"; - - const sendVideoNote = vi.fn().mockResolvedValue({ - message_id: 301, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 302, - chat: { id: chatId }, - }); - const api = { sendVideoNote, sendMessage } as unknown as { - sendVideoNote: typeof sendVideoNote; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-video"), - contentType: "video/mp4", - fileName: "video.mp4", - }); - - await sendMessageTelegram(chatId, text, { - token: "tok", - api, - mediaUrl: "https://example.com/video.mp4", - asVideoNote: true, - buttons: [[{ text: "Btn", callback_data: "dat" }]], - }); - - // Video note sent WITHOUT reply_markup (it goes to text) - expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); - - // Text message gets reply markup - expect(sendMessage).toHaveBeenCalledWith(chatId, text, { - parse_mode: "HTML", - reply_markup: { - inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]], - }, - }); - }); - - it("threads video note and text message correctly", async () => { - const chatId = "123"; - const text = "Threaded reply"; - - const sendVideoNote = vi.fn().mockResolvedValue({ - message_id: 401, - chat: { id: chatId }, - }); - const sendMessage = vi.fn().mockResolvedValue({ - message_id: 402, - chat: { id: chatId }, - }); - const api = { sendVideoNote, sendMessage } as unknown as { - sendVideoNote: typeof sendVideoNote; - sendMessage: typeof sendMessage; - }; - - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("fake-video"), - contentType: "video/mp4", - fileName: "video.mp4", - }); - - await sendMessageTelegram(chatId, text, { - token: "tok", - api, - mediaUrl: "https://example.com/video.mp4", - asVideoNote: true, - replyToMessageId: 999, - }); - - // Video note threaded - expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { - reply_to_message_id: 999, - }); - - // Text threaded - expect(sendMessage).toHaveBeenCalledWith(chatId, text, { - parse_mode: "HTML", - reply_to_message_id: 999, - }); - }); -}); From 023091ded3563f9cbcffc1ee0a2788e4129f8be6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 20:46:36 +0000 Subject: [PATCH 041/178] perf(test): consolidate slack tool-result suites --- ...es-thread-replies-replytoid-is-set.test.ts | 153 ------- ...ix.test.ts => monitor.tool-result.test.ts} | 382 +++++++++++++++++- ...p-level-replies-replytomode-is-all.test.ts | 272 ------------- 3 files changed, 381 insertions(+), 426 deletions(-) delete mode 100644 src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts rename src/slack/{monitor.tool-result.sends-tool-summaries-responseprefix.test.ts => monitor.tool-result.test.ts} (52%) delete mode 100644 src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts diff --git a/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts b/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts deleted file mode 100644 index 4743cd85188..00000000000 --- a/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { - defaultSlackTestConfig, - getSlackClient, - getSlackHandlerOrThrow, - getSlackTestState, - resetSlackTestState, - runSlackMessageOnce, - startSlackMonitor, - stopSlackMonitor, -} from "./monitor.test-helpers.js"; - -const { monitorSlackProvider } = await import("./monitor.js"); - -const slackTestState = getSlackTestState(); -const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState; - -beforeEach(() => { - resetInboundDedupe(); - resetSlackTestState(defaultSlackTestConfig()); -}); - -describe("monitorSlackProvider tool results", () => { - it("forces thread replies when replyToId is set", async () => { - replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" }); - slackTestState.config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dmPolicy: "open", - allowFrom: ["*"], - dm: { enabled: true }, - replyToMode: "off", - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "789", - channel: "C1", - channel_type: "im", - }, - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "555" }); - }); - - it("reacts to mention-gated room messages when ackReaction is enabled", async () => { - replyMock.mockResolvedValue(undefined); - const client = getSlackClient(); - if (!client) { - throw new Error("Slack client not registered"); - } - const conversations = client.conversations as { - info: ReturnType; - }; - conversations.info.mockResolvedValueOnce({ - channel: { name: "general", is_channel: true }, - }); - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "<@bot-user> hello", - ts: "456", - channel: "C1", - channel_type: "channel", - }, - }); - - expect(reactMock).toHaveBeenCalledWith({ - channel: "C1", - timestamp: "456", - name: "👀", - }); - }); - - it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { - slackTestState.config = { - ...slackTestState.config, - channels: { - ...slackTestState.config.channels, - slack: { - ...slackTestState.config.channels?.slack, - dm: { enabled: true, policy: "pairing", allowFrom: [] }, - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "123", - channel: "C1", - channel_type: "im", - }, - }); - - expect(replyMock).not.toHaveBeenCalled(); - expect(upsertPairingRequestMock).toHaveBeenCalled(); - expect(sendMock).toHaveBeenCalledTimes(1); - expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Slack user id: U1"); - expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE"); - }); - - it("does not resend pairing code when a request is already pending", async () => { - slackTestState.config = { - ...slackTestState.config, - channels: { - ...slackTestState.config.channels, - slack: { - ...slackTestState.config.channels?.slack, - dm: { enabled: true, policy: "pairing", allowFrom: [] }, - }, - }, - }; - upsertPairingRequestMock - .mockResolvedValueOnce({ code: "PAIRCODE", created: true }) - .mockResolvedValueOnce({ code: "PAIRCODE", created: false }); - - const { controller, run } = startSlackMonitor(monitorSlackProvider); - const handler = await getSlackHandlerOrThrow("message"); - - const baseEvent = { - type: "message", - user: "U1", - text: "hello", - ts: "123", - channel: "C1", - channel_type: "im", - }; - - await handler({ event: baseEvent }); - await handler({ event: { ...baseEvent, ts: "124", text: "hello again" } }); - - await stopSlackMonitor({ controller, run }); - - expect(sendMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.test.ts similarity index 52% rename from src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts rename to src/slack/monitor.tool-result.test.ts index f9fc21705c1..2e98b8e2653 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -16,7 +16,7 @@ import { const { monitorSlackProvider } = await import("./monitor.js"); const slackTestState = getSlackTestState(); -const { sendMock, replyMock } = slackTestState; +const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState; beforeEach(() => { resetInboundDedupe(); @@ -439,4 +439,384 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); }); + + it("forces thread replies when replyToId is set", async () => { + replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" }); + slackTestState.config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + channels: { + slack: { + dmPolicy: "open", + allowFrom: ["*"], + dm: { enabled: true }, + replyToMode: "off", + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "555" }); + }); + + it("reacts to mention-gated room messages when ackReaction is enabled", async () => { + replyMock.mockResolvedValue(undefined); + const client = getSlackClient(); + if (!client) { + throw new Error("Slack client not registered"); + } + const conversations = client.conversations as { + info: ReturnType; + }; + conversations.info.mockResolvedValueOnce({ + channel: { name: "general", is_channel: true }, + }); + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "<@bot-user> hello", + ts: "456", + channel: "C1", + channel_type: "channel", + }, + }); + + expect(reactMock).toHaveBeenCalledWith({ + channel: "C1", + timestamp: "456", + name: "👀", + }); + }); + + it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { + slackTestState.config = { + ...slackTestState.config, + channels: { + ...slackTestState.config.channels, + slack: { + ...slackTestState.config.channels?.slack, + dm: { enabled: true, policy: "pairing", allowFrom: [] }, + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Slack user id: U1"); + expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE"); + }); + + it("does not resend pairing code when a request is already pending", async () => { + slackTestState.config = { + ...slackTestState.config, + channels: { + ...slackTestState.config.channels, + slack: { + ...slackTestState.config.channels?.slack, + dm: { enabled: true, policy: "pairing", allowFrom: [] }, + }, + }, + }; + upsertPairingRequestMock + .mockResolvedValueOnce({ code: "PAIRCODE", created: true }) + .mockResolvedValueOnce({ code: "PAIRCODE", created: false }); + + const { controller, run } = startSlackMonitor(monitorSlackProvider); + const handler = await getSlackHandlerOrThrow("message"); + + const baseEvent = { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }; + + await handler({ event: baseEvent }); + await handler({ event: { ...baseEvent, ts: "124", text: "hello again" } }); + + await stopSlackMonitor({ controller, run }); + + expect(sendMock).toHaveBeenCalledTimes(1); + }); + + it("threads top-level replies when replyToMode is all", async () => { + replyMock.mockResolvedValue({ text: "thread reply" }); + slackTestState.config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + replyToMode: "all", + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + channel: "C1", + channel_type: "im", + }, + }); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "123" }); + }); + + it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => { + replyMock.mockResolvedValue({ text: "thread reply" }); + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + thread_ts: "123", + parent_user_id: "U2", + channel: "C1", + channel_type: "im", + }, + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + const ctx = replyMock.mock.calls[0]?.[0] as { + SessionKey?: string; + ParentSessionKey?: string; + }; + expect(ctx.SessionKey).toBe("agent:main:main:thread:123"); + expect(ctx.ParentSessionKey).toBeUndefined(); + }); + + it("keeps thread parent inheritance opt-in", async () => { + replyMock.mockResolvedValue({ text: "thread reply" }); + + slackTestState.config = { + messages: { responsePrefix: "PFX" }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: false } }, + thread: { inheritParent: true }, + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + thread_ts: "111.222", + channel: "C1", + channel_type: "channel", + }, + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + const ctx = replyMock.mock.calls[0]?.[0] as { + SessionKey?: string; + ParentSessionKey?: string; + }; + expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); + expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1"); + }); + + it("injects starter context for thread replies", async () => { + replyMock.mockResolvedValue({ text: "ok" }); + + const client = getSlackClient(); + if (client?.conversations?.info) { + client.conversations.info.mockResolvedValue({ + channel: { name: "general", is_channel: true }, + }); + } + if (client?.conversations?.replies) { + client.conversations.replies.mockResolvedValue({ + messages: [{ text: "starter message", user: "U2", ts: "111.222" }], + }); + } + + slackTestState.config = { + messages: { responsePrefix: "PFX" }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: false } }, + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "thread reply", + ts: "123.456", + thread_ts: "111.222", + channel: "C1", + channel_type: "channel", + }, + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + const ctx = replyMock.mock.calls[0]?.[0] as { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + }; + expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); + expect(ctx.ParentSessionKey).toBeUndefined(); + expect(ctx.ThreadStarterBody).toContain("starter message"); + expect(ctx.ThreadLabel).toContain("Slack thread #general"); + }); + + it("scopes thread session keys to the routed agent", async () => { + replyMock.mockResolvedValue({ text: "ok" }); + slackTestState.config = { + messages: { responsePrefix: "PFX" }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: false } }, + }, + }, + bindings: [{ agentId: "support", match: { channel: "slack", teamId: "T1" } }], + }; + + const client = getSlackClient(); + if (client?.auth?.test) { + client.auth.test.mockResolvedValue({ + user_id: "bot-user", + team_id: "T1", + }); + } + if (client?.conversations?.info) { + client.conversations.info.mockResolvedValue({ + channel: { name: "general", is_channel: true }, + }); + } + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "thread reply", + ts: "123.456", + thread_ts: "111.222", + channel: "C1", + channel_type: "channel", + }, + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + const ctx = replyMock.mock.calls[0]?.[0] as { + SessionKey?: string; + ParentSessionKey?: string; + }; + expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222"); + expect(ctx.ParentSessionKey).toBeUndefined(); + }); + + it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => { + replyMock.mockResolvedValue({ text: "root reply" }); + slackTestState.config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + replyToMode: "off", + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + }); + + it("threads first reply when replyToMode is first and message is not threaded", async () => { + replyMock.mockResolvedValue({ text: "first reply" }); + slackTestState.config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + replyToMode: "first", + }, + }, + }; + + await runSlackMessageOnce(monitorSlackProvider, { + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + expect(sendMock).toHaveBeenCalledTimes(1); + // First reply starts a thread under the incoming message + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" }); + }); }); diff --git a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts b/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts deleted file mode 100644 index 7e2574d6967..00000000000 --- a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { - defaultSlackTestConfig, - getSlackClient, - getSlackTestState, - resetSlackTestState, - runSlackMessageOnce, -} from "./monitor.test-helpers.js"; - -const { monitorSlackProvider } = await import("./monitor.js"); - -const slackTestState = getSlackTestState(); -const { sendMock, replyMock } = slackTestState; - -beforeEach(() => { - resetInboundDedupe(); - resetSlackTestState(defaultSlackTestConfig()); -}); - -describe("monitorSlackProvider tool results", () => { - it("threads top-level replies when replyToMode is all", async () => { - replyMock.mockResolvedValue({ text: "thread reply" }); - slackTestState.config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - replyToMode: "all", - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "123", - channel: "C1", - channel_type: "im", - }, - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "123" }); - }); - - it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => { - replyMock.mockResolvedValue({ text: "thread reply" }); - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "123", - thread_ts: "123", - parent_user_id: "U2", - channel: "C1", - channel_type: "im", - }, - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; - expect(ctx.SessionKey).toBe("agent:main:main:thread:123"); - expect(ctx.ParentSessionKey).toBeUndefined(); - }); - - it("keeps thread parent inheritance opt-in", async () => { - replyMock.mockResolvedValue({ text: "thread reply" }); - - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - thread: { inheritParent: true }, - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "123", - thread_ts: "111.222", - channel: "C1", - channel_type: "channel", - }, - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; - expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); - expect(ctx.ParentSessionKey).toBe("agent:main:slack:channel:c1"); - }); - - it("injects starter context for thread replies", async () => { - replyMock.mockResolvedValue({ text: "ok" }); - - const client = getSlackClient(); - if (client?.conversations?.info) { - client.conversations.info.mockResolvedValue({ - channel: { name: "general", is_channel: true }, - }); - } - if (client?.conversations?.replies) { - client.conversations.replies.mockResolvedValue({ - messages: [{ text: "starter message", user: "U2", ts: "111.222" }], - }); - } - - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "thread reply", - ts: "123.456", - thread_ts: "111.222", - channel: "C1", - channel_type: "channel", - }, - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - ThreadStarterBody?: string; - ThreadLabel?: string; - }; - expect(ctx.SessionKey).toBe("agent:main:slack:channel:c1:thread:111.222"); - expect(ctx.ParentSessionKey).toBeUndefined(); - expect(ctx.ThreadStarterBody).toContain("starter message"); - expect(ctx.ThreadLabel).toContain("Slack thread #general"); - }); - - it("scopes thread session keys to the routed agent", async () => { - replyMock.mockResolvedValue({ text: "ok" }); - slackTestState.config = { - messages: { responsePrefix: "PFX" }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - channels: { C1: { allow: true, requireMention: false } }, - }, - }, - bindings: [{ agentId: "support", match: { channel: "slack", teamId: "T1" } }], - }; - - const client = getSlackClient(); - if (client?.auth?.test) { - client.auth.test.mockResolvedValue({ - user_id: "bot-user", - team_id: "T1", - }); - } - if (client?.conversations?.info) { - client.conversations.info.mockResolvedValue({ - channel: { name: "general", is_channel: true }, - }); - } - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "thread reply", - ts: "123.456", - thread_ts: "111.222", - channel: "C1", - channel_type: "channel", - }, - }); - - expect(replyMock).toHaveBeenCalledTimes(1); - const ctx = replyMock.mock.calls[0]?.[0] as { - SessionKey?: string; - ParentSessionKey?: string; - }; - expect(ctx.SessionKey).toBe("agent:support:slack:channel:c1:thread:111.222"); - expect(ctx.ParentSessionKey).toBeUndefined(); - }); - - it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => { - replyMock.mockResolvedValue({ text: "root reply" }); - slackTestState.config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - replyToMode: "off", - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "789", - channel: "C1", - channel_type: "im", - }, - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); - }); - - it("threads first reply when replyToMode is first and message is not threaded", async () => { - replyMock.mockResolvedValue({ text: "first reply" }); - slackTestState.config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - replyToMode: "first", - }, - }, - }; - - await runSlackMessageOnce(monitorSlackProvider, { - event: { - type: "message", - user: "U1", - text: "hello", - ts: "789", - channel: "C1", - channel_type: "im", - }, - }); - - expect(sendMock).toHaveBeenCalledTimes(1); - // First reply starts a thread under the incoming message - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" }); - }); -}); From f8925b758860a38a67d887c3608514fd8f36fc54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:00:44 +0000 Subject: [PATCH 042/178] perf(test): consolidate reply commands suites --- src/auto-reply/reply/commands-approve.test.ts | 114 --- src/auto-reply/reply/commands-compact.test.ts | 114 --- src/auto-reply/reply/commands-info.test.ts | 13 - src/auto-reply/reply/commands-parsing.test.ts | 85 --- src/auto-reply/reply/commands-policy.test.ts | 335 --------- src/auto-reply/reply/commands.test.ts | 656 +++++++++++++++++- 6 files changed, 654 insertions(+), 663 deletions(-) delete mode 100644 src/auto-reply/reply/commands-approve.test.ts delete mode 100644 src/auto-reply/reply/commands-compact.test.ts delete mode 100644 src/auto-reply/reply/commands-info.test.ts delete mode 100644 src/auto-reply/reply/commands-parsing.test.ts delete mode 100644 src/auto-reply/reply/commands-policy.test.ts diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts deleted file mode 100644 index cfb1f3cb7f0..00000000000 --- a/src/auto-reply/reply/commands-approve.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { callGateway } from "../../gateway/call.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../gateway/call.js", () => ({ - callGateway: vi.fn(), -})); - -describe("/approve command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("rejects invalid usage", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Usage: /approve"); - }); - - it("submits approval", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { SenderId: "123" }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("rejects gateway clients without approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.write"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("requires operator.approvals"); - expect(mockCallGateway).not.toHaveBeenCalled(); - }); - - it("allows gateway clients with approvals scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.approvals"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("allows gateway clients with admin scope", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildCommandTestParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: ["operator.admin"], - }); - - const mockCallGateway = vi.mocked(callGateway); - mockCallGateway.mockResolvedValueOnce({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Exec approval allow-once submitted"); - expect(mockCallGateway).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts deleted file mode 100644 index 7c418ac239a..00000000000 --- a/src/auto-reply/reply/commands-compact.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; - -vi.mock("../../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn(), - compactEmbeddedPiSession: vi.fn(), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/system-events.js", () => ({ - enqueueSystemEvent: vi.fn(), -})); - -vi.mock("./session-updates.js", () => ({ - incrementCompactionCount: vi.fn(), -})); - -describe("/compact command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns null when command is not /compact", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/status", cfg); - - const result = await handleCompactCommand( - { - ...params, - }, - true, - ); - - expect(result).toBeNull(); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("rejects unauthorized /compact commands", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact", cfg); - - const result = await handleCompactCommand( - { - ...params, - command: { - ...params.command, - isAuthorizedSender: false, - senderId: "unauthorized", - }, - }, - true, - ); - - expect(result).toEqual({ shouldContinue: false }); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("routes manual compaction with explicit trigger and context metadata", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: "/tmp/openclaw-session-store.json" }, - } as OpenClawConfig; - const params = buildCommandTestParams("/compact: focus on decisions", cfg, { - From: "+15550001", - To: "+15550002", - }); - vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ - ok: true, - compacted: false, - }); - - const result = await handleCompactCommand( - { - ...params, - sessionEntry: { - sessionId: "session-1", - groupId: "group-1", - groupChannel: "#general", - space: "workspace-1", - spawnedBy: "agent:main:parent", - totalTokens: 12345, - }, - }, - true, - ); - - expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "session-1", - sessionKey: "agent:main:main", - trigger: "manual", - customInstructions: "focus on decisions", - messageChannel: "whatsapp", - groupId: "group-1", - groupChannel: "#general", - groupSpace: "workspace-1", - spawnedBy: "agent:main:parent", - }), - ); - }); -}); diff --git a/src/auto-reply/reply/commands-info.test.ts b/src/auto-reply/reply/commands-info.test.ts deleted file mode 100644 index 9751c39cca5..00000000000 --- a/src/auto-reply/reply/commands-info.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCommandsPaginationKeyboard } from "./commands-info.js"; - -describe("buildCommandsPaginationKeyboard", () => { - it("adds agent id to callback data when provided", () => { - const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); - expect(keyboard[0]).toEqual([ - { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, - { text: "2/3", callback_data: "commands_page_noop:agent-main" }, - { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, - ]); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts deleted file mode 100644 index 47309f93217..00000000000 --- a/src/auto-reply/reply/commands-parsing.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { extractMessageText } from "./commands-subagents.js"; -import { handleCommands } from "./commands.js"; -import { buildCommandTestParams } from "./commands.test-harness.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-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 } }); - }); -}); - -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 OpenClawConfig; - const params = buildCommandTestParams('/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-policy.test.ts b/src/auto-reply/reply/commands-policy.test.ts deleted file mode 100644 index c93b818e25f..00000000000 --- a/src/auto-reply/reply/commands-policy.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } 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"], - }; -}); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, - { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, - { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, - ]), -})); - -function buildParams(commandBody: string, cfg: OpenClawConfig, 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", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("lists config + store allowFrom entries", async () => { - readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); - - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["123", "@Alice"] } }, - } as OpenClawConfig; - 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 OpenClawConfig; - 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"); - }); - - it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - slack: { - allowFrom: ["U111", "U222"], - dm: { allowFrom: ["U111", "U222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm U111", cfg, { - Provider: "slack", - Surface: "slack", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); - expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.slack.allowFrom"); - }); - - it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - - const cfg = { - commands: { text: true, config: true }, - channels: { - discord: { - allowFrom: ["111", "222"], - dm: { allowFrom: ["111", "222"] }, - configWrites: true, - }, - }, - } as OpenClawConfig; - - const params = buildParams("/allowlist remove dm 111", cfg, { - Provider: "discord", - Surface: "discord", - }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; - expect(written.channels?.discord?.allowFrom).toEqual(["222"]); - expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain("channels.discord.allowFrom"); - }); -}); - -describe("/models command", () => { - const cfg = { - commands: { text: true }, - agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, - } as unknown as OpenClawConfig; - - it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { - const params = buildParams("/models", cfg, { Provider: surface, Surface: surface }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Providers:"); - expect(result.reply?.text).toContain("anthropic"); - expect(result.reply?.text).toContain("Use: /models "); - }); - - it("lists providers on telegram (buttons)", async () => { - const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toBe("Select a provider:"); - const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) - ?.telegram?.buttons; - expect(buttons).toBeDefined(); - expect(buttons?.length).toBeGreaterThan(0); - }); - - it("lists provider models with pagination hints", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).toContain("Switch: /model "); - expect(result.reply?.text).toContain("All: /models anthropic all"); - }); - - it("ignores page argument when all flag is present", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (anthropic)"); - expect(result.reply?.text).toContain("page 1/1"); - expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); - expect(result.reply?.text).not.toContain("Page out of range"); - }); - - it("errors on out-of-range pages", async () => { - // Use discord surface for text-based output tests - const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" }); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Page out of range"); - expect(result.reply?.text).toContain("valid: 1-"); - }); - - it("handles unknown providers", async () => { - const params = buildParams("/models not-a-provider", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Unknown provider"); - expect(result.reply?.text).toContain("Available providers"); - }); - - it("lists configured models outside the curated catalog", async () => { - const customCfg = { - commands: { text: true }, - agents: { - defaults: { - model: { - primary: "localai/ultra-chat", - fallbacks: ["anthropic/claude-opus-4-5"], - }, - imageModel: "visionpro/studio-v1", - }, - }, - } as unknown as OpenClawConfig; - - // Use discord surface for text-based output tests - const providerList = await handleCommands( - buildParams("/models", customCfg, { Surface: "discord" }), - ); - expect(providerList.reply?.text).toContain("localai"); - expect(providerList.reply?.text).toContain("visionpro"); - - const result = await handleCommands( - buildParams("/models localai", customCfg, { Surface: "discord" }), - ); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Models (localai)"); - expect(result.reply?.text).toContain("localai/ultra-chat"); - expect(result.reply?.text).not.toContain("Unknown provider"); - }); -}); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 431755561dc..1351296a2a1 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; import { @@ -13,14 +13,96 @@ import { updateSessionStore } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js"; import { resetBashChatCommandForTests } from "./bash-command.js"; +import { handleCompactCommand } from "./commands-compact.js"; +import { buildCommandsPaginationKeyboard } from "./commands-info.js"; +import { extractMessageText } from "./commands-subagents.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-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" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" }, + { provider: "openai", id: "gpt-4.1", name: "GPT-4.1" }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" }, + ]), +})); + +vi.mock("../../agents/pi-embedded.js", () => { + const resolveEmbeddedSessionLane = (key: string) => { + const cleaned = key.trim() || "main"; + return cleaned.startsWith("session:") ? cleaned : `session:${cleaned}`; + }; + return { + abortEmbeddedPiRun: vi.fn(), + compactEmbeddedPiSession: vi.fn(), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane, + runEmbeddedPiAgent: vi.fn(), + waitForEmbeddedPiRunEnd: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("../../infra/system-events.js", () => ({ + enqueueSystemEvent: vi.fn(), +})); + +vi.mock("./session-updates.js", () => ({ + incrementCompactionCount: vi.fn(), +})); const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -import { handleCommands } from "./commands.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; // Avoid expensive workspace scans during /context tests. vi.mock("./commands-context-report.js", () => ({ @@ -104,6 +186,293 @@ describe("handleCommands gating", () => { }); }); +describe("/approve command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects invalid usage", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Usage: /approve"); + }); + + it("submits approval", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("rejects gateway clients without approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.write"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("requires operator.approvals"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("allows gateway clients with approvals scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.approvals"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("allows gateway clients with admin scope", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + const params = buildParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: ["operator.admin"], + }); + + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); +}); + +describe("/compact command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when command is not /compact", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/status", cfg); + + const result = await handleCompactCommand( + { + ...params, + }, + true, + ); + + expect(result).toBeNull(); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("rejects unauthorized /compact commands", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + + const result = await handleCompactCommand( + { + ...params, + command: { + ...params.command, + isAuthorizedSender: false, + senderId: "unauthorized", + }, + }, + true, + ); + + expect(result).toEqual({ shouldContinue: false }); + expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); + }); + + it("routes manual compaction with explicit trigger and context metadata", async () => { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: "/tmp/openclaw-session-store.json" }, + } as OpenClawConfig; + const params = buildParams("/compact: focus on decisions", cfg, { + From: "+15550001", + To: "+15550002", + }); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: true, + compacted: false, + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + groupId: "group-1", + groupChannel: "#general", + space: "workspace-1", + spawnedBy: "agent:main:parent", + totalTokens: 12345, + }, + }, + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sessionKey: "agent:main:main", + trigger: "manual", + customInstructions: "focus on decisions", + messageChannel: "whatsapp", + groupId: "group-1", + groupChannel: "#general", + groupSpace: "workspace-1", + spawnedBy: "agent:main:parent", + }), + ); + }); +}); + +describe("buildCommandsPaginationKeyboard", () => { + it("adds agent id to callback data when provided", () => { + const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); + expect(keyboard[0]).toEqual([ + { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, + { text: "2/3", callback_data: "commands_page_noop:agent-main" }, + { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, + ]); + }); +}); + +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 OpenClawConfig; + 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"); + }); +}); + describe("handleCommands bash alias", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests(); @@ -130,6 +499,289 @@ describe("handleCommands bash alias", () => { }); }); +function buildPolicyParams( + commandBody: string, + cfg: OpenClawConfig, + 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", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists config + store allowFrom entries", async () => { + readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]); + + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["123", "@Alice"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/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 OpenClawConfig; + const params = buildPolicyParams("/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"); + }); + + it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + slack: { + allowFrom: ["U111", "U222"], + dm: { allowFrom: ["U111", "U222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm U111", cfg, { + Provider: "slack", + Surface: "slack", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.slack?.allowFrom).toEqual(["U222"]); + expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.slack.allowFrom"); + }); + + it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + + const cfg = { + commands: { text: true, config: true }, + channels: { + discord: { + allowFrom: ["111", "222"], + dm: { allowFrom: ["111", "222"] }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams("/allowlist remove dm 111", cfg, { + Provider: "discord", + Surface: "discord", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(writeConfigFileMock).toHaveBeenCalledTimes(1); + const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig; + expect(written.channels?.discord?.allowFrom).toEqual(["222"]); + expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain("channels.discord.allowFrom"); + }); +}); + +describe("/models command", () => { + const cfg = { + commands: { text: true }, + agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, + } as unknown as OpenClawConfig; + + it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => { + const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Providers:"); + expect(result.reply?.text).toContain("anthropic"); + expect(result.reply?.text).toContain("Use: /models "); + }); + + it("lists providers on telegram (buttons)", async () => { + const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toBe("Select a provider:"); + const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } }) + ?.telegram?.buttons; + expect(buttons).toBeDefined(); + expect(buttons?.length).toBeGreaterThan(0); + }); + + it("lists provider models with pagination hints", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).toContain("Switch: /model "); + expect(result.reply?.text).toContain("All: /models anthropic all"); + }); + + it("ignores page argument when all flag is present", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (anthropic)"); + expect(result.reply?.text).toContain("page 1/1"); + expect(result.reply?.text).toContain("anthropic/claude-opus-4-5"); + expect(result.reply?.text).not.toContain("Page out of range"); + }); + + it("errors on out-of-range pages", async () => { + // Use discord surface for text-based output tests + const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" }); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Page out of range"); + expect(result.reply?.text).toContain("valid: 1-"); + }); + + it("handles unknown providers", async () => { + const params = buildPolicyParams("/models not-a-provider", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Unknown provider"); + expect(result.reply?.text).toContain("Available providers"); + }); + + it("lists configured models outside the curated catalog", async () => { + const customCfg = { + commands: { text: true }, + agents: { + defaults: { + model: { + primary: "localai/ultra-chat", + fallbacks: ["anthropic/claude-opus-4-5"], + }, + imageModel: "visionpro/studio-v1", + }, + }, + } as unknown as OpenClawConfig; + + // Use discord surface for text-based output tests + const providerList = await handleCommands( + buildPolicyParams("/models", customCfg, { Surface: "discord" }), + ); + expect(providerList.reply?.text).toContain("localai"); + expect(providerList.reply?.text).toContain("visionpro"); + + const result = await handleCommands( + buildPolicyParams("/models localai", customCfg, { Surface: "discord" }), + ); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Models (localai)"); + expect(result.reply?.text).toContain("localai/ultra-chat"); + expect(result.reply?.text).not.toContain("Unknown provider"); + }); +}); + describe("handleCommands plugin commands", () => { it("dispatches registered plugin commands", async () => { clearPluginCommands(); From 51709c63fe18e0e7b5effdcad5393613da57664c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:03:31 +0000 Subject: [PATCH 043/178] perf(test): consolidate model selection suites --- ...model-selection.override-respected.test.ts | 132 -------------- ...parent.test.ts => model-selection.test.ts} | 163 +++++++++++++++--- 2 files changed, 137 insertions(+), 158 deletions(-) delete mode 100644 src/auto-reply/reply/model-selection.override-respected.test.ts rename src/auto-reply/reply/{model-selection.inherit-parent.test.ts => model-selection.test.ts} (56%) diff --git a/src/auto-reply/reply/model-selection.override-respected.test.ts b/src/auto-reply/reply/model-selection.override-respected.test.ts deleted file mode 100644 index b3457fc5596..00000000000 --- a/src/auto-reply/reply/model-selection.override-respected.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createModelSelectionState } from "./model-selection.js"; - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(async () => [ - { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, - ]), -})); - -const defaultProvider = "inferencer"; -const defaultModel = "deepseek-v3-4bit-mlx"; - -const makeEntry = (overrides: Record = {}) => ({ - sessionId: "session-id", - updatedAt: Date.now(), - ...overrides, -}); - -describe("createModelSelectionState respects session model override", () => { - it("applies session modelOverride when set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - providerOverride: "kimi-coding", - modelOverride: "k2p5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe("kimi-coding"); - expect(state.model).toBe("k2p5"); - }); - - it("falls back to default when no modelOverride is set", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry(); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe(defaultModel); - }); - - it("respects modelOverride even when session model field differs", async () => { - // This tests the scenario from issue #14783: user switches model via /model, - // the override is stored, but session.model still reflects the last-used - // fallback model. The override should take precedence. - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - // Last-used model (from fallback) - should NOT be used for selection - model: "k2p5", - modelProvider: "kimi-coding", - contextTokens: 262_000, - // User's explicit override - SHOULD be used - providerOverride: "anthropic", - modelOverride: "claude-opus-4-5", - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - // Should use the override, not the last-used model - expect(state.provider).toBe("anthropic"); - expect(state.model).toBe("claude-opus-4-5"); - }); - - it("uses default provider when providerOverride is not set but modelOverride is", async () => { - const cfg = {} as OpenClawConfig; - const sessionKey = "agent:main:main"; - const sessionEntry = makeEntry({ - modelOverride: "deepseek-v3-4bit-mlx", - // no providerOverride - }); - const sessionStore = { [sessionKey]: sessionEntry }; - - const state = await createModelSelectionState({ - cfg, - agentCfg: undefined, - sessionEntry, - sessionStore, - sessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); - - expect(state.provider).toBe(defaultProvider); - expect(state.model).toBe("deepseek-v3-4bit-mlx"); - }); -}); diff --git a/src/auto-reply/reply/model-selection.inherit-parent.test.ts b/src/auto-reply/reply/model-selection.test.ts similarity index 56% rename from src/auto-reply/reply/model-selection.inherit-parent.test.ts rename to src/auto-reply/reply/model-selection.test.ts index e80088b42a0..3da30c3c6da 100644 --- a/src/auto-reply/reply/model-selection.inherit-parent.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -4,44 +4,46 @@ import { createModelSelectionState } from "./model-selection.js"; vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, + { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, - { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, ]), })); -const defaultProvider = "openai"; -const defaultModel = "gpt-4o-mini"; - const makeEntry = (overrides: Record = {}) => ({ sessionId: "session-id", updatedAt: Date.now(), ...overrides, }); -async function resolveState(params: { - cfg: OpenClawConfig; - sessionEntry: ReturnType; - sessionStore: Record>; - sessionKey: string; - parentSessionKey?: string; -}) { - return createModelSelectionState({ - cfg: params.cfg, - agentCfg: params.cfg.agents?.defaults, - sessionEntry: params.sessionEntry, - sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - parentSessionKey: params.parentSessionKey, - defaultProvider, - defaultModel, - provider: defaultProvider, - model: defaultModel, - hasModelDirective: false, - }); -} - describe("createModelSelectionState parent inheritance", () => { + const defaultProvider = "openai"; + const defaultModel = "gpt-4o-mini"; + + async function resolveState(params: { + cfg: OpenClawConfig; + sessionEntry: ReturnType; + sessionStore: Record>; + sessionKey: string; + parentSessionKey?: string; + }) { + return createModelSelectionState({ + cfg: params.cfg, + agentCfg: params.cfg.agents?.defaults, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + } + it("inherits parent override from explicit parentSessionKey", async () => { const cfg = {} as OpenClawConfig; const parentKey = "agent:main:discord:channel:c1"; @@ -212,3 +214,112 @@ describe("createModelSelectionState parent inheritance", () => { expect(state.model).toBe("claude-opus-4-5"); }); }); + +describe("createModelSelectionState respects session model override", () => { + const defaultProvider = "inferencer"; + const defaultModel = "deepseek-v3-4bit-mlx"; + + it("applies session modelOverride when set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + providerOverride: "kimi-coding", + modelOverride: "k2p5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("kimi-coding"); + expect(state.model).toBe("k2p5"); + }); + + it("falls back to default when no modelOverride is set", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe(defaultModel); + }); + + it("respects modelOverride even when session model field differs", async () => { + // From issue #14783: stored override should beat last-used fallback model. + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + model: "k2p5", + modelProvider: "kimi-coding", + contextTokens: 262_000, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-5", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe("anthropic"); + expect(state.model).toBe("claude-opus-4-5"); + }); + + it("uses default provider when providerOverride is not set but modelOverride is", async () => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:main"; + const sessionEntry = makeEntry({ + modelOverride: "deepseek-v3-4bit-mlx", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider, + defaultModel, + provider: defaultProvider, + model: defaultModel, + hasModelDirective: false, + }); + + expect(state.provider).toBe(defaultProvider); + expect(state.model).toBe("deepseek-v3-4bit-mlx"); + }); +}); From 53ec78319d7cb89e7a2cdd998dbc15466a85d000 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:16:39 +0000 Subject: [PATCH 044/178] perf(test): consolidate session suites --- src/auto-reply/reply/session-resets.test.ts | 689 ----------------- src/auto-reply/reply/session-usage.test.ts | 120 --- src/auto-reply/reply/session.test.ts | 779 ++++++++++++++++++++ 3 files changed, 779 insertions(+), 809 deletions(-) delete mode 100644 src/auto-reply/reply/session-resets.test.ts delete mode 100644 src/auto-reply/reply/session-usage.test.ts diff --git a/src/auto-reply/reply/session-resets.test.ts b/src/auto-reply/reply/session-resets.test.ts deleted file mode 100644 index 9c105c0307b..00000000000 --- a/src/auto-reply/reply/session-resets.test.ts +++ /dev/null @@ -1,689 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { buildModelAliasIndex } from "../../agents/model-selection.js"; -import { saveSessionStore } from "../../config/sessions.js"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; -import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; -import { applyResetModelOverride } from "./session-reset-model.js"; -import { prependSystemEvents } from "./session-updates.js"; -import { initSessionState } from "./session.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" }, - ]), -})); - -let suiteRoot = ""; -let suiteCase = 0; - -beforeAll(async () => { - suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-resets-suite-")); -}); - -afterAll(async () => { - await fs.rm(suiteRoot, { recursive: true, force: true }); - suiteRoot = ""; - suiteCase = 0; -}); - -async function createStorePath(prefix: string): Promise { - const root = path.join(suiteRoot, `${prefix}${++suiteCase}`); - await fs.mkdir(root); - return path.join(root, "sessions.json"); -} - -describe("initSessionState reset triggers in WhatsApp groups", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { - return { - session: { store: params.storePath, idleMinutes: 999 }, - channels: { - whatsapp: { - allowFrom: params.allowFrom, - groupPolicy: "open", - }, - }, - } as OpenClawConfig; - } - - it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { - const storePath = await createStorePath("openclaw-group-reset-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - 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", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Peschiño", - SenderE164: "+41796666864", - SenderId: "41796666864:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { - const storePath = await createStorePath("openclaw-group-reset-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "OtherPerson", - SenderE164: "+1555123456", - SenderId: "1555123456:0@s.whatsapp.net", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); - - it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { - const storePath = await createStorePath("openclaw-group-rawbody-"); - const sessionKey = "agent:main:whatsapp:group:g1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["*"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+1111", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - SenderE164: "+1222", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Owner", - SenderE164: "+41796666864", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.isNewSession).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { - const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); - const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = makeCfg({ - storePath, - allowFrom: ["+41796666864"], - }); - - const groupMessageCtx = { - Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, - RawBody: "/new", - CommandBody: "/new", - From: "120363406150318674@g.us", - To: "+41779241027", - ChatType: "group", - SessionKey: sessionKey, - Provider: "whatsapp", - Surface: "whatsapp", - SenderName: "Other", - SenderE164: "+1555123456", - SenderId: "123@lid", - }; - - const result = await initSessionState({ - ctx: groupMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.triggerBodyNormalized).toBe("/new"); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); - }); -}); - -describe("initSessionState reset triggers in Slack channels", () => { - async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - sessionId: string; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - }, - }); - } - - it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-reset-"); - const sessionKey = "agent:main:slack:channel:c1"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /reset", - RawBody: "<@U123> /reset", - CommandBody: "<@U123> /reset", - From: "slack:channel:C1", - To: "channel:C1", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe(""); - }); - - it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { - const storePath = await createStorePath("openclaw-slack-channel-new-"); - const sessionKey = "agent:main:slack:channel:c2"; - const existingSessionId = "existing-session-123"; - await seedSessionStore({ - storePath, - sessionKey, - sessionId: existingSessionId, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const channelMessageCtx = { - Body: "<@U123> /new take notes", - RawBody: "<@U123> /new take notes", - CommandBody: "<@U123> /new take notes", - From: "slack:channel:C2", - To: "channel:C2", - ChatType: "channel", - SessionKey: sessionKey, - Provider: "slack", - Surface: "slack", - SenderId: "U123", - SenderName: "Owner", - }; - - const result = await initSessionState({ - ctx: channelMessageCtx, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.bodyStripped).toBe("take notes"); - }); -}); - -describe("applyResetModelOverride", () => { - it("selects a model hint and strips it from the body", async () => { - const cfg = {} as OpenClawConfig; - 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 OpenClawConfig; - 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 OpenClawConfig; - 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("initSessionState preserves behavior overrides across /new and /reset", () => { - async function seedSessionStoreWithOverrides(params: { - storePath: string; - sessionKey: string; - sessionId: string; - overrides: Record; - }): Promise { - const { saveSessionStore } = await import("../../config/sessions.js"); - await saveSessionStore(params.storePath, { - [params.sessionKey]: { - sessionId: params.sessionId, - updatedAt: Date.now(), - ...params.overrides, - }, - }); - } - - it("/new preserves verboseLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-verbose-"); - const sessionKey = "agent:main:telegram:dm:user1"; - const existingSessionId = "existing-session-verbose"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { verboseLevel: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user1", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionId).not.toBe(existingSessionId); - expect(result.sessionEntry.verboseLevel).toBe("on"); - }); - - it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-thinking-"); - const sessionKey = "agent:main:telegram:dm:user2"; - const existingSessionId = "existing-session-thinking"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { thinkingLevel: "full", reasoningLevel: "high" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/reset", - RawBody: "/reset", - CommandBody: "/reset", - From: "user2", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - expect(result.sessionEntry.thinkingLevel).toBe("full"); - expect(result.sessionEntry.reasoningLevel).toBe("high"); - }); - - it("/new preserves ttsAuto from previous session", async () => { - const storePath = await createStorePath("openclaw-reset-tts-"); - const sessionKey = "agent:main:telegram:dm:user3"; - const existingSessionId = "existing-session-tts"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: { ttsAuto: "on" }, - }); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user3", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.sessionEntry.ttsAuto).toBe("on"); - }); - - it("archives previous transcript file on /new reset", async () => { - const storePath = await createStorePath("openclaw-reset-archive-"); - const sessionKey = "agent:main:telegram:dm:user-archive"; - const existingSessionId = "existing-session-archive"; - await seedSessionStoreWithOverrides({ - storePath, - sessionKey, - sessionId: existingSessionId, - overrides: {}, - }); - const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`); - await fs.writeFile( - transcriptPath, - `${JSON.stringify({ message: { role: "user", content: "hello" } })}\n`, - "utf-8", - ); - - const cfg = { - session: { store: storePath, idleMinutes: 999 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "/new", - RawBody: "/new", - CommandBody: "/new", - From: "user-archive", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(true); - const files = await fs.readdir(path.dirname(storePath)); - expect(files.some((f) => f.startsWith(`${existingSessionId}.jsonl.reset.`))).toBe(true); - }); - - it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { - const storePath = await createStorePath("openclaw-idle-no-preserve-"); - const sessionKey = "agent:main:telegram:dm:new-user"; - - const cfg = { - session: { store: storePath, idleMinutes: 0 }, - } as OpenClawConfig; - - const result = await initSessionState({ - ctx: { - Body: "hello", - RawBody: "hello", - CommandBody: "hello", - From: "new-user", - To: "bot", - ChatType: "direct", - SessionKey: sessionKey, - Provider: "telegram", - Surface: "telegram", - }, - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.resetTriggered).toBe(false); - expect(result.sessionEntry.verboseLevel).toBeUndefined(); - expect(result.sessionEntry.thinkingLevel).toBeUndefined(); - }); -}); - -describe("prependSystemEvents", () => { - it("adds a local timestamp to queued system events by default", async () => { - vi.useFakeTimers(); - try { - const timestamp = new Date("2026-01-12T20:19:17Z"); - const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); - vi.setSystemTime(timestamp); - - enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); - - const result = await prependSystemEvents({ - cfg: {} as OpenClawConfig, - sessionKey: "agent:main:main", - isMainSession: false, - isNewSession: false, - prefixedBodyBase: "User: hi", - }); - - expect(expectedTimestamp).toBeDefined(); - expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); - } finally { - resetSystemEventsForTest(); - vi.useRealTimers(); - } - }); -}); diff --git a/src/auto-reply/reply/session-usage.test.ts b/src/auto-reply/reply/session-usage.test.ts deleted file mode 100644 index ab44c53ed29..00000000000 --- a/src/auto-reply/reply/session-usage.test.ts +++ /dev/null @@ -1,120 +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 { persistSessionUsageUpdate } from "./session-usage.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("persistSessionUsageUpdate", () => { - it("uses lastCallUsage for totalTokens when provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, - }); - - // Accumulated usage (sums all API calls) — inflated - const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; - // Last individual API call's usage — actual context after compaction - const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: accumulatedUsage, - lastCallUsage, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - // totalTokens should reflect lastCallUsage (12_000 input), not accumulated (180_000) - expect(stored[sessionKey].totalTokens).toBe(12_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - // inputTokens/outputTokens still reflect accumulated usage for cost tracking - expect(stored[sessionKey].inputTokens).toBe(180_000); - expect(stored[sessionKey].outputTokens).toBe(10_000); - }); - - it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBeUndefined(); - expect(stored[sessionKey].totalTokensFresh).toBe(false); - }); - - it("uses promptTokens when available without lastCallUsage", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 50_000, output: 5_000, total: 55_000 }, - promptTokens: 42_000, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(42_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); - - it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - await seedSessionStore({ - storePath, - sessionKey, - entry: { sessionId: "s1", updatedAt: Date.now() }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 300_000, output: 10_000, total: 310_000 }, - lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, - contextTokensUsed: 200_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].totalTokens).toBe(250_000); - expect(stored[sessionKey].totalTokensFresh).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 269279146d4..5eb8bedc65b 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { buildModelAliasIndex } from "../../agents/model-selection.js"; import { saveSessionStore } from "../../config/sessions.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; +import { applyResetModelOverride } from "./session-reset-model.js"; +import { prependSystemEvents } from "./session-updates.js"; +import { persistSessionUsageUpdate } from "./session-usage.js"; import { initSessionState } from "./session.js"; // Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files. @@ -11,6 +17,13 @@ vi.mock("../../agents/session-write-lock.js", () => ({ acquireSessionWriteLock: async () => ({ release: async () => {} }), })); +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" }, + ]), +})); + let suiteRoot = ""; let suiteCase = 0; @@ -30,6 +43,13 @@ async function makeCaseDir(prefix: string): Promise { return dir; } +async function makeStorePath(prefix: string): Promise { + const root = await makeCaseDir(prefix); + return path.join(root, "sessions.json"); +} + +const createStorePath = makeStorePath; + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -513,3 +533,762 @@ describe("initSessionState channel reset overrides", () => { expect(result.sessionEntry.sessionId).toBe(sessionId); }); }); + +describe("initSessionState reset triggers in WhatsApp groups", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + function makeCfg(params: { storePath: string; allowFrom: string[] }): OpenClawConfig { + return { + session: { store: params.storePath, idleMinutes: 999 }, + channels: { + whatsapp: { + allowFrom: params.allowFrom, + groupPolicy: "open", + }, + }, + } as OpenClawConfig; + } + + it("Reset trigger /new works for authorized sender in WhatsApp group", async () => { + const storePath = await createStorePath("openclaw-group-reset-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + 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", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Peschiño", + SenderE164: "+41796666864", + SenderId: "41796666864:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked for unauthorized sender in existing session", async () => { + const storePath = await createStorePath("openclaw-group-reset-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "OtherPerson", + SenderE164: "+1555123456", + SenderId: "1555123456:0@s.whatsapp.net", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); + + it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => { + const storePath = await createStorePath("openclaw-group-rawbody-"); + const sessionKey = "agent:main:whatsapp:group:g1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["*"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+1111", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + SenderE164: "+1222", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Owner", + SenderE164: "+41796666864", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.isNewSession).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => { + const storePath = await createStorePath("openclaw-group-reset-lid-unauth-"); + const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = makeCfg({ + storePath, + allowFrom: ["+41796666864"], + }); + + const groupMessageCtx = { + Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`, + RawBody: "/new", + CommandBody: "/new", + From: "120363406150318674@g.us", + To: "+41779241027", + ChatType: "group", + SessionKey: sessionKey, + Provider: "whatsapp", + Surface: "whatsapp", + SenderName: "Other", + SenderE164: "+1555123456", + SenderId: "123@lid", + }; + + const result = await initSessionState({ + ctx: groupMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.triggerBodyNormalized).toBe("/new"); + expect(result.sessionId).toBe(existingSessionId); + expect(result.isNewSession).toBe(false); + }); +}); + +describe("initSessionState reset triggers in Slack channels", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + sessionId: string; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + }, + }); + } + + it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-reset-"); + const sessionKey = "agent:main:slack:channel:c1"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /reset", + RawBody: "<@U123> /reset", + CommandBody: "<@U123> /reset", + From: "slack:channel:C1", + To: "channel:C1", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe(""); + }); + + it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => { + const storePath = await createStorePath("openclaw-slack-channel-new-"); + const sessionKey = "agent:main:slack:channel:c2"; + const existingSessionId = "existing-session-123"; + await seedSessionStore({ + storePath, + sessionKey, + sessionId: existingSessionId, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const channelMessageCtx = { + Body: "<@U123> /new take notes", + RawBody: "<@U123> /new take notes", + CommandBody: "<@U123> /new take notes", + From: "slack:channel:C2", + To: "channel:C2", + ChatType: "channel", + SessionKey: sessionKey, + Provider: "slack", + Surface: "slack", + SenderId: "U123", + SenderName: "Owner", + }; + + const result = await initSessionState({ + ctx: channelMessageCtx, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.bodyStripped).toBe("take notes"); + }); +}); + +describe("applyResetModelOverride", () => { + it("selects a model hint and strips it from the body", async () => { + const cfg = {} as OpenClawConfig; + 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 OpenClawConfig; + 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 OpenClawConfig; + 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("initSessionState preserves behavior overrides across /new and /reset", () => { + async function seedSessionStoreWithOverrides(params: { + storePath: string; + sessionKey: string; + sessionId: string; + overrides: Record; + }): Promise { + await saveSessionStore(params.storePath, { + [params.sessionKey]: { + sessionId: params.sessionId, + updatedAt: Date.now(), + ...params.overrides, + }, + }); + } + + it("/new preserves verboseLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-verbose-"); + const sessionKey = "agent:main:telegram:dm:user1"; + const existingSessionId = "existing-session-verbose"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + await fs.writeFile( + path.join(path.dirname(storePath), `${existingSessionId}.jsonl`), + "", + "utf-8", + ); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user1", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.verboseLevel).toBe("on"); + }); + + it("/reset preserves thinkingLevel and reasoningLevel from previous session", async () => { + const storePath = await createStorePath("openclaw-reset-thinking-"); + const sessionKey = "agent:main:telegram:dm:user2"; + const existingSessionId = "existing-session-thinking"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { thinkingLevel: "high", reasoningLevel: "low" }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/reset", + RawBody: "/reset", + CommandBody: "/reset", + From: "user2", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.thinkingLevel).toBe("high"); + expect(result.sessionEntry.reasoningLevel).toBe("low"); + }); + + it("/new in a new session does not preserve overrides", async () => { + const storePath = await createStorePath("openclaw-new-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:user3"; + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user3", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); + + it("archives the old session store entry on /new", async () => { + const storePath = await createStorePath("openclaw-archive-old-"); + const sessionKey = "agent:main:telegram:dm:user-archive"; + const existingSessionId = "existing-session-archive"; + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { verboseLevel: "on" }, + }); + const sessionUtils = await import("../../gateway/session-utils.fs.js"); + const archiveSpy = vi.spyOn(sessionUtils, "archiveSessionTranscripts"); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "/new", + RawBody: "/new", + CommandBody: "/new", + From: "user-archive", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(true); + expect(archiveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: existingSessionId, + storePath, + reason: "reset", + }), + ); + archiveSpy.mockRestore(); + }); + + it("idle-based new session does NOT preserve overrides (no entry to read)", async () => { + const storePath = await createStorePath("openclaw-idle-no-preserve-"); + const sessionKey = "agent:main:telegram:dm:new-user"; + + const cfg = { + session: { store: storePath, idleMinutes: 0 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: "hello", + RawBody: "hello", + CommandBody: "hello", + From: "new-user", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(false); + expect(result.sessionEntry.verboseLevel).toBeUndefined(); + expect(result.sessionEntry.thinkingLevel).toBeUndefined(); + }); +}); + +describe("prependSystemEvents", () => { + it("adds a local timestamp to queued system events by default", async () => { + vi.useFakeTimers(); + try { + const timestamp = new Date("2026-01-12T20:19:17Z"); + const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true }); + vi.setSystemTime(timestamp); + + enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" }); + + const result = await prependSystemEvents({ + cfg: {} as OpenClawConfig, + sessionKey: "agent:main:main", + isMainSession: false, + isNewSession: false, + prefixedBodyBase: "User: hi", + }); + + expect(expectedTimestamp).toBeDefined(); + expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); + } finally { + resetSystemEventsForTest(); + vi.useRealTimers(); + } + }); +}); + +describe("persistSessionUsageUpdate", () => { + async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; + }) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); + } + + it("uses lastCallUsage for totalTokens when provided", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now(), totalTokens: 100_000 }, + }); + + const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; + const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: accumulatedUsage, + lastCallUsage, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(12_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + expect(stored[sessionKey].inputTokens).toBe(180_000); + expect(stored[sessionKey].outputTokens).toBe(10_000); + }); + + it("marks totalTokens as unknown when no fresh context snapshot is available", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBeUndefined(); + expect(stored[sessionKey].totalTokensFresh).toBe(false); + }); + + it("uses promptTokens when available without lastCallUsage", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 50_000, output: 5_000, total: 55_000 }, + promptTokens: 42_000, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(42_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); + + it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { + const storePath = await createStorePath("openclaw-usage-"); + const sessionKey = "main"; + await seedSessionStore({ + storePath, + sessionKey, + entry: { sessionId: "s1", updatedAt: Date.now() }, + }); + + await persistSessionUsageUpdate({ + storePath, + sessionKey, + usage: { input: 300_000, output: 10_000, total: 310_000 }, + lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, + contextTokensUsed: 200_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].totalTokens).toBe(250_000); + expect(stored[sessionKey].totalTokensFresh).toBe(true); + }); +}); From ed276d3e50f367710ceb4b353e8d8e1881808c54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:18:52 +0000 Subject: [PATCH 045/178] perf(test): consolidate inbound reply suites --- src/auto-reply/reply/inbound-meta.test.ts | 24 -------- src/auto-reply/reply/inbound-text.test.ts | 35 ----------- ...iders-contract.test.ts => inbound.test.ts} | 59 ++++++++++++++++++- 3 files changed, 57 insertions(+), 61 deletions(-) delete mode 100644 src/auto-reply/reply/inbound-meta.test.ts delete mode 100644 src/auto-reply/reply/inbound-text.test.ts rename src/auto-reply/reply/{inbound-context.providers-contract.test.ts => inbound.test.ts} (65%) diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts deleted file mode 100644 index f358aebc794..00000000000 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TemplateContext } from "../templating.js"; -import { buildInboundUserContextPrefix } from "./inbound-meta.js"; - -describe("buildInboundUserContextPrefix", () => { - it("omits conversation label block for direct chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "direct", - ConversationLabel: "openclaw-tui", - } as TemplateContext); - - expect(text).toBe(""); - }); - - it("keeps conversation label for group chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "group", - ConversationLabel: "ops-room", - } as TemplateContext); - - expect(text).toContain("Conversation info (untrusted metadata):"); - expect(text).toContain('"conversation_label": "ops-room"'); - }); -}); 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 2b54a71299a..00000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeInboundTextNewlines } from "./inbound-text.js"; - -describe("normalizeInboundTextNewlines", () => { - it("converts CRLF to LF", () => { - expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); - }); - - it("converts CR to LF", () => { - expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); - }); - - it("preserves literal backslash-n sequences in Windows paths", () => { - // Windows paths like C:\Work\nxxx should NOT have \n converted to newlines - const windowsPath = "C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); - }); - - it("preserves backslash-n in messages containing Windows paths", () => { - const message = "Please read the file at C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(message)).toBe( - "Please read the file at C:\\Work\\nxxx\\README.md", - ); - }); - - it("preserves multiple backslash-n sequences", () => { - const message = "C:\\new\\notes\\nested"; - expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); - }); - - it("still normalizes actual CRLF while preserving backslash-n", () => { - const message = "Line 1\r\nC:\\Work\\nxxx"; - expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.providers-contract.test.ts b/src/auto-reply/reply/inbound.test.ts similarity index 65% rename from src/auto-reply/reply/inbound-context.providers-contract.test.ts rename to src/auto-reply/reply/inbound.test.ts index a75b2996c30..b92d7acf513 100644 --- a/src/auto-reply/reply/inbound-context.providers-contract.test.ts +++ b/src/auto-reply/reply/inbound.test.ts @@ -1,7 +1,62 @@ -import { describe, it } from "vitest"; -import type { MsgContext } from "../templating.js"; +import { describe, expect, it } from "vitest"; +import type { MsgContext, TemplateContext } from "../templating.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { finalizeInboundContext } from "./inbound-context.js"; +import { buildInboundUserContextPrefix } from "./inbound-meta.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; + +describe("buildInboundUserContextPrefix", () => { + it("omits conversation label block for direct chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + ConversationLabel: "openclaw-tui", + } as TemplateContext); + + expect(text).toBe(""); + }); + + it("keeps conversation label for group chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + ConversationLabel: "ops-room", + } as TemplateContext); + + expect(text).toContain("Conversation info (untrusted metadata):"); + expect(text).toContain('"conversation_label": "ops-room"'); + }); +}); + +describe("normalizeInboundTextNewlines", () => { + it("converts CRLF to LF", () => { + expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); + }); + + it("converts CR to LF", () => { + expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); + }); + + it("preserves literal backslash-n sequences in Windows paths", () => { + const windowsPath = "C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); + }); + + it("preserves backslash-n in messages containing Windows paths", () => { + const message = "Please read the file at C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(message)).toBe( + "Please read the file at C:\\Work\\nxxx\\README.md", + ); + }); + + it("preserves multiple backslash-n sequences", () => { + const message = "C:\\new\\notes\\nested"; + expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); + }); + + it("still normalizes actual CRLF while preserving backslash-n", () => { + const message = "Line 1\r\nC:\\Work\\nxxx"; + expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); + }); +}); describe("inbound context contract (providers + extensions)", () => { const cases: Array<{ name: string; ctx: MsgContext }> = [ From 2158b09b9dc52706d00ca8811f41195047c21d4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:23:36 +0000 Subject: [PATCH 046/178] perf(test): consolidate discord monitor utils --- src/discord/monitor/agent-components.test.ts | 106 ---- src/discord/monitor/allow-list.test.ts | 129 ---- src/discord/monitor/gateway-registry.test.ts | 56 -- src/discord/monitor/monitor.test.ts | 614 +++++++++++++++++++ src/discord/monitor/presence-cache.test.ts | 34 - src/discord/monitor/presence.test.ts | 42 -- src/discord/monitor/threading.test.ts | 261 -------- 7 files changed, 614 insertions(+), 628 deletions(-) delete mode 100644 src/discord/monitor/agent-components.test.ts delete mode 100644 src/discord/monitor/allow-list.test.ts delete mode 100644 src/discord/monitor/gateway-registry.test.ts create mode 100644 src/discord/monitor/monitor.test.ts delete mode 100644 src/discord/monitor/presence-cache.test.ts delete mode 100644 src/discord/monitor/presence.test.ts delete mode 100644 src/discord/monitor/threading.test.ts diff --git a/src/discord/monitor/agent-components.test.ts b/src/discord/monitor/agent-components.test.ts deleted file mode 100644 index ea19695dc63..00000000000 --- a/src/discord/monitor/agent-components.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js"; - -const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); -const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../../infra/system-events.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), - }; -}); - -const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; - -const createDmButtonInteraction = (overrides: Partial = {}) => { - const reply = vi.fn().mockResolvedValue(undefined); - const defer = vi.fn().mockResolvedValue(undefined); - const interaction = { - rawData: { channel_id: "dm-channel" }, - user: { id: "123456789", username: "Alice", discriminator: "1234" }, - defer, - reply, - ...overrides, - } as unknown as ButtonInteraction; - return { interaction, defer, reply }; -}; - -const createDmSelectInteraction = (overrides: Partial = {}) => { - const reply = vi.fn().mockResolvedValue(undefined); - const defer = vi.fn().mockResolvedValue(undefined); - const interaction = { - rawData: { channel_id: "dm-channel" }, - user: { id: "123456789", username: "Alice", discriminator: "1234" }, - values: ["alpha"], - defer, - reply, - ...overrides, - } as unknown as StringSelectMenuInteraction; - return { interaction, defer, reply }; -}; - -beforeEach(() => { - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); - enqueueSystemEventMock.mockReset(); -}); - -describe("agent components", () => { - it("sends pairing reply when DM sender is not allowlisted", async () => { - const button = createAgentComponentButton({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "pairing", - }); - const { interaction, defer, reply } = createDmButtonInteraction(); - - await button.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledTimes(1); - expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); - expect(enqueueSystemEventMock).not.toHaveBeenCalled(); - }); - - it("allows DM interactions when pairing store allowlist matches", async () => { - readAllowFromStoreMock.mockResolvedValue(["123456789"]); - const button = createAgentComponentButton({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "allowlist", - }); - const { interaction, defer, reply } = createDmButtonInteraction(); - - await button.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledWith({ content: "✓" }); - expect(enqueueSystemEventMock).toHaveBeenCalled(); - }); - - it("matches tag-based allowlist entries for DM select menus", async () => { - const select = createAgentSelectMenu({ - cfg: createCfg(), - accountId: "default", - dmPolicy: "allowlist", - allowFrom: ["Alice#1234"], - }); - const { interaction, defer, reply } = createDmSelectInteraction(); - - await select.run(interaction, { componentId: "hello" } as ComponentData); - - expect(defer).toHaveBeenCalledWith({ ephemeral: true }); - expect(reply).toHaveBeenCalledWith({ content: "✓" }); - expect(enqueueSystemEventMock).toHaveBeenCalled(); - }); -}); diff --git a/src/discord/monitor/allow-list.test.ts b/src/discord/monitor/allow-list.test.ts deleted file mode 100644 index c620bd71af1..00000000000 --- a/src/discord/monitor/allow-list.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DiscordChannelConfigResolved } from "./allow-list.js"; -import { - resolveDiscordMemberAllowed, - resolveDiscordOwnerAllowFrom, - resolveDiscordRoleAllowed, -} from "./allow-list.js"; - -describe("resolveDiscordOwnerAllowFrom", () => { - it("returns undefined when no allowlist is configured", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toBeUndefined(); - }); - - it("skips wildcard matches for owner allowFrom", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["*"] } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toBeUndefined(); - }); - - it("returns a matching user id entry", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["123"] } as DiscordChannelConfigResolved, - sender: { id: "123" }, - }); - - expect(result).toEqual(["123"]); - }); - - it("returns the normalized name slug for name matches", () => { - const result = resolveDiscordOwnerAllowFrom({ - channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, - sender: { id: "999", name: "Some User" }, - }); - - expect(result).toEqual(["some-user"]); - }); -}); - -describe("resolveDiscordRoleAllowed", () => { - it("allows when no role allowlist is configured", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: undefined, - memberRoleIds: ["role-1"], - }); - - expect(allowed).toBe(true); - }); - - it("matches role IDs only", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["123"], - memberRoleIds: ["123", "456"], - }); - - expect(allowed).toBe(true); - }); - - it("does not match non-ID role entries", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["Admin"], - memberRoleIds: ["Admin"], - }); - - expect(allowed).toBe(false); - }); - - it("returns false when no matching role IDs", () => { - const allowed = resolveDiscordRoleAllowed({ - allowList: ["456"], - memberRoleIds: ["123"], - }); - - expect(allowed).toBe(false); - }); -}); - -describe("resolveDiscordMemberAllowed", () => { - it("allows when no user or role allowlists are configured", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: undefined, - roleAllowList: undefined, - memberRoleIds: [], - userId: "u1", - }); - - expect(allowed).toBe(true); - }); - - it("allows when user allowlist matches", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["123"], - roleAllowList: ["456"], - memberRoleIds: ["999"], - userId: "123", - }); - - expect(allowed).toBe(true); - }); - - it("allows when role allowlist matches", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["999"], - roleAllowList: ["456"], - memberRoleIds: ["456"], - userId: "123", - }); - - expect(allowed).toBe(true); - }); - - it("denies when user and role allowlists do not match", () => { - const allowed = resolveDiscordMemberAllowed({ - userAllowList: ["u2"], - roleAllowList: ["role-2"], - memberRoleIds: ["role-1"], - userId: "u1", - }); - - expect(allowed).toBe(false); - }); -}); diff --git a/src/discord/monitor/gateway-registry.test.ts b/src/discord/monitor/gateway-registry.test.ts deleted file mode 100644 index 8e0c66a87e3..00000000000 --- a/src/discord/monitor/gateway-registry.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { GatewayPlugin } from "@buape/carbon/gateway"; -import { beforeEach, describe, expect, it } from "vitest"; -import { - clearGateways, - getGateway, - registerGateway, - unregisterGateway, -} from "./gateway-registry.js"; - -function fakeGateway(props: Partial = {}): GatewayPlugin { - return { isConnected: true, ...props } as unknown as GatewayPlugin; -} - -describe("gateway-registry", () => { - beforeEach(() => { - clearGateways(); - }); - - it("stores and retrieves a gateway by account", () => { - const gateway = fakeGateway(); - registerGateway("account-a", gateway); - expect(getGateway("account-a")).toBe(gateway); - expect(getGateway("account-b")).toBeUndefined(); - }); - - it("uses collision-safe key when accountId is undefined", () => { - const gateway = fakeGateway(); - registerGateway(undefined, gateway); - expect(getGateway(undefined)).toBe(gateway); - // "default" as a literal account ID must not collide with the sentinel key - expect(getGateway("default")).toBeUndefined(); - }); - - it("unregisters a gateway", () => { - const gateway = fakeGateway(); - registerGateway("account-a", gateway); - unregisterGateway("account-a"); - expect(getGateway("account-a")).toBeUndefined(); - }); - - it("clears all gateways", () => { - registerGateway("a", fakeGateway()); - registerGateway("b", fakeGateway()); - clearGateways(); - expect(getGateway("a")).toBeUndefined(); - expect(getGateway("b")).toBeUndefined(); - }); - - it("overwrites existing entry for same account", () => { - const gateway1 = fakeGateway({ isConnected: true }); - const gateway2 = fakeGateway({ isConnected: false }); - registerGateway("account-a", gateway1); - registerGateway("account-a", gateway2); - expect(getGateway("account-a")).toBe(gateway2); - }); -}); diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts new file mode 100644 index 00000000000..ec9e2fa4bbb --- /dev/null +++ b/src/discord/monitor/monitor.test.ts @@ -0,0 +1,614 @@ +import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon"; +import type { Client } from "@buape/carbon"; +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { DiscordChannelConfigResolved } from "./allow-list.js"; +import { buildAgentSessionKey } from "../../routing/resolve-route.js"; +import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js"; +import { + resolveDiscordMemberAllowed, + resolveDiscordOwnerAllowFrom, + resolveDiscordRoleAllowed, +} from "./allow-list.js"; +import { + clearGateways, + getGateway, + registerGateway, + unregisterGateway, +} from "./gateway-registry.js"; +import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; +import { resolveDiscordPresenceUpdate } from "./presence.js"; +import { + maybeCreateDiscordAutoThread, + resolveDiscordAutoThreadContext, + resolveDiscordAutoThreadReplyPlan, + resolveDiscordReplyDeliveryPlan, +} from "./threading.js"; + +const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); +const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); +const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), +})); + +vi.mock("../../infra/system-events.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + }; +}); + +describe("agent components", () => { + const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; + + const createDmButtonInteraction = (overrides: Partial = {}) => { + const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); + const interaction = { + rawData: { channel_id: "dm-channel" }, + user: { id: "123456789", username: "Alice", discriminator: "1234" }, + defer, + reply, + ...overrides, + } as unknown as ButtonInteraction; + return { interaction, defer, reply }; + }; + + const createDmSelectInteraction = (overrides: Partial = {}) => { + const reply = vi.fn().mockResolvedValue(undefined); + const defer = vi.fn().mockResolvedValue(undefined); + const interaction = { + rawData: { channel_id: "dm-channel" }, + user: { id: "123456789", username: "Alice", discriminator: "1234" }, + values: ["alpha"], + defer, + reply, + ...overrides, + } as unknown as StringSelectMenuInteraction; + return { interaction, defer, reply }; + }; + + beforeEach(() => { + readAllowFromStoreMock.mockReset().mockResolvedValue([]); + upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + enqueueSystemEventMock.mockReset(); + }); + + it("sends pairing reply when DM sender is not allowlisted", async () => { + const button = createAgentComponentButton({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "pairing", + }); + const { interaction, defer, reply } = createDmButtonInteraction(); + + await button.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledTimes(1); + expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + }); + + it("allows DM interactions when pairing store allowlist matches", async () => { + readAllowFromStoreMock.mockResolvedValue(["123456789"]); + const button = createAgentComponentButton({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "allowlist", + }); + const { interaction, defer, reply } = createDmButtonInteraction(); + + await button.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(enqueueSystemEventMock).toHaveBeenCalled(); + }); + + it("matches tag-based allowlist entries for DM select menus", async () => { + const select = createAgentSelectMenu({ + cfg: createCfg(), + accountId: "default", + dmPolicy: "allowlist", + allowFrom: ["Alice#1234"], + }); + const { interaction, defer, reply } = createDmSelectInteraction(); + + await select.run(interaction, { componentId: "hello" } as ComponentData); + + expect(defer).toHaveBeenCalledWith({ ephemeral: true }); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(enqueueSystemEventMock).toHaveBeenCalled(); + }); +}); + +describe("resolveDiscordOwnerAllowFrom", () => { + it("returns undefined when no allowlist is configured", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toBeUndefined(); + }); + + it("skips wildcard matches for owner allowFrom", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["*"] } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toBeUndefined(); + }); + + it("returns a matching user id entry", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["123"] } as DiscordChannelConfigResolved, + sender: { id: "123" }, + }); + + expect(result).toEqual(["123"]); + }); + + it("returns the normalized name slug for name matches", () => { + const result = resolveDiscordOwnerAllowFrom({ + channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, + sender: { id: "999", name: "Some User" }, + }); + + expect(result).toEqual(["some-user"]); + }); +}); + +describe("resolveDiscordRoleAllowed", () => { + it("allows when no role allowlist is configured", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: undefined, + memberRoleIds: ["role-1"], + }); + + expect(allowed).toBe(true); + }); + + it("matches role IDs only", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["123"], + memberRoleIds: ["123", "456"], + }); + + expect(allowed).toBe(true); + }); + + it("does not match non-ID role entries", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["Admin"], + memberRoleIds: ["Admin"], + }); + + expect(allowed).toBe(false); + }); + + it("returns false when no matching role IDs", () => { + const allowed = resolveDiscordRoleAllowed({ + allowList: ["456"], + memberRoleIds: ["123"], + }); + + expect(allowed).toBe(false); + }); +}); + +describe("resolveDiscordMemberAllowed", () => { + it("allows when no user or role allowlists are configured", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: undefined, + roleAllowList: undefined, + memberRoleIds: [], + userId: "u1", + }); + + expect(allowed).toBe(true); + }); + + it("allows when user allowlist matches", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["123"], + roleAllowList: ["456"], + memberRoleIds: ["999"], + userId: "123", + }); + + expect(allowed).toBe(true); + }); + + it("allows when role allowlist matches", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["999"], + roleAllowList: ["456"], + memberRoleIds: ["456"], + userId: "123", + }); + + expect(allowed).toBe(true); + }); + + it("denies when user and role allowlists do not match", () => { + const allowed = resolveDiscordMemberAllowed({ + userAllowList: ["u2"], + roleAllowList: ["role-2"], + memberRoleIds: ["role-1"], + userId: "u1", + }); + + expect(allowed).toBe(false); + }); +}); + +describe("gateway-registry", () => { + type GatewayPlugin = { isConnected: boolean }; + + function fakeGateway(props: Partial = {}): GatewayPlugin { + return { isConnected: true, ...props }; + } + + beforeEach(() => { + clearGateways(); + }); + + it("stores and retrieves a gateway by account", () => { + const gateway = fakeGateway(); + registerGateway("account-a", gateway as never); + expect(getGateway("account-a")).toBe(gateway); + expect(getGateway("account-b")).toBeUndefined(); + }); + + it("uses collision-safe key when accountId is undefined", () => { + const gateway = fakeGateway(); + registerGateway(undefined, gateway as never); + expect(getGateway(undefined)).toBe(gateway); + expect(getGateway("default")).toBeUndefined(); + }); + + it("unregisters a gateway", () => { + const gateway = fakeGateway(); + registerGateway("account-a", gateway as never); + unregisterGateway("account-a"); + expect(getGateway("account-a")).toBeUndefined(); + }); + + it("clears all gateways", () => { + registerGateway("a", fakeGateway() as never); + registerGateway("b", fakeGateway() as never); + clearGateways(); + expect(getGateway("a")).toBeUndefined(); + expect(getGateway("b")).toBeUndefined(); + }); + + it("overwrites existing entry for same account", () => { + const gateway1 = fakeGateway({ isConnected: true }); + const gateway2 = fakeGateway({ isConnected: false }); + registerGateway("account-a", gateway1 as never); + registerGateway("account-a", gateway2 as never); + expect(getGateway("account-a")).toBe(gateway2); + }); +}); + +describe("presence-cache", () => { + beforeEach(() => { + clearPresences(); + }); + + it("scopes presence entries by account", () => { + const presenceA = { status: "online" } as GatewayPresenceUpdate; + const presenceB = { status: "idle" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presenceA); + setPresence("account-b", "user-1", presenceB); + + expect(getPresence("account-a", "user-1")).toBe(presenceA); + expect(getPresence("account-b", "user-1")).toBe(presenceB); + expect(getPresence("account-a", "user-2")).toBeUndefined(); + }); + + it("clears presence per account", () => { + const presence = { status: "dnd" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presence); + setPresence("account-b", "user-2", presence); + + clearPresences("account-a"); + + expect(getPresence("account-a", "user-1")).toBeUndefined(); + expect(getPresence("account-b", "user-2")).toBe(presence); + expect(presenceCacheSize()).toBe(1); + }); +}); + +describe("resolveDiscordPresenceUpdate", () => { + it("returns null when no presence config provided", () => { + expect(resolveDiscordPresenceUpdate({})).toBeNull(); + }); + + it("returns status-only presence when activity is omitted", () => { + const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); + expect(presence).not.toBeNull(); + expect(presence?.status).toBe("dnd"); + expect(presence?.activities).toEqual([]); + }); + + it("defaults to custom activity type when activity is set without type", () => { + const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); + expect(presence).not.toBeNull(); + expect(presence?.status).toBe("online"); + expect(presence?.activities).toHaveLength(1); + expect(presence?.activities[0]).toMatchObject({ + type: 4, + name: "Custom Status", + state: "Focus time", + }); + }); + + it("includes streaming url when activityType is streaming", () => { + const presence = resolveDiscordPresenceUpdate({ + activity: "Live", + activityType: 1, + activityUrl: "https://twitch.tv/openclaw", + }); + expect(presence).not.toBeNull(); + expect(presence?.activities).toHaveLength(1); + expect(presence?.activities[0]).toMatchObject({ + type: 1, + name: "Live", + url: "https://twitch.tv/openclaw", + }); + }); +}); + +describe("resolveDiscordAutoThreadContext", () => { + it("returns null when no createdThreadId", () => { + expect( + resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: undefined, + }), + ).toBeNull(); + }); + + it("re-keys session context to the created thread", () => { + const context = resolveDiscordAutoThreadContext({ + agentId: "agent", + channel: "discord", + messageChannelId: "parent", + createdThreadId: "thread", + }); + expect(context).not.toBeNull(); + expect(context?.To).toBe("channel:thread"); + expect(context?.From).toBe("discord:channel:thread"); + expect(context?.OriginatingTo).toBe("channel:thread"); + expect(context?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + expect(context?.ParentSessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "parent" }, + }), + ); + }); +}); + +describe("resolveDiscordReplyDeliveryPlan", () => { + it("uses reply references when posting to the original target", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: null, + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.replyTarget).toBe("channel:parent"); + expect(plan.replyReference.use()).toBe("m1"); + }); + + it("disables reply references when autoThread creates a new thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:parent", + replyToMode: "all", + messageId: "m1", + threadChannel: null, + createdThreadId: "thread", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + }); + + it("respects replyToMode off even inside a thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "off", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBeUndefined(); + }); + + it("uses existingId when inside a thread with replyToMode all", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "all", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.replyReference.use()).toBe("m1"); + }); + + it("uses existingId only on first call with replyToMode first inside a thread", () => { + const plan = resolveDiscordReplyDeliveryPlan({ + replyTarget: "channel:thread", + replyToMode: "first", + messageId: "m1", + threadChannel: { id: "thread" }, + createdThreadId: null, + }); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.replyReference.use()).toBeUndefined(); + }); +}); + +describe("maybeCreateDiscordAutoThread", () => { + it("returns existing thread ID when creation fails due to race condition", async () => { + const client = { + rest: { + post: async () => { + throw new Error("A thread has already been created on this message"); + }, + get: async () => ({ thread: { id: "existing-thread" } }), + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBe("existing-thread"); + }); + + it("returns undefined when creation fails and no existing thread found", async () => { + const client = { + rest: { + post: async () => { + throw new Error("Some other error"); + }, + get: async () => ({ thread: null }), + }, + } as unknown as Client; + + const result = await maybeCreateDiscordAutoThread({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + }); + + expect(result).toBeUndefined(); + }); +}); + +describe("resolveDiscordAutoThreadReplyPlan", () => { + it("switches delivery + session context to the created thread", async () => { + const client = { + rest: { post: async () => ({ id: "thread" }) }, + } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBeUndefined(); + expect(plan.autoThreadContext?.SessionKey).toBe( + buildAgentSessionKey({ + agentId: "agent", + channel: "discord", + peer: { kind: "channel", id: "thread" }, + }), + ); + }); + + it("routes replies to an existing thread channel", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: true, + } as unknown as DiscordChannelConfigResolved, + threadChannel: { id: "thread" }, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:thread"); + expect(plan.replyTarget).toBe("channel:thread"); + expect(plan.replyReference.use()).toBe("m1"); + expect(plan.autoThreadContext).toBeNull(); + }); + + it("does nothing when autoThread is disabled", async () => { + const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; + const plan = await resolveDiscordAutoThreadReplyPlan({ + client, + message: { + id: "m1", + channelId: "parent", + } as unknown as import("./listeners.js").DiscordMessageEvent["message"], + isGuildMessage: true, + channelConfig: { + autoThread: false, + } as unknown as DiscordChannelConfigResolved, + threadChannel: null, + baseText: "hello", + combinedBody: "hello", + replyToMode: "all", + agentId: "agent", + channel: "discord", + }); + expect(plan.deliverTarget).toBe("channel:parent"); + expect(plan.autoThreadContext).toBeNull(); + }); +}); diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts deleted file mode 100644 index e7dd04d0806..00000000000 --- a/src/discord/monitor/presence-cache.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GatewayPresenceUpdate } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it } from "vitest"; -import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; - -describe("presence-cache", () => { - beforeEach(() => { - clearPresences(); - }); - - it("scopes presence entries by account", () => { - const presenceA = { status: "online" } as GatewayPresenceUpdate; - const presenceB = { status: "idle" } as GatewayPresenceUpdate; - - setPresence("account-a", "user-1", presenceA); - setPresence("account-b", "user-1", presenceB); - - expect(getPresence("account-a", "user-1")).toBe(presenceA); - expect(getPresence("account-b", "user-1")).toBe(presenceB); - expect(getPresence("account-a", "user-2")).toBeUndefined(); - }); - - it("clears presence per account", () => { - const presence = { status: "dnd" } as GatewayPresenceUpdate; - - setPresence("account-a", "user-1", presence); - setPresence("account-b", "user-2", presence); - - clearPresences("account-a"); - - expect(getPresence("account-a", "user-1")).toBeUndefined(); - expect(getPresence("account-b", "user-2")).toBe(presence); - expect(presenceCacheSize()).toBe(1); - }); -}); diff --git a/src/discord/monitor/presence.test.ts b/src/discord/monitor/presence.test.ts deleted file mode 100644 index 83fd15efaf6..00000000000 --- a/src/discord/monitor/presence.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveDiscordPresenceUpdate } from "./presence.js"; - -describe("resolveDiscordPresenceUpdate", () => { - it("returns null when no presence config provided", () => { - expect(resolveDiscordPresenceUpdate({})).toBeNull(); - }); - - it("returns status-only presence when activity is omitted", () => { - const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("dnd"); - expect(presence?.activities).toEqual([]); - }); - - it("defaults to custom activity type when activity is set without type", () => { - const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); - expect(presence).not.toBeNull(); - expect(presence?.status).toBe("online"); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 4, - name: "Custom Status", - state: "Focus time", - }); - }); - - it("includes streaming url when activityType is streaming", () => { - const presence = resolveDiscordPresenceUpdate({ - activity: "Live", - activityType: 1, - activityUrl: "https://twitch.tv/openclaw", - }); - expect(presence).not.toBeNull(); - expect(presence?.activities).toHaveLength(1); - expect(presence?.activities[0]).toMatchObject({ - type: 1, - name: "Live", - url: "https://twitch.tv/openclaw", - }); - }); -}); diff --git a/src/discord/monitor/threading.test.ts b/src/discord/monitor/threading.test.ts deleted file mode 100644 index 587aca8bb16..00000000000 --- a/src/discord/monitor/threading.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { Client } from "@buape/carbon"; -import { describe, expect, it } from "vitest"; -import { buildAgentSessionKey } from "../../routing/resolve-route.js"; -import { - maybeCreateDiscordAutoThread, - resolveDiscordAutoThreadContext, - resolveDiscordAutoThreadReplyPlan, - resolveDiscordReplyDeliveryPlan, -} from "./threading.js"; - -describe("resolveDiscordAutoThreadContext", () => { - it("returns null when no createdThreadId", () => { - expect( - resolveDiscordAutoThreadContext({ - agentId: "agent", - channel: "discord", - messageChannelId: "parent", - createdThreadId: undefined, - }), - ).toBeNull(); - }); - - it("re-keys session context to the created thread", () => { - const context = resolveDiscordAutoThreadContext({ - agentId: "agent", - channel: "discord", - messageChannelId: "parent", - createdThreadId: "thread", - }); - expect(context).not.toBeNull(); - expect(context?.To).toBe("channel:thread"); - expect(context?.From).toBe("discord:channel:thread"); - expect(context?.OriginatingTo).toBe("channel:thread"); - expect(context?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - expect(context?.ParentSessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "parent" }, - }), - ); - }); -}); - -describe("resolveDiscordReplyDeliveryPlan", () => { - it("uses reply references when posting to the original target", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: null, - }); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.replyTarget).toBe("channel:parent"); - expect(plan.replyReference.use()).toBe("m1"); - }); - - it("disables reply references when autoThread creates a new thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:parent", - replyToMode: "all", - messageId: "m1", - threadChannel: null, - createdThreadId: "thread", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("respects replyToMode off even inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "off", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - expect(plan.replyReference.use()).toBeUndefined(); - }); - - it("uses existingId when inside a thread with replyToMode all", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "all", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - // "all" returns the reference on every call. - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBe("m1"); - }); - - it("uses existingId only on first call with replyToMode first inside a thread", () => { - const plan = resolveDiscordReplyDeliveryPlan({ - replyTarget: "channel:thread", - replyToMode: "first", - messageId: "m1", - threadChannel: { id: "thread" }, - createdThreadId: null, - }); - // "first" returns the reference only once. - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.replyReference.use()).toBeUndefined(); - }); -}); - -describe("maybeCreateDiscordAutoThread", () => { - it("returns existing thread ID when creation fails due to race condition", async () => { - // First call succeeds (simulating another agent creating the thread) - const client = { - rest: { - post: async () => { - throw new Error("A thread has already been created on this message"); - }, - get: async () => { - // Return message with existing thread (simulating race condition resolution) - return { thread: { id: "existing-thread" } }; - }, - }, - } as unknown as Client; - - const result = await maybeCreateDiscordAutoThread({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - }); - - expect(result).toBe("existing-thread"); - }); - - it("returns undefined when creation fails and no existing thread found", async () => { - const client = { - rest: { - post: async () => { - throw new Error("Some other error"); - }, - get: async () => { - // Message has no thread - return { thread: null }; - }, - }, - } as unknown as Client; - - const result = await maybeCreateDiscordAutoThread({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - }); - - expect(result).toBeUndefined(); - }); -}); - -describe("resolveDiscordAutoThreadReplyPlan", () => { - it("switches delivery + session context to the created thread", async () => { - const client = { - rest: { post: async () => ({ id: "thread" }) }, - } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBeUndefined(); - expect(plan.autoThreadContext?.SessionKey).toBe( - buildAgentSessionKey({ - agentId: "agent", - channel: "discord", - peer: { kind: "channel", id: "thread" }, - }), - ); - }); - - it("routes replies to an existing thread channel", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: { id: "thread" }, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:thread"); - expect(plan.replyTarget).toBe("channel:thread"); - expect(plan.replyReference.use()).toBe("m1"); - expect(plan.autoThreadContext).toBeNull(); - }); - - it("does nothing when autoThread is disabled", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: false, - } as unknown as import("./allow-list.js").DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); - expect(plan.deliverTarget).toBe("channel:parent"); - expect(plan.autoThreadContext).toBeNull(); - }); -}); From 704c8ed530c484f58c8bb2f4736b1ed1938a4c07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:28:43 +0000 Subject: [PATCH 047/178] perf(test): consolidate sessions config suites --- src/config/sessions/metadata.test.ts | 22 - src/config/sessions/paths.test.ts | 196 ------ src/config/sessions/reset.test.ts | 72 -- src/config/sessions/sessions.test.ts | 652 ++++++++++++++++++ src/config/sessions/store.lock.test.ts | 357 ---------- .../sessions/store.undefined-path.test.ts | 23 - src/config/sessions/transcript.test.ts | 114 --- 7 files changed, 652 insertions(+), 784 deletions(-) delete mode 100644 src/config/sessions/metadata.test.ts delete mode 100644 src/config/sessions/paths.test.ts delete mode 100644 src/config/sessions/reset.test.ts create mode 100644 src/config/sessions/sessions.test.ts delete mode 100644 src/config/sessions/store.lock.test.ts delete mode 100644 src/config/sessions/store.undefined-path.test.ts delete mode 100644 src/config/sessions/transcript.test.ts diff --git a/src/config/sessions/metadata.test.ts b/src/config/sessions/metadata.test.ts deleted file mode 100644 index c85624f0cbb..00000000000 --- a/src/config/sessions/metadata.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSessionMetaPatch } from "./metadata.js"; - -describe("deriveSessionMetaPatch", () => { - it("captures origin + group metadata", () => { - const patch = deriveSessionMetaPatch({ - ctx: { - Provider: "whatsapp", - ChatType: "group", - GroupSubject: "Family", - From: "123@g.us", - }, - sessionKey: "agent:main:whatsapp:group:123@g.us", - }); - - expect(patch?.origin?.label).toBe("Family id:123@g.us"); - expect(patch?.origin?.provider).toBe("whatsapp"); - expect(patch?.subject).toBe("Family"); - expect(patch?.channel).toBe("whatsapp"); - expect(patch?.groupId).toBe("123@g.us"); - }); -}); diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts deleted file mode 100644 index 443b7791b8f..00000000000 --- a/src/config/sessions/paths.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveSessionTranscriptPath, - resolveSessionTranscriptPathInDir, - resolveStorePath, - validateSessionId, -} from "./paths.js"; - -describe("resolveStorePath", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("uses OPENCLAW_HOME for tilde expansion", () => { - vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); - vi.stubEnv("HOME", "/home/other"); - - const resolved = resolveStorePath("~/.openclaw/agents/{agentId}/sessions/sessions.json", { - agentId: "research", - }); - - expect(resolved).toBe( - path.resolve("/srv/openclaw-home/.openclaw/agents/research/sessions/sessions.json"), - ); - }); -}); - -describe("session path safety", () => { - it("validates safe session IDs", () => { - expect(validateSessionId("sess-1")).toBe("sess-1"); - expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); - }); - - it("rejects unsafe session IDs", () => { - expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); - expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); - }); - - it("resolves transcript path inside an explicit sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); - - expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); - }); - - it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - - expect(() => - resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), - ).toThrow(/within sessions directory/); - }); - - it("accepts sessionFile candidates within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "subdir/threaded-session.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); - }); - - it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); - }); - - it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, - { sessionsDir }, - ); - - expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); - }); - - it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - expect(() => - resolveSessionFilePath( - "sess-1", - { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, - { sessionsDir }, - ), - ).toThrow(/within sessions directory/); - }); - - it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses absolute path fallback when sessionFile includes a different agent dir", () => { - const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); - const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); - const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses sibling fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir, agentId: "ops" }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses extracted agent fallback for custom per-agent store roots", () => { - const mainSessionsDir = "/srv/custom/agents/main/sessions"; - const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; - - const resolved = resolveSessionFilePath( - "sess-1", - { sessionFile: opsSessionFile }, - { sessionsDir: mainSessionsDir }, - ); - - expect(resolved).toBe(path.resolve(opsSessionFile)); - }); - - it("uses agent sessions dir fallback for transcript path", () => { - const resolved = resolveSessionTranscriptPath("sess-1", "main"); - expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); - }); - - it("keeps storePath and agentId when resolving session file options", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/tmp/custom/agent-store/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/tmp/custom/agent-store"), - agentId: "ops", - }); - }); - - it("keeps custom per-agent store roots when agentId is provided", () => { - const opts = resolveSessionFilePathOptions({ - storePath: "/srv/custom/agents/ops/sessions/sessions.json", - agentId: "ops", - }); - expect(opts).toEqual({ - sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"), - agentId: "ops", - }); - }); - - it("falls back to agentId when storePath is absent", () => { - const opts = resolveSessionFilePathOptions({ agentId: "ops" }); - expect(opts).toEqual({ agentId: "ops" }); - }); -}); diff --git a/src/config/sessions/reset.test.ts b/src/config/sessions/reset.test.ts deleted file mode 100644 index 01962a887e5..00000000000 --- a/src/config/sessions/reset.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionConfig } from "../types.base.js"; -import { resolveSessionResetPolicy } from "./reset.js"; - -describe("resolveSessionResetPolicy", () => { - describe("backward compatibility: resetByType.dm → direct", () => { - it("uses resetByType.direct when available", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "idle" as const, idleMinutes: 30 }, - }, - } satisfies SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(30); - }); - - it("falls back to resetByType.dm (legacy) when direct is missing", () => { - // Simulating legacy config with "dm" key instead of "direct" - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("idle"); - expect(policy.idleMinutes).toBe(45); - }); - - it("prefers resetByType.direct over resetByType.dm when both present", () => { - const sessionCfg = { - resetByType: { - direct: { mode: "daily" as const }, - dm: { mode: "idle" as const, idleMinutes: 99 }, - }, - } as unknown as SessionConfig; - - const policy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "direct", - }); - - expect(policy.mode).toBe("daily"); - }); - - it("does not use dm fallback for group/thread types", () => { - const sessionCfg = { - resetByType: { - dm: { mode: "idle" as const, idleMinutes: 45 }, - }, - } as unknown as SessionConfig; - - const groupPolicy = resolveSessionResetPolicy({ - sessionCfg, - resetType: "group", - }); - - // Should use default mode since group has no config and dm doesn't apply - expect(groupPolicy.mode).toBe("daily"); - }); - }); -}); diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts new file mode 100644 index 00000000000..ae963eb6441 --- /dev/null +++ b/src/config/sessions/sessions.test.ts @@ -0,0 +1,652 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionConfig } from "../types.base.js"; +import type { SessionEntry } from "./types.js"; +import { + clearSessionStoreCacheForTest, + getSessionStoreLockQueueSizeForTest, + loadSessionStore, + updateSessionStore, + updateSessionStoreEntry, +} from "../sessions.js"; +import { withSessionStoreLockForTest } from "../sessions.js"; +import { deriveSessionMetaPatch } from "./metadata.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, + resolveSessionTranscriptPath, + resolveSessionTranscriptPathInDir, + resolveStorePath, + validateSessionId, +} from "./paths.js"; +import { resolveSessionResetPolicy } from "./reset.js"; +import { updateSessionStore as updateSessionStoreUnsafe } from "./store.js"; +import { + appendAssistantMessageToSessionTranscript, + resolveMirroredTranscriptText, +} from "./transcript.js"; + +describe("deriveSessionMetaPatch", () => { + it("captures origin + group metadata", () => { + const patch = deriveSessionMetaPatch({ + ctx: { + Provider: "whatsapp", + ChatType: "group", + GroupSubject: "Family", + From: "123@g.us", + }, + sessionKey: "agent:main:whatsapp:group:123@g.us", + }); + + expect(patch?.origin?.label).toBe("Family id:123@g.us"); + expect(patch?.origin?.provider).toBe("whatsapp"); + expect(patch?.subject).toBe("Family"); + expect(patch?.channel).toBe("whatsapp"); + expect(patch?.groupId).toBe("123@g.us"); + }); +}); + +describe("resolveStorePath", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses OPENCLAW_HOME for tilde expansion", () => { + vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home"); + vi.stubEnv("HOME", "/home/other"); + + const resolved = resolveStorePath("~/.openclaw/agents/{agentId}/sessions/sessions.json", { + agentId: "research", + }); + + expect(resolved).toBe( + path.resolve("/srv/openclaw-home/.openclaw/agents/research/sessions/sessions.json"), + ); + }); +}); + +describe("session path safety", () => { + it("validates safe session IDs", () => { + expect(validateSessionId("sess-1")).toBe("sess-1"); + expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello"); + }); + + it("rejects unsafe session IDs", () => { + expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/); + expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/); + }); + + it("resolves transcript path inside an explicit sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b"); + + expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl")); + }); + + it("rejects unsafe sessionFile candidates that escape the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + + expect(() => + resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }), + ).toThrow(/within sessions directory/); + }); + + it("accepts sessionFile candidates within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "subdir/threaded-session.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl")); + }); + + it("accepts absolute sessionFile paths that resolve within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl")); + }); + + it("accepts absolute sessionFile with topic suffix within the sessions dir", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" }, + { sessionsDir }, + ); + + expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl")); + }); + + it("rejects absolute sessionFile paths outside known agent sessions dirs", () => { + const sessionsDir = "/tmp/openclaw/agents/main/sessions"; + + expect(() => + resolveSessionFilePath( + "sess-1", + { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" }, + { sessionsDir }, + ), + ).toThrow(/within sessions directory/); + }); + + it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => { + const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); + const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); + const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir, agentId: "ops" }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses absolute path fallback when sessionFile includes a different agent dir", () => { + const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" })); + const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" })); + const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl"); + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses sibling fallback for custom per-agent store roots", () => { + const mainSessionsDir = "/srv/custom/agents/main/sessions"; + const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir, agentId: "ops" }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses extracted agent fallback for custom per-agent store roots", () => { + const mainSessionsDir = "/srv/custom/agents/main/sessions"; + const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl"; + + const resolved = resolveSessionFilePath( + "sess-1", + { sessionFile: opsSessionFile }, + { sessionsDir: mainSessionsDir }, + ); + + expect(resolved).toBe(path.resolve(opsSessionFile)); + }); + + it("uses agent sessions dir fallback for transcript path", () => { + const resolved = resolveSessionTranscriptPath("sess-1", "main"); + expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); + }); + + it("keeps storePath and agentId when resolving session file options", () => { + const opts = resolveSessionFilePathOptions({ + storePath: "/tmp/custom/agent-store/sessions.json", + agentId: "ops", + }); + expect(opts).toEqual({ + sessionsDir: path.resolve("/tmp/custom/agent-store"), + agentId: "ops", + }); + }); + + it("keeps custom per-agent store roots when agentId is provided", () => { + const opts = resolveSessionFilePathOptions({ + storePath: "/srv/custom/agents/ops/sessions/sessions.json", + agentId: "ops", + }); + expect(opts).toEqual({ + sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"), + agentId: "ops", + }); + }); + + it("falls back to agentId when storePath is absent", () => { + const opts = resolveSessionFilePathOptions({ agentId: "ops" }); + expect(opts).toEqual({ agentId: "ops" }); + }); +}); + +describe("resolveSessionResetPolicy", () => { + describe("backward compatibility: resetByType.dm -> direct", () => { + it("uses resetByType.direct when available", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "idle" as const, idleMinutes: 30 }, + }, + } satisfies SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(30); + }); + + it("falls back to resetByType.dm (legacy) when direct is missing", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("idle"); + expect(policy.idleMinutes).toBe(45); + }); + + it("prefers resetByType.direct over resetByType.dm when both present", () => { + const sessionCfg = { + resetByType: { + direct: { mode: "daily" as const }, + dm: { mode: "idle" as const, idleMinutes: 99 }, + }, + } as unknown as SessionConfig; + + const policy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + + expect(policy.mode).toBe("daily"); + }); + + it("does not use dm fallback for group/thread types", () => { + const sessionCfg = { + resetByType: { + dm: { mode: "idle" as const, idleMinutes: 45 }, + }, + } as unknown as SessionConfig; + + const groupPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "group", + }); + + expect(groupPolicy.mode).toBe("daily"); + }); + }); +}); + +describe("session store lock (Promise chain mutex)", () => { + let lockFixtureRoot = ""; + let lockCaseId = 0; + let lockTmpDirs: string[] = []; + + async function makeTmpStore( + initial: Record = {}, + ): Promise<{ dir: string; storePath: string }> { + const dir = path.join(lockFixtureRoot, `case-${lockCaseId++}`); + await fsPromises.mkdir(dir); + lockTmpDirs.push(dir); + const storePath = path.join(dir, "sessions.json"); + if (Object.keys(initial).length > 0) { + await fsPromises.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); + } + return { dir, storePath }; + } + + beforeAll(async () => { + lockFixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); + }); + + afterAll(async () => { + if (lockFixtureRoot) { + await fsPromises.rm(lockFixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } + }); + + afterEach(async () => { + clearSessionStoreCacheForTest(); + lockTmpDirs = []; + }); + + it("serializes concurrent updateSessionStore calls without data loss", async () => { + const key = "agent:main:test"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, + }); + + const N = 4; + await Promise.all( + Array.from({ length: N }, (_, i) => + updateSessionStore(storePath, async (store) => { + const entry = store[key] as Record; + await Promise.resolve(); + entry.counter = (entry.counter as number) + 1; + entry.tag = `writer-${i}`; + }), + ), + ); + + const store = loadSessionStore(storePath); + expect((store[key] as Record).counter).toBe(N); + }); + + it("concurrent updateSessionStoreEntry patches all merge correctly", async () => { + const key = "agent:main:merge"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + await Promise.all([ + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { modelOverride: "model-a" }; + }, + }), + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { thinkingLevel: "high" as const }; + }, + }), + updateSessionStoreEntry({ + storePath, + sessionKey: key, + update: async () => { + await Promise.resolve(); + return { systemPromptOverride: "custom" }; + }, + }), + ]); + + const store = loadSessionStore(storePath); + const entry = store[key]; + expect(entry.modelOverride).toBe("model-a"); + expect(entry.thinkingLevel).toBe("high"); + expect(entry.systemPromptOverride).toBe("custom"); + }); + + it("continues processing queued tasks after a preceding task throws", async () => { + const key = "agent:main:err"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + const errorPromise = updateSessionStore(storePath, async () => { + throw new Error("boom"); + }); + + const successPromise = updateSessionStore(storePath, async (store) => { + store[key] = { ...store[key], modelOverride: "after-error" } as unknown as SessionEntry; + }); + + await expect(errorPromise).rejects.toThrow("boom"); + await successPromise; + + const store = loadSessionStore(storePath); + expect(store[key]?.modelOverride).toBe("after-error"); + }); + + it("multiple consecutive errors do not permanently poison the queue", async () => { + const key = "agent:main:multi-err"; + const { storePath } = await makeTmpStore({ + [key]: { sessionId: "s1", updatedAt: 100 }, + }); + + const errors = Array.from({ length: 3 }, (_, i) => + updateSessionStore(storePath, async () => { + throw new Error(`fail-${i}`); + }), + ); + + const success = updateSessionStore(storePath, async (store) => { + store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; + }); + + for (const p of errors) { + await expect(p).rejects.toThrow(); + } + await success; + + const store = loadSessionStore(storePath); + expect(store[key]?.modelOverride).toBe("recovered"); + }); + + it("operations on different storePaths execute concurrently", async () => { + const { storePath: pathA } = await makeTmpStore({ + a: { sessionId: "a", updatedAt: 100 }, + }); + const { storePath: pathB } = await makeTmpStore({ + b: { sessionId: "b", updatedAt: 100 }, + }); + + const order: string[] = []; + let started = 0; + let releaseBoth: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseBoth = resolve; + }); + const markStarted = () => { + started += 1; + if (started === 2) { + releaseBoth?.(); + } + }; + + const opA = updateSessionStore(pathA, async (store) => { + order.push("a-start"); + markStarted(); + await gate; + store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; + order.push("a-end"); + }); + + const opB = updateSessionStore(pathB, async (store) => { + order.push("b-start"); + markStarted(); + await gate; + store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; + order.push("b-end"); + }); + + await Promise.all([opA, opB]); + + const aStart = order.indexOf("a-start"); + const bStart = order.indexOf("b-start"); + const aEnd = order.indexOf("a-end"); + const bEnd = order.indexOf("b-end"); + const firstEnd = Math.min(aEnd, bEnd); + expect(aStart).toBeGreaterThanOrEqual(0); + expect(bStart).toBeGreaterThanOrEqual(0); + expect(aEnd).toBeGreaterThanOrEqual(0); + expect(bEnd).toBeGreaterThanOrEqual(0); + expect(aStart).toBeLessThan(firstEnd); + expect(bStart).toBeLessThan(firstEnd); + + expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); + expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); + }); + + it("cleans up LOCK_QUEUES entry after all tasks complete", async () => { + const { storePath } = await makeTmpStore({ + x: { sessionId: "x", updatedAt: 100 }, + }); + + await updateSessionStore(storePath, async (store) => { + store.x = { ...store.x, modelOverride: "done" } as unknown as SessionEntry; + }); + + await Promise.resolve(); + + expect(getSessionStoreLockQueueSizeForTest()).toBe(0); + }); + + it("cleans up LOCK_QUEUES entry even after errors", async () => { + const { storePath } = await makeTmpStore({}); + + await updateSessionStore(storePath, async () => { + throw new Error("fail"); + }).catch(() => undefined); + + await Promise.resolve(); + + expect(getSessionStoreLockQueueSizeForTest()).toBe(0); + }); +}); + +describe("withSessionStoreLock storePath guard (#14717)", () => { + it("throws descriptive error when storePath is undefined", async () => { + await expect( + updateSessionStoreUnsafe(undefined as unknown as string, (store) => store), + ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); + }); + + it("throws descriptive error when storePath is empty string", async () => { + await expect(updateSessionStoreUnsafe("", (store) => store)).rejects.toThrow( + "withSessionStoreLock: storePath must be a non-empty string", + ); + }); + + it("withSessionStoreLockForTest also throws descriptive error when storePath is undefined", async () => { + await expect( + withSessionStoreLockForTest(undefined as unknown as string, async () => {}), + ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); + }); +}); + +describe("resolveMirroredTranscriptText", () => { + it("prefers media filenames over text", () => { + const result = resolveMirroredTranscriptText({ + text: "caption here", + mediaUrls: ["https://example.com/files/report.pdf?sig=123"], + }); + expect(result).toBe("report.pdf"); + }); + + it("returns trimmed text when no media", () => { + const result = resolveMirroredTranscriptText({ text: " hello " }); + expect(result).toBe("hello"); + }); +}); + +describe("appendAssistantMessageToSessionTranscript", () => { + let tempDir: string; + let storePath: string; + let sessionsDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); + sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + storePath = path.join(sessionsDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns error for missing sessionKey", async () => { + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "", + text: "test", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("missing sessionKey"); + } + }); + + it("returns error for empty text", async () => { + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "test-session", + text: " ", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("empty text"); + } + }); + + it("returns error for unknown sessionKey", async () => { + fs.writeFileSync(storePath, JSON.stringify({}), "utf-8"); + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "nonexistent", + text: "test message", + storePath, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toContain("unknown sessionKey"); + } + }); + + it("creates transcript file and appends message for valid session", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + storePath, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(fs.existsSync(result.sessionFile)).toBe(true); + + const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const header = JSON.parse(lines[0]); + expect(header.type).toBe("session"); + expect(header.id).toBe(sessionId); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.type).toBe("message"); + expect(messageLine.message.role).toBe("assistant"); + expect(messageLine.message.content[0].type).toBe("text"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + } + }); +}); diff --git a/src/config/sessions/store.lock.test.ts b/src/config/sessions/store.lock.test.ts deleted file mode 100644 index 91ee7e0ddf3..00000000000 --- a/src/config/sessions/store.lock.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { SessionEntry } from "./types.js"; -import { - clearSessionStoreCacheForTest, - getSessionStoreLockQueueSizeForTest, - loadSessionStore, - updateSessionStore, - updateSessionStoreEntry, - withSessionStoreLockForTest, -} from "../sessions.js"; - -describe("session store lock (Promise chain mutex)", () => { - let fixtureRoot = ""; - let caseId = 0; - let tmpDirs: string[] = []; - - function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; - } - - async function makeTmpStore( - initial: Record = {}, - ): Promise<{ dir: string; storePath: string }> { - const dir = path.join(fixtureRoot, `case-${caseId++}`); - await fs.mkdir(dir); - tmpDirs.push(dir); - const storePath = path.join(dir, "sessions.json"); - if (Object.keys(initial).length > 0) { - await fs.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8"); - } - return { dir, storePath }; - } - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - } - }); - - afterEach(async () => { - clearSessionStoreCacheForTest(); - tmpDirs = []; - }); - - // ── 1. Concurrent access does not corrupt data ────────────────────── - - it("serializes concurrent updateSessionStore calls without data loss", async () => { - const key = "agent:main:test"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, counter: 0 }, - }); - - // Launch a few concurrent read-modify-write cycles (enough to surface stale-read races). - const N = 4; - await Promise.all( - Array.from({ length: N }, (_, i) => - updateSessionStore(storePath, async (store) => { - const entry = store[key] as Record; - // Keep an async boundary so stale-read races would surface without serialization. - await Promise.resolve(); - entry.counter = (entry.counter as number) + 1; - entry.tag = `writer-${i}`; - }), - ), - ); - - const store = loadSessionStore(storePath); - expect((store[key] as Record).counter).toBe(N); - }); - - it("concurrent updateSessionStoreEntry patches all merge correctly", async () => { - const key = "agent:main:merge"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - await Promise.all([ - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { modelOverride: "model-a" }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { thinkingLevel: "high" as const }; - }, - }), - updateSessionStoreEntry({ - storePath, - sessionKey: key, - update: async () => { - await Promise.resolve(); - return { systemPromptOverride: "custom" }; - }, - }), - ]); - - const store = loadSessionStore(storePath); - const entry = store[key]; - expect(entry.modelOverride).toBe("model-a"); - expect(entry.thinkingLevel).toBe("high"); - expect(entry.systemPromptOverride).toBe("custom"); - }); - - // ── 2. Error in fn() does not break queue ─────────────────────────── - - it("continues processing queued tasks after a preceding task throws", async () => { - const key = "agent:main:err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errorPromise = updateSessionStore(storePath, async () => { - throw new Error("boom"); - }); - - // Queue a second write immediately after the failing one. - const successPromise = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "after-error" } as unknown as SessionEntry; - }); - - await expect(errorPromise).rejects.toThrow("boom"); - await successPromise; // must resolve, not hang or reject - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("after-error"); - }); - - it("multiple consecutive errors do not permanently poison the queue", async () => { - const key = "agent:main:multi-err"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const errors = Array.from({ length: 3 }, (_, i) => - updateSessionStore(storePath, async () => { - throw new Error(`fail-${i}`); - }), - ); - - const success = updateSessionStore(storePath, async (store) => { - store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry; - }); - - // All error promises reject. - for (const p of errors) { - await expect(p).rejects.toThrow(); - } - // The trailing write succeeds. - await success; - - const store = loadSessionStore(storePath); - expect(store[key]?.modelOverride).toBe("recovered"); - }); - - // ── 3. Different storePaths run independently / in parallel ───────── - - it("operations on different storePaths execute concurrently", async () => { - const { storePath: pathA } = await makeTmpStore({ - a: { sessionId: "a", updatedAt: 100 }, - }); - const { storePath: pathB } = await makeTmpStore({ - b: { sessionId: "b", updatedAt: 100 }, - }); - - const order: string[] = []; - let started = 0; - let releaseBoth: (() => void) | undefined; - const gate = new Promise((resolve) => { - releaseBoth = resolve; - }); - const markStarted = () => { - started += 1; - if (started === 2) { - releaseBoth?.(); - } - }; - - const opA = updateSessionStore(pathA, async (store) => { - order.push("a-start"); - markStarted(); - await gate; - store.a = { ...store.a, modelOverride: "done-a" } as unknown as SessionEntry; - order.push("a-end"); - }); - - const opB = updateSessionStore(pathB, async (store) => { - order.push("b-start"); - markStarted(); - await gate; - store.b = { ...store.b, modelOverride: "done-b" } as unknown as SessionEntry; - order.push("b-end"); - }); - - await Promise.all([opA, opB]); - - // Parallel behavior: both ops start before either one finishes. - const aStart = order.indexOf("a-start"); - const bStart = order.indexOf("b-start"); - const aEnd = order.indexOf("a-end"); - const bEnd = order.indexOf("b-end"); - const firstEnd = Math.min(aEnd, bEnd); - expect(aStart).toBeGreaterThanOrEqual(0); - expect(bStart).toBeGreaterThanOrEqual(0); - expect(aEnd).toBeGreaterThanOrEqual(0); - expect(bEnd).toBeGreaterThanOrEqual(0); - expect(aStart).toBeLessThan(firstEnd); - expect(bStart).toBeLessThan(firstEnd); - - expect(loadSessionStore(pathA).a?.modelOverride).toBe("done-a"); - expect(loadSessionStore(pathB).b?.modelOverride).toBe("done-b"); - }); - - // ── 4. LOCK_QUEUES cleanup ───────────────────────────────────────── - - it("cleans up LOCK_QUEUES entry after all tasks complete", async () => { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - - await updateSessionStore(storePath, async (store) => { - store.x = { ...store.x, modelOverride: "done" } as unknown as SessionEntry; - }); - - // Allow microtask (finally) to run. - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - it("cleans up LOCK_QUEUES entry even after errors", async () => { - const { storePath } = await makeTmpStore({}); - - await updateSessionStore(storePath, async () => { - throw new Error("fail"); - }).catch(() => undefined); - - await Promise.resolve(); - - expect(getSessionStoreLockQueueSizeForTest()).toBe(0); - }); - - // ── 5. FIFO order guarantee ────────────────────────────────────────── - - it("executes queued operations in FIFO order", async () => { - const key = "agent:main:fifo"; - const { storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100, order: "" }, - }); - - const executionOrder: number[] = []; - - // Queue 5 operations sequentially (no awaiting in between). - const promises = Array.from({ length: 5 }, (_, i) => - updateSessionStore(storePath, async (store) => { - executionOrder.push(i); - const entry = store[key] as Record; - entry.order = ((entry.order as string) || "") + String(i); - }), - ); - - await Promise.all(promises); - - // Execution order must be 0, 1, 2, 3, 4 (FIFO). - expect(executionOrder).toEqual([0, 1, 2, 3, 4]); - - // The store should reflect sequential application. - const store = loadSessionStore(storePath); - expect((store[key] as Record).order).toBe("01234"); - }); - - it("times out queued operations strictly and does not run them later", async () => { - vi.useFakeTimers(); - try { - const { storePath } = await makeTmpStore({ - x: { sessionId: "x", updatedAt: 100 }, - }); - let timedOutRan = false; - - const releaseLock = createDeferred(); - const lockStarted = createDeferred(); - const lockHolder = withSessionStoreLockForTest( - storePath, - async () => { - lockStarted.resolve(); - await releaseLock.promise; - }, - { timeoutMs: 1_000 }, - ); - await lockStarted.promise; - const timedOut = withSessionStoreLockForTest( - storePath, - async () => { - timedOutRan = true; - }, - { timeoutMs: 5 }, - ); - - // Attach rejection handler before advancing fake timers to avoid unhandled rejections. - const timedOutExpectation = expect(timedOut).rejects.toThrow( - "timeout waiting for session store lock", - ); - await vi.advanceTimersByTimeAsync(5); - await timedOutExpectation; - releaseLock.resolve(); - await lockHolder; - await vi.runOnlyPendingTimersAsync(); - expect(timedOutRan).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("creates and removes lock file while operation runs", async () => { - const key = "agent:main:no-lock-file"; - const { dir, storePath } = await makeTmpStore({ - [key]: { sessionId: "s1", updatedAt: 100 }, - }); - - const lockPath = `${storePath}.lock`; - const allowWrite = createDeferred(); - const writeStarted = createDeferred(); - const write = updateSessionStore(storePath, async (store) => { - writeStarted.resolve(); - await allowWrite.promise; - store[key] = { ...store[key], modelOverride: "v" } as unknown as SessionEntry; - }); - - await writeStarted.promise; - await fs.access(lockPath); - allowWrite.resolve(); - await write; - - const files = await fs.readdir(dir); - const lockFiles = files.filter((f) => f.endsWith(".lock")); - expect(lockFiles).toHaveLength(0); - }); -}); diff --git a/src/config/sessions/store.undefined-path.test.ts b/src/config/sessions/store.undefined-path.test.ts deleted file mode 100644 index 8d0bc1b05be..00000000000 --- a/src/config/sessions/store.undefined-path.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Regression test for #14717: path.dirname(undefined) crash in withSessionStoreLock - * - * When a channel plugin passes undefined as storePath to recordSessionMetaFromInbound, - * the call chain reaches withSessionStoreLock → path.dirname(undefined) → TypeError crash. - * After fix, a clear Error is thrown instead of an unhandled TypeError. - */ -import { describe, expect, it } from "vitest"; -import { updateSessionStore } from "./store.js"; - -describe("withSessionStoreLock storePath guard (#14717)", () => { - it("throws descriptive error when storePath is undefined", async () => { - await expect( - updateSessionStore(undefined as unknown as string, (store) => store), - ).rejects.toThrow("withSessionStoreLock: storePath must be a non-empty string"); - }); - - it("throws descriptive error when storePath is empty string", async () => { - await expect(updateSessionStore("", (store) => store)).rejects.toThrow( - "withSessionStoreLock: storePath must be a non-empty string", - ); - }); -}); diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts deleted file mode 100644 index 540ebd04752..00000000000 --- a/src/config/sessions/transcript.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - appendAssistantMessageToSessionTranscript, - resolveMirroredTranscriptText, -} from "./transcript.js"; - -describe("resolveMirroredTranscriptText", () => { - it("prefers media filenames over text", () => { - const result = resolveMirroredTranscriptText({ - text: "caption here", - mediaUrls: ["https://example.com/files/report.pdf?sig=123"], - }); - expect(result).toBe("report.pdf"); - }); - - it("returns trimmed text when no media", () => { - const result = resolveMirroredTranscriptText({ text: " hello " }); - expect(result).toBe("hello"); - }); -}); - -describe("appendAssistantMessageToSessionTranscript", () => { - let tempDir: string; - let storePath: string; - let sessionsDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-")); - sessionsDir = path.join(tempDir, "agents", "main", "sessions"); - fs.mkdirSync(sessionsDir, { recursive: true }); - storePath = path.join(sessionsDir, "sessions.json"); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it("returns error for missing sessionKey", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "", - text: "test", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("missing sessionKey"); - } - }); - - it("returns error for empty text", async () => { - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "test-session", - text: " ", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe("empty text"); - } - }); - - it("returns error for unknown sessionKey", async () => { - fs.writeFileSync(storePath, JSON.stringify({}), "utf-8"); - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "nonexistent", - text: "test message", - storePath, - }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toContain("unknown sessionKey"); - } - }); - - it("creates transcript file and appends message for valid session", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(storePath, JSON.stringify(store), "utf-8"); - - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - storePath, - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(fs.existsSync(result.sessionFile)).toBe(true); - - const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(2); // header + message - - const header = JSON.parse(lines[0]); - expect(header.type).toBe("session"); - expect(header.id).toBe(sessionId); - - const messageLine = JSON.parse(lines[1]); - expect(messageLine.type).toBe("message"); - expect(messageLine.message.role).toBe("assistant"); - expect(messageLine.message.content[0].type).toBe("text"); - expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); - } - }); -}); From 36b5f0c9a8e68b24c0921e1296e38db3c0f5f0d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:32:12 +0000 Subject: [PATCH 048/178] perf(test): consolidate gateway server-methods suites --- src/gateway/server-methods/agent-job.test.ts | 37 -- .../server-methods/agent-timestamp.test.ts | 143 ----- .../attachment-normalize.test.ts | 19 - .../chat.sanitize-message.test.ts | 20 - .../chat.transcript-writes.guardrail.test.ts | 23 - .../server-methods/exec-approval.test.ts | 285 ---------- src/gateway/server-methods/logs.test.ts | 49 -- .../server-methods/server-methods.test.ts | 490 ++++++++++++++++++ 8 files changed, 490 insertions(+), 576 deletions(-) delete mode 100644 src/gateway/server-methods/agent-job.test.ts delete mode 100644 src/gateway/server-methods/agent-timestamp.test.ts delete mode 100644 src/gateway/server-methods/attachment-normalize.test.ts delete mode 100644 src/gateway/server-methods/chat.sanitize-message.test.ts delete mode 100644 src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts delete mode 100644 src/gateway/server-methods/exec-approval.test.ts delete mode 100644 src/gateway/server-methods/logs.test.ts create mode 100644 src/gateway/server-methods/server-methods.test.ts diff --git a/src/gateway/server-methods/agent-job.test.ts b/src/gateway/server-methods/agent-job.test.ts deleted file mode 100644 index d696d9e0830..00000000000 --- a/src/gateway/server-methods/agent-job.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { emitAgentEvent } from "../../infra/agent-events.js"; -import { waitForAgentJob } from "./agent-job.js"; - -describe("waitForAgentJob", () => { - it("maps lifecycle end events with aborted=true to timeout", async () => { - const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); - - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } }); - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { phase: "end", endedAt: 200, aborted: true }, - }); - - const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("timeout"); - expect(snapshot?.startedAt).toBe(100); - expect(snapshot?.endedAt).toBe(200); - }); - - it("keeps non-aborted lifecycle end events as ok", async () => { - const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); - - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } }); - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } }); - - const snapshot = await waitPromise; - expect(snapshot).not.toBeNull(); - expect(snapshot?.status).toBe("ok"); - expect(snapshot?.startedAt).toBe(300); - expect(snapshot?.endedAt).toBe(400); - }); -}); diff --git a/src/gateway/server-methods/agent-timestamp.test.ts b/src/gateway/server-methods/agent-timestamp.test.ts deleted file mode 100644 index 1482194c2eb..00000000000 --- a/src/gateway/server-methods/agent-timestamp.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; -import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; - -describe("injectTimestamp", () => { - beforeEach(() => { - vi.useFakeTimers(); - // Wednesday, January 28, 2026 at 8:30 PM EST (01:30 UTC Jan 29) - vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("prepends a compact timestamp matching formatZonedTimestamp", () => { - const result = injectTimestamp("Is it the weekend?", { - timezone: "America/New_York", - }); - - expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); - }); - - it("uses channel envelope format with DOW prefix", () => { - const now = new Date(); - const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" }); - - const result = injectTimestamp("hello", { timezone: "America/New_York" }); - - // DOW prefix + formatZonedTimestamp format - expect(result).toBe(`[Wed ${expected}] hello`); - }); - - it("always uses 24-hour format", () => { - const result = injectTimestamp("hello", { timezone: "America/New_York" }); - - expect(result).toContain("20:30"); - expect(result).not.toContain("PM"); - expect(result).not.toContain("AM"); - }); - - it("uses the configured timezone", () => { - const result = injectTimestamp("hello", { timezone: "America/Chicago" }); - - // 8:30 PM EST = 7:30 PM CST = 19:30 - expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/); - }); - - it("defaults to UTC when no timezone specified", () => { - const result = injectTimestamp("hello", {}); - - // 2026-01-29T01:30:00Z - expect(result).toMatch(/^\[Thu 2026-01-29 01:30/); - }); - - it("returns empty/whitespace messages unchanged", () => { - expect(injectTimestamp("", { timezone: "UTC" })).toBe(""); - expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" "); - }); - - it("does NOT double-stamp messages with channel envelope timestamps", () => { - const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there"; - const result = injectTimestamp(enveloped, { timezone: "America/New_York" }); - - expect(result).toBe(enveloped); - }); - - it("does NOT double-stamp messages already injected by us", () => { - const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there"; - const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" }); - - expect(result).toBe(alreadyStamped); - }); - - it("does NOT double-stamp messages with cron-injected timestamps", () => { - const cronMessage = - "[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; - const result = injectTimestamp(cronMessage, { timezone: "America/New_York" }); - - expect(result).toBe(cronMessage); - }); - - it("handles midnight correctly", () => { - vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); // midnight EST - - const result = injectTimestamp("hello", { timezone: "America/New_York" }); - - expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/); - }); - - it("handles date boundaries (just before midnight)", () => { - vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); // 23:59 Jan 31 EST - - const result = injectTimestamp("hello", { timezone: "America/New_York" }); - - expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/); - }); - - it("handles DST correctly (same UTC hour, different local time)", () => { - // EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15 - vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z")); - const winter = injectTimestamp("winter", { timezone: "America/New_York" }); - expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/); - - // EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15 - vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z")); - const summer = injectTimestamp("summer", { timezone: "America/New_York" }); - expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/); - }); - - it("accepts a custom now date", () => { - const customDate = new Date("2025-07-04T16:00:00.000Z"); // July 4, noon ET - - const result = injectTimestamp("fireworks?", { - timezone: "America/New_York", - now: customDate, - }); - - expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/); - }); -}); - -describe("timestampOptsFromConfig", () => { - it("extracts timezone from config", () => { - const opts = timestampOptsFromConfig({ - agents: { - defaults: { - userTimezone: "America/Chicago", - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any); - - expect(opts.timezone).toBe("America/Chicago"); - }); - - it("falls back gracefully with empty config", () => { - // oxlint-disable-next-line typescript/no-explicit-any - const opts = timestampOptsFromConfig({} as any); - - expect(opts.timezone).toBeDefined(); // resolveUserTimezone provides a default - }); -}); diff --git a/src/gateway/server-methods/attachment-normalize.test.ts b/src/gateway/server-methods/attachment-normalize.test.ts deleted file mode 100644 index 159bae80492..00000000000 --- a/src/gateway/server-methods/attachment-normalize.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; - -describe("normalizeRpcAttachmentsToChatAttachments", () => { - it("passes through string content", () => { - const res = normalizeRpcAttachmentsToChatAttachments([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - expect(res).toEqual([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - }); - - it("converts Uint8Array content to base64", () => { - const bytes = new TextEncoder().encode("foo"); - const res = normalizeRpcAttachmentsToChatAttachments([{ content: bytes }]); - expect(res[0]?.content).toBe("Zm9v"); - }); -}); diff --git a/src/gateway/server-methods/chat.sanitize-message.test.ts b/src/gateway/server-methods/chat.sanitize-message.test.ts deleted file mode 100644 index dd41d4c883e..00000000000 --- a/src/gateway/server-methods/chat.sanitize-message.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeChatSendMessageInput } from "./chat.js"; - -describe("sanitizeChatSendMessageInput", () => { - it("rejects null bytes", () => { - expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({ - ok: false, - error: "message must not contain null bytes", - }); - }); - - it("strips unsafe control characters while preserving tab/newline/carriage return", () => { - const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f"); - expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" }); - }); - - it("normalizes unicode to NFC", () => { - expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" }); - }); -}); diff --git a/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts b/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts deleted file mode 100644 index d6b098dc28f..00000000000 --- a/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import fs from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -// Guardrail: the "empty post-compaction context" regression came from gateway code appending -// Pi transcript message entries as raw JSONL without `parentId`. -// -// This test is intentionally simple and file-local: if someone reintroduces direct JSONL appends -// against `transcriptPath`, Pi's SessionManager parent chain can break again. -describe("gateway chat transcript writes (guardrail)", () => { - it("does not append transcript messages via raw fs.appendFileSync(transcriptPath, ...)", () => { - const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url)); - const src = fs.readFileSync(chatTs, "utf-8"); - - // Disallow raw appends against the resolved transcript path variable. - // (The transcript header creation via writeFileSync is OK; the bug class is raw message appends.) - expect(src.includes("fs.appendFileSync(transcriptPath")).toBe(false); - - // Ensure we keep using SessionManager for transcript message appends. - expect(src).toContain("SessionManager.open(transcriptPath)"); - expect(src).toContain("appendMessage("); - }); -}); diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts deleted file mode 100644 index ac0373343b0..00000000000 --- a/src/gateway/server-methods/exec-approval.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { ExecApprovalManager } from "../exec-approval-manager.js"; -import { validateExecApprovalRequestParams } from "../protocol/index.js"; -import { createExecApprovalHandlers } from "./exec-approval.js"; - -const noop = () => {}; - -describe("exec approval handlers", () => { - describe("ExecApprovalRequestParams validation", () => { - it("accepts request with resolvedPath omitted", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - host: "node", - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); - - it("accepts request with resolvedPath as string", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - host: "node", - resolvedPath: "/usr/bin/echo", - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); - - it("accepts request with resolvedPath as undefined", () => { - const params = { - command: "echo hi", - cwd: "/tmp", - host: "node", - resolvedPath: undefined, - }; - expect(validateExecApprovalRequestParams(params)).toBe(true); - }); - - // 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(true); - }); - }); - - it("broadcasts request + resolve", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - - const respond = vi.fn(); - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - command: "echo ok", - cwd: "/tmp", - host: "node", - timeoutMs: 2000, - twoPhase: true, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - expect(requested).toBeTruthy(); - const id = (requested?.payload as { id?: string })?.id ?? ""; - expect(id).not.toBe(""); - - // First response should be "accepted" (registration confirmation) - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ status: "accepted", id }), - undefined, - ); - - const resolveRespond = vi.fn(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - - expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - // Second response should contain the decision - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ id, decision: "allow-once" }), - undefined, - ); - expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); - }); - - it("accepts resolve during broadcast", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const respond = vi.fn(); - const resolveRespond = vi.fn(); - - const resolveContext = { - broadcast: () => {}, - }; - - const context = { - broadcast: (event: string, payload: unknown) => { - if (event !== "exec.approval.requested") { - return; - } - const id = (payload as { id?: string })?.id ?? ""; - void handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: resolveContext as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - }, - }; - - await handlers["exec.approval.request"]({ - params: { - command: "echo ok", - cwd: "/tmp", - host: "node", - timeoutMs: 2000, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ decision: "allow-once" }), - undefined, - ); - }); - - it("accepts explicit approval ids", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - - const respond = vi.fn(); - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - id: "approval-123", - command: "echo ok", - cwd: "/tmp", - host: "gateway", - timeoutMs: 2000, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - const id = (requested?.payload as { id?: string })?.id ?? ""; - expect(id).toBe("approval-123"); - - const resolveRespond = vi.fn(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ id: "approval-123", decision: "allow-once" }), - undefined, - ); - }); - - it("rejects duplicate approval ids", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const respondA = vi.fn(); - const respondB = vi.fn(); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - id: "dup-1", - command: "echo ok", - }, - respond: respondA, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - await handlers["exec.approval.request"]({ - params: { - id: "dup-1", - command: "echo again", - }, - respond: respondB, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-2", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - expect(respondB).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: "approval id already pending" }), - ); - - const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - const id = (requested?.payload as { id?: string })?.id ?? ""; - const resolveRespond = vi.fn(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "deny" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-3", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - }); -}); diff --git a/src/gateway/server-methods/logs.test.ts b/src/gateway/server-methods/logs.test.ts deleted file mode 100644 index fd9a46f920b..00000000000 --- a/src/gateway/server-methods/logs.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../../logging.js"; -import { logsHandlers } from "./logs.js"; - -const noop = () => false; - -describe("logs.tail", () => { - afterEach(() => { - resetLogger(); - setLoggerOverride(null); - }); - - it("falls back to latest rolling log file when today is missing", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-")); - const older = path.join(tempDir, "openclaw-2026-01-20.log"); - const newer = path.join(tempDir, "openclaw-2026-01-21.log"); - - await fs.writeFile(older, '{"msg":"old"}\n'); - await fs.writeFile(newer, '{"msg":"new"}\n'); - await fs.utimes(older, new Date(0), new Date(0)); - await fs.utimes(newer, new Date(), new Date()); - - setLoggerOverride({ file: path.join(tempDir, "openclaw-2026-01-22.log") }); - - const respond = vi.fn(); - await logsHandlers["logs.tail"]({ - params: {}, - respond, - context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "logs.tail" }, - isWebchatConnect: noop, - }); - - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - file: newer, - lines: ['{"msg":"new"}'], - }), - undefined, - ); - - await fs.rm(tempDir, { recursive: true, force: true }); - }); -}); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts new file mode 100644 index 00000000000..dfc11df9436 --- /dev/null +++ b/src/gateway/server-methods/server-methods.test.ts @@ -0,0 +1,490 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { emitAgentEvent } from "../../infra/agent-events.js"; +import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; +import { resetLogger, setLoggerOverride } from "../../logging.js"; +import { ExecApprovalManager } from "../exec-approval-manager.js"; +import { validateExecApprovalRequestParams } from "../protocol/index.js"; +import { waitForAgentJob } from "./agent-job.js"; +import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; +import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; +import { sanitizeChatSendMessageInput } from "./chat.js"; +import { createExecApprovalHandlers } from "./exec-approval.js"; +import { logsHandlers } from "./logs.js"; + +describe("waitForAgentJob", () => { + it("maps lifecycle end events with aborted=true to timeout", async () => { + const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); + + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } }); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "end", endedAt: 200, aborted: true }, + }); + + const snapshot = await waitPromise; + expect(snapshot).not.toBeNull(); + expect(snapshot?.status).toBe("timeout"); + expect(snapshot?.startedAt).toBe(100); + expect(snapshot?.endedAt).toBe(200); + }); + + it("keeps non-aborted lifecycle end events as ok", async () => { + const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); + + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } }); + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } }); + + const snapshot = await waitPromise; + expect(snapshot).not.toBeNull(); + expect(snapshot?.status).toBe("ok"); + expect(snapshot?.startedAt).toBe(300); + expect(snapshot?.endedAt).toBe(400); + }); +}); + +describe("injectTimestamp", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("prepends a compact timestamp matching formatZonedTimestamp", () => { + const result = injectTimestamp("Is it the weekend?", { + timezone: "America/New_York", + }); + + expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); + }); + + it("uses channel envelope format with DOW prefix", () => { + const now = new Date(); + const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" }); + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toBe(`[Wed ${expected}] hello`); + }); + + it("always uses 24-hour format", () => { + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toContain("20:30"); + expect(result).not.toContain("PM"); + expect(result).not.toContain("AM"); + }); + + it("uses the configured timezone", () => { + const result = injectTimestamp("hello", { timezone: "America/Chicago" }); + + expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/); + }); + + it("defaults to UTC when no timezone specified", () => { + const result = injectTimestamp("hello", {}); + + expect(result).toMatch(/^\[Thu 2026-01-29 01:30/); + }); + + it("returns empty/whitespace messages unchanged", () => { + expect(injectTimestamp("", { timezone: "UTC" })).toBe(""); + expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" "); + }); + + it("does NOT double-stamp messages with channel envelope timestamps", () => { + const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there"; + const result = injectTimestamp(enveloped, { timezone: "America/New_York" }); + + expect(result).toBe(enveloped); + }); + + it("does NOT double-stamp messages already injected by us", () => { + const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there"; + const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" }); + + expect(result).toBe(alreadyStamped); + }); + + it("does NOT double-stamp messages with cron-injected timestamps", () => { + const cronMessage = + "[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; + const result = injectTimestamp(cronMessage, { timezone: "America/New_York" }); + + expect(result).toBe(cronMessage); + }); + + it("handles midnight correctly", () => { + vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z")); + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/); + }); + + it("handles date boundaries (just before midnight)", () => { + vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z")); + + const result = injectTimestamp("hello", { timezone: "America/New_York" }); + + expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/); + }); + + it("handles DST correctly (same UTC hour, different local time)", () => { + vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z")); + const winter = injectTimestamp("winter", { timezone: "America/New_York" }); + expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/); + + vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z")); + const summer = injectTimestamp("summer", { timezone: "America/New_York" }); + expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/); + }); + + it("accepts a custom now date", () => { + const customDate = new Date("2025-07-04T16:00:00.000Z"); + + const result = injectTimestamp("fireworks?", { + timezone: "America/New_York", + now: customDate, + }); + + expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/); + }); +}); + +describe("timestampOptsFromConfig", () => { + it("extracts timezone from config", () => { + const opts = timestampOptsFromConfig({ + agents: { + defaults: { + userTimezone: "America/Chicago", + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + + expect(opts.timezone).toBe("America/Chicago"); + }); + + it("falls back gracefully with empty config", () => { + // oxlint-disable-next-line typescript/no-explicit-any + const opts = timestampOptsFromConfig({} as any); + + expect(opts.timezone).toBeDefined(); + }); +}); + +describe("normalizeRpcAttachmentsToChatAttachments", () => { + it("passes through string content", () => { + const res = normalizeRpcAttachmentsToChatAttachments([ + { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, + ]); + expect(res).toEqual([ + { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, + ]); + }); + + it("converts Uint8Array content to base64", () => { + const bytes = new TextEncoder().encode("foo"); + const res = normalizeRpcAttachmentsToChatAttachments([{ content: bytes }]); + expect(res[0]?.content).toBe("Zm9v"); + }); +}); + +describe("sanitizeChatSendMessageInput", () => { + it("rejects null bytes", () => { + expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({ + ok: false, + error: "message must not contain null bytes", + }); + }); + + it("strips unsafe control characters while preserving tab/newline/carriage return", () => { + const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f"); + expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" }); + }); + + it("normalizes unicode to NFC", () => { + expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" }); + }); +}); + +describe("gateway chat transcript writes (guardrail)", () => { + it("does not append transcript messages via raw fs.appendFileSync(transcriptPath, ...)", () => { + const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url)); + const src = fs.readFileSync(chatTs, "utf-8"); + + expect(src.includes("fs.appendFileSync(transcriptPath")).toBe(false); + + expect(src).toContain("SessionManager.open(transcriptPath)"); + expect(src).toContain("appendMessage("); + }); +}); + +describe("exec approval handlers", () => { + const execApprovalNoop = () => {}; + + describe("ExecApprovalRequestParams validation", () => { + it("accepts request with resolvedPath omitted", () => { + const params = { + command: "echo hi", + cwd: "/tmp", + host: "node", + }; + expect(validateExecApprovalRequestParams(params)).toBe(true); + }); + + it("accepts request with resolvedPath as string", () => { + const params = { + command: "echo hi", + cwd: "/tmp", + host: "node", + resolvedPath: "/usr/bin/echo", + }; + expect(validateExecApprovalRequestParams(params)).toBe(true); + }); + + it("accepts request with resolvedPath as undefined", () => { + const params = { + command: "echo hi", + cwd: "/tmp", + host: "node", + resolvedPath: undefined, + }; + expect(validateExecApprovalRequestParams(params)).toBe(true); + }); + + it("accepts request with resolvedPath as null", () => { + const params = { + command: "echo hi", + cwd: "/tmp", + host: "node", + resolvedPath: null, + }; + expect(validateExecApprovalRequestParams(params)).toBe(true); + }); + }); + + it("broadcasts request + resolve", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + + const respond = vi.fn(); + const context = { + broadcast: (event: string, payload: unknown) => { + broadcasts.push({ event, payload }); + }, + }; + + const requestPromise = handlers["exec.approval.request"]({ + params: { + command: "echo ok", + cwd: "/tmp", + host: "node", + timeoutMs: 2000, + twoPhase: true, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + expect(requested).toBeTruthy(); + const id = (requested?.payload as { id?: string })?.id ?? ""; + expect(id).not.toBe(""); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ status: "accepted", id }), + undefined, + ); + + const resolveRespond = vi.fn(); + await handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + + await requestPromise; + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id, decision: "allow-once" }), + undefined, + ); + expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); + }); + + it("accepts resolve during broadcast", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const respond = vi.fn(); + const resolveRespond = vi.fn(); + + const resolveContext = { + broadcast: () => {}, + }; + + const context = { + broadcast: (event: string, payload: unknown) => { + if (event !== "exec.approval.requested") { + return; + } + const id = (payload as { id?: string })?.id ?? ""; + void handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: resolveContext as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + }, + }; + + await handlers["exec.approval.request"]({ + params: { + command: "echo ok", + cwd: "/tmp", + host: "node", + timeoutMs: 2000, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ decision: "allow-once" }), + undefined, + ); + }); + + it("accepts explicit approval ids", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + + const respond = vi.fn(); + const context = { + broadcast: (event: string, payload: unknown) => { + broadcasts.push({ event, payload }); + }, + }; + + const requestPromise = handlers["exec.approval.request"]({ + params: { + id: "approval-123", + command: "echo ok", + cwd: "/tmp", + host: "gateway", + timeoutMs: 2000, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); + const id = (requested?.payload as { id?: string })?.id ?? ""; + expect(id).toBe("approval-123"); + + const resolveRespond = vi.fn(); + await handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + + await requestPromise; + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-123", decision: "allow-once" }), + undefined, + ); + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); +}); + +describe("logs.tail", () => { + const logsNoop = () => false; + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + }); + + it("falls back to latest rolling log file when today is missing", async () => { + const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-")); + const older = path.join(tempDir, "openclaw-2026-01-20.log"); + const newer = path.join(tempDir, "openclaw-2026-01-21.log"); + + await fsPromises.writeFile(older, '{"msg":"old"}\n'); + await fsPromises.writeFile(newer, '{"msg":"new"}\n'); + await fsPromises.utimes(older, new Date(0), new Date(0)); + await fsPromises.utimes(newer, new Date(), new Date()); + + setLoggerOverride({ file: path.join(tempDir, "openclaw-2026-01-22.log") }); + + const respond = vi.fn(); + await logsHandlers["logs.tail"]({ + params: {}, + respond, + context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "logs.tail" }, + isWebchatConnect: logsNoop, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + file: newer, + lines: ['{"msg":"new"}'], + }), + undefined, + ); + + await fsPromises.rm(tempDir, { recursive: true, force: true }); + }); +}); From 34b088ede63d23190f81c7e38518a92df9bcf77c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:39:22 +0000 Subject: [PATCH 049/178] perf(test): consolidate infra outbound suites --- src/infra/outbound/delivery-queue.test.ts | 373 ------- src/infra/outbound/directory-cache.test.ts | 45 - src/infra/outbound/envelope.test.ts | 64 -- src/infra/outbound/format.test.ts | 107 -- src/infra/outbound/outbound-policy.test.ts | 93 -- src/infra/outbound/outbound-session.test.ts | 116 -- src/infra/outbound/outbound.test.ts | 1084 +++++++++++++++++++ src/infra/outbound/payloads.test.ts | 77 -- src/infra/outbound/targets.test.ts | 211 ---- 9 files changed, 1084 insertions(+), 1086 deletions(-) delete mode 100644 src/infra/outbound/delivery-queue.test.ts delete mode 100644 src/infra/outbound/directory-cache.test.ts delete mode 100644 src/infra/outbound/envelope.test.ts delete mode 100644 src/infra/outbound/format.test.ts delete mode 100644 src/infra/outbound/outbound-policy.test.ts delete mode 100644 src/infra/outbound/outbound-session.test.ts create mode 100644 src/infra/outbound/outbound.test.ts delete mode 100644 src/infra/outbound/payloads.test.ts delete mode 100644 src/infra/outbound/targets.test.ts diff --git a/src/infra/outbound/delivery-queue.test.ts b/src/infra/outbound/delivery-queue.test.ts deleted file mode 100644 index ee94d13b62b..00000000000 --- a/src/infra/outbound/delivery-queue.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - ackDelivery, - computeBackoffMs, - enqueueDelivery, - failDelivery, - loadPendingDeliveries, - MAX_RETRIES, - moveToFailed, - recoverPendingDeliveries, -} from "./delivery-queue.js"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-dq-test-")); -}); - -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("enqueue + ack lifecycle", () => { - it("creates and removes a queue entry", async () => { - const id = await enqueueDelivery( - { - channel: "whatsapp", - to: "+1555", - payloads: [{ text: "hello" }], - bestEffort: true, - gifPlayback: true, - silent: true, - mirror: { - sessionKey: "agent:main:main", - text: "hello", - mediaUrls: ["https://example.com/file.png"], - }, - }, - tmpDir, - ); - - // Entry file exists after enqueue. - const queueDir = path.join(tmpDir, "delivery-queue"); - const files = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); - expect(files).toHaveLength(1); - expect(files[0]).toBe(`${id}.json`); - - // Entry contents are correct. - const entry = JSON.parse(fs.readFileSync(path.join(queueDir, files[0]), "utf-8")); - expect(entry).toMatchObject({ - id, - channel: "whatsapp", - to: "+1555", - bestEffort: true, - gifPlayback: true, - silent: true, - mirror: { - sessionKey: "agent:main:main", - text: "hello", - mediaUrls: ["https://example.com/file.png"], - }, - retryCount: 0, - }); - expect(entry.payloads).toEqual([{ text: "hello" }]); - - // Ack removes the file. - await ackDelivery(id, tmpDir); - const remaining = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); - expect(remaining).toHaveLength(0); - }); - - it("ack is idempotent (no error on missing file)", async () => { - await expect(ackDelivery("nonexistent-id", tmpDir)).resolves.toBeUndefined(); - }); -}); - -describe("failDelivery", () => { - it("increments retryCount and sets lastError", async () => { - const id = await enqueueDelivery( - { - channel: "telegram", - to: "123", - payloads: [{ text: "test" }], - }, - tmpDir, - ); - - await failDelivery(id, "connection refused", tmpDir); - - const queueDir = path.join(tmpDir, "delivery-queue"); - const entry = JSON.parse(fs.readFileSync(path.join(queueDir, `${id}.json`), "utf-8")); - expect(entry.retryCount).toBe(1); - expect(entry.lastError).toBe("connection refused"); - }); -}); - -describe("moveToFailed", () => { - it("moves entry to failed/ subdirectory", async () => { - const id = await enqueueDelivery( - { - channel: "slack", - to: "#general", - payloads: [{ text: "hi" }], - }, - tmpDir, - ); - - await moveToFailed(id, tmpDir); - - const queueDir = path.join(tmpDir, "delivery-queue"); - const failedDir = path.join(queueDir, "failed"); - expect(fs.existsSync(path.join(queueDir, `${id}.json`))).toBe(false); - expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); - }); -}); - -describe("loadPendingDeliveries", () => { - it("returns empty array when queue directory does not exist", async () => { - const nonexistent = path.join(tmpDir, "no-such-dir"); - const entries = await loadPendingDeliveries(nonexistent); - expect(entries).toEqual([]); - }); - - it("loads multiple entries", async () => { - await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); - await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); - - const entries = await loadPendingDeliveries(tmpDir); - expect(entries).toHaveLength(2); - }); -}); - -describe("computeBackoffMs", () => { - it("returns 0 for retryCount 0", () => { - expect(computeBackoffMs(0)).toBe(0); - }); - - it("returns correct backoff for each retry", () => { - expect(computeBackoffMs(1)).toBe(5_000); - expect(computeBackoffMs(2)).toBe(25_000); - expect(computeBackoffMs(3)).toBe(120_000); - expect(computeBackoffMs(4)).toBe(600_000); - // Beyond defined schedule — clamps to last value. - expect(computeBackoffMs(5)).toBe(600_000); - }); -}); - -describe("recoverPendingDeliveries", () => { - const noopDelay = async () => {}; - const baseCfg = {}; - - it("recovers entries from a simulated crash", async () => { - // Manually create two queue entries as if gateway crashed before delivery. - await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); - await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); - - const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(deliver).toHaveBeenCalledTimes(2); - expect(result.recovered).toBe(2); - expect(result.failed).toBe(0); - expect(result.skipped).toBe(0); - - // Queue should be empty after recovery. - const remaining = await loadPendingDeliveries(tmpDir); - expect(remaining).toHaveLength(0); - }); - - it("moves entries that exceeded max retries to failed/", async () => { - // Create an entry and manually set retryCount to MAX_RETRIES. - const id = await enqueueDelivery( - { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, - tmpDir, - ); - const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); - const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")); - entry.retryCount = MAX_RETRIES; - fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); - - const deliver = vi.fn(); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(deliver).not.toHaveBeenCalled(); - expect(result.skipped).toBe(1); - - // Entry should be in failed/ directory. - const failedDir = path.join(tmpDir, "delivery-queue", "failed"); - expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); - }); - - it("increments retryCount on failed recovery attempt", async () => { - await enqueueDelivery({ channel: "slack", to: "#ch", payloads: [{ text: "x" }] }, tmpDir); - - const deliver = vi.fn().mockRejectedValue(new Error("network down")); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(result.failed).toBe(1); - expect(result.recovered).toBe(0); - - // Entry should still be in queue with incremented retryCount. - const entries = await loadPendingDeliveries(tmpDir); - expect(entries).toHaveLength(1); - expect(entries[0].retryCount).toBe(1); - expect(entries[0].lastError).toBe("network down"); - }); - - it("passes skipQueue: true to prevent re-enqueueing during recovery", async () => { - await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); - - const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ skipQueue: true })); - }); - - it("replays stored delivery options during recovery", async () => { - await enqueueDelivery( - { - channel: "whatsapp", - to: "+1", - payloads: [{ text: "a" }], - bestEffort: true, - gifPlayback: true, - silent: true, - mirror: { - sessionKey: "agent:main:main", - text: "a", - mediaUrls: ["https://example.com/a.png"], - }, - }, - tmpDir, - ); - - const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(deliver).toHaveBeenCalledWith( - expect.objectContaining({ - bestEffort: true, - gifPlayback: true, - silent: true, - mirror: { - sessionKey: "agent:main:main", - text: "a", - mediaUrls: ["https://example.com/a.png"], - }, - }), - ); - }); - - it("respects maxRecoveryMs time budget", async () => { - await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); - await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); - await enqueueDelivery({ channel: "slack", to: "#c", payloads: [{ text: "c" }] }, tmpDir); - - const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - maxRecoveryMs: 0, // Immediate timeout — no entries should be processed. - }); - - expect(deliver).not.toHaveBeenCalled(); - expect(result.recovered).toBe(0); - expect(result.failed).toBe(0); - expect(result.skipped).toBe(0); - - // All entries should still be in the queue. - const remaining = await loadPendingDeliveries(tmpDir); - expect(remaining).toHaveLength(3); - - // Should have logged a warning about deferred entries. - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next restart")); - }); - - it("defers entries when backoff exceeds the recovery budget", async () => { - const id = await enqueueDelivery( - { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, - tmpDir, - ); - const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); - const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")); - entry.retryCount = 3; - fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); - - const deliver = vi.fn().mockResolvedValue([]); - const delay = vi.fn(async () => {}); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay, - maxRecoveryMs: 1000, - }); - - expect(deliver).not.toHaveBeenCalled(); - expect(delay).not.toHaveBeenCalled(); - expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 }); - - const remaining = await loadPendingDeliveries(tmpDir); - expect(remaining).toHaveLength(1); - - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next restart")); - }); - - it("returns zeros when queue is empty", async () => { - const deliver = vi.fn(); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); - - expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 }); - expect(deliver).not.toHaveBeenCalled(); - }); -}); diff --git a/src/infra/outbound/directory-cache.test.ts b/src/infra/outbound/directory-cache.test.ts deleted file mode 100644 index ce7d041951e..00000000000 --- a/src/infra/outbound/directory-cache.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { DirectoryCache } from "./directory-cache.js"; - -describe("DirectoryCache", () => { - const cfg = {} as OpenClawConfig; - - it("expires entries after ttl", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const cache = new DirectoryCache(1000, 10); - - cache.set("a", "value-a", cfg); - expect(cache.get("a", cfg)).toBe("value-a"); - - vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); - expect(cache.get("a", cfg)).toBeUndefined(); - - vi.useRealTimers(); - }); - - it("evicts oldest keys when max size is exceeded", () => { - const cache = new DirectoryCache(60_000, 2); - cache.set("a", "value-a", cfg); - cache.set("b", "value-b", cfg); - cache.set("c", "value-c", cfg); - - expect(cache.get("a", cfg)).toBeUndefined(); - expect(cache.get("b", cfg)).toBe("value-b"); - expect(cache.get("c", cfg)).toBe("value-c"); - }); - - it("refreshes insertion order on key updates", () => { - const cache = new DirectoryCache(60_000, 2); - cache.set("a", "value-a", cfg); - cache.set("b", "value-b", cfg); - cache.set("a", "value-a2", cfg); - cache.set("c", "value-c", cfg); - - // Updating "a" should keep it and evict older "b". - expect(cache.get("a", cfg)).toBe("value-a2"); - expect(cache.get("b", cfg)).toBeUndefined(); - expect(cache.get("c", cfg)).toBe("value-c"); - }); -}); diff --git a/src/infra/outbound/envelope.test.ts b/src/infra/outbound/envelope.test.ts deleted file mode 100644 index 71effdee808..00000000000 --- a/src/infra/outbound/envelope.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OutboundDeliveryJson } from "./format.js"; -import { buildOutboundResultEnvelope } from "./envelope.js"; - -describe("buildOutboundResultEnvelope", () => { - it("flattens delivery-only payloads by default", () => { - const delivery: OutboundDeliveryJson = { - provider: "whatsapp", - via: "gateway", - to: "+1", - messageId: "m1", - mediaUrl: null, - }; - expect(buildOutboundResultEnvelope({ delivery })).toEqual(delivery); - }); - - it("keeps payloads and meta in the envelope", () => { - const envelope = buildOutboundResultEnvelope({ - payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], - meta: { foo: "bar" }, - }); - expect(envelope).toEqual({ - payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], - meta: { foo: "bar" }, - }); - }); - - it("includes delivery when payloads are present", () => { - const delivery: OutboundDeliveryJson = { - provider: "telegram", - via: "direct", - to: "123", - messageId: "m2", - mediaUrl: null, - chatId: "c1", - }; - const envelope = buildOutboundResultEnvelope({ - payloads: [], - delivery, - meta: { ok: true }, - }); - expect(envelope).toEqual({ - payloads: [], - meta: { ok: true }, - delivery, - }); - }); - - it("can keep delivery wrapped when requested", () => { - const delivery: OutboundDeliveryJson = { - provider: "discord", - via: "gateway", - to: "channel:C1", - messageId: "m3", - mediaUrl: null, - channelId: "C1", - }; - const envelope = buildOutboundResultEnvelope({ - delivery, - flattenDelivery: false, - }); - expect(envelope).toEqual({ delivery }); - }); -}); diff --git a/src/infra/outbound/format.test.ts b/src/infra/outbound/format.test.ts deleted file mode 100644 index 950bb3e5fd1..00000000000 --- a/src/infra/outbound/format.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildOutboundDeliveryJson, - formatGatewaySummary, - formatOutboundDeliverySummary, -} from "./format.js"; - -describe("formatOutboundDeliverySummary", () => { - it("falls back when result is missing", () => { - expect(formatOutboundDeliverySummary("telegram")).toBe( - "✅ Sent via Telegram. Message ID: unknown", - ); - expect(formatOutboundDeliverySummary("imessage")).toBe( - "✅ Sent via iMessage. Message ID: unknown", - ); - }); - - it("adds chat or channel details", () => { - expect( - formatOutboundDeliverySummary("telegram", { - channel: "telegram", - messageId: "m1", - chatId: "c1", - }), - ).toBe("✅ Sent via Telegram. Message ID: m1 (chat c1)"); - - expect( - formatOutboundDeliverySummary("discord", { - channel: "discord", - messageId: "d1", - channelId: "chan", - }), - ).toBe("✅ Sent via Discord. Message ID: d1 (channel chan)"); - }); -}); - -describe("buildOutboundDeliveryJson", () => { - it("builds direct delivery payloads", () => { - expect( - buildOutboundDeliveryJson({ - channel: "telegram", - to: "123", - result: { channel: "telegram", messageId: "m1", chatId: "c1" }, - mediaUrl: "https://example.com/a.png", - }), - ).toEqual({ - channel: "telegram", - via: "direct", - to: "123", - messageId: "m1", - mediaUrl: "https://example.com/a.png", - chatId: "c1", - }); - }); - - it("supports whatsapp metadata when present", () => { - expect( - buildOutboundDeliveryJson({ - channel: "whatsapp", - to: "+1", - result: { channel: "whatsapp", messageId: "w1", toJid: "jid" }, - }), - ).toEqual({ - channel: "whatsapp", - via: "direct", - to: "+1", - messageId: "w1", - mediaUrl: null, - toJid: "jid", - }); - }); - - it("keeps timestamp for signal", () => { - expect( - buildOutboundDeliveryJson({ - channel: "signal", - to: "+1", - result: { channel: "signal", messageId: "s1", timestamp: 123 }, - }), - ).toEqual({ - channel: "signal", - via: "direct", - to: "+1", - messageId: "s1", - mediaUrl: null, - timestamp: 123, - }); - }); -}); - -describe("formatGatewaySummary", () => { - it("formats gateway summaries with channel", () => { - expect(formatGatewaySummary({ channel: "whatsapp", messageId: "m1" })).toBe( - "✅ Sent via gateway (whatsapp). Message ID: m1", - ); - }); - - it("supports custom actions", () => { - expect( - formatGatewaySummary({ - action: "Poll sent", - channel: "discord", - messageId: "p1", - }), - ).toBe("✅ Poll sent via gateway (discord). Message ID: p1"); - }); -}); diff --git a/src/infra/outbound/outbound-policy.test.ts b/src/infra/outbound/outbound-policy.test.ts deleted file mode 100644 index 9daa3c86c2a..00000000000 --- a/src/infra/outbound/outbound-policy.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { - applyCrossContextDecoration, - buildCrossContextDecoration, - enforceCrossContextPolicy, -} from "./outbound-policy.js"; - -const slackConfig = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - }, - }, -} as OpenClawConfig; - -const discordConfig = { - channels: { - discord: {}, - }, -} as OpenClawConfig; - -describe("outbound policy", () => { - it("blocks cross-provider sends by default", () => { - expect(() => - enforceCrossContextPolicy({ - cfg: slackConfig, - channel: "telegram", - action: "send", - args: { to: "telegram:@ops" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).toThrow(/Cross-context messaging denied/); - }); - - it("allows cross-provider sends when enabled", () => { - const cfg = { - ...slackConfig, - tools: { - message: { crossContext: { allowAcrossProviders: true } }, - }, - } as OpenClawConfig; - - expect(() => - enforceCrossContextPolicy({ - cfg, - channel: "telegram", - action: "send", - args: { to: "telegram:@ops" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).not.toThrow(); - }); - - it("blocks same-provider cross-context when disabled", () => { - const cfg = { - ...slackConfig, - tools: { message: { crossContext: { allowWithinProvider: false } } }, - } as OpenClawConfig; - - expect(() => - enforceCrossContextPolicy({ - cfg, - channel: "slack", - action: "send", - args: { to: "C99999999" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).toThrow(/Cross-context messaging denied/); - }); - - it("uses components when available and preferred", async () => { - const decoration = await buildCrossContextDecoration({ - cfg: discordConfig, - channel: "discord", - target: "123", - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "discord" }, - }); - - expect(decoration).not.toBeNull(); - const applied = applyCrossContextDecoration({ - message: "hello", - decoration: decoration!, - preferComponents: true, - }); - - expect(applied.usedComponents).toBe(true); - expect(applied.componentsBuilder).toBeDefined(); - expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0); - expect(applied.message).toBe("hello"); - }); -}); diff --git a/src/infra/outbound/outbound-session.test.ts b/src/infra/outbound/outbound-session.test.ts deleted file mode 100644 index 48da825a5f3..00000000000 --- a/src/infra/outbound/outbound-session.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { resolveOutboundSessionRoute } from "./outbound-session.js"; - -const baseConfig = {} as OpenClawConfig; - -describe("resolveOutboundSessionRoute", () => { - it("builds Slack thread session keys", async () => { - const route = await resolveOutboundSessionRoute({ - cfg: baseConfig, - channel: "slack", - agentId: "main", - target: "channel:C123", - replyToId: "456", - }); - - expect(route?.sessionKey).toBe("agent:main:slack:channel:c123:thread:456"); - expect(route?.from).toBe("slack:channel:C123"); - expect(route?.to).toBe("channel:C123"); - expect(route?.threadId).toBe("456"); - }); - - it("uses Telegram topic ids in group session keys", async () => { - const route = await resolveOutboundSessionRoute({ - cfg: baseConfig, - channel: "telegram", - agentId: "main", - target: "-100123456:topic:42", - }); - - expect(route?.sessionKey).toBe("agent:main:telegram:group:-100123456:topic:42"); - expect(route?.from).toBe("telegram:group:-100123456:topic:42"); - expect(route?.to).toBe("telegram:-100123456"); - expect(route?.threadId).toBe(42); - }); - - it("treats Telegram usernames as DMs when unresolved", async () => { - const cfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; - const route = await resolveOutboundSessionRoute({ - cfg, - channel: "telegram", - agentId: "main", - target: "@alice", - }); - - expect(route?.sessionKey).toBe("agent:main:telegram:direct:@alice"); - expect(route?.chatType).toBe("direct"); - }); - - it("honors dmScope identity links", async () => { - const cfg = { - session: { - dmScope: "per-peer", - identityLinks: { - alice: ["discord:123"], - }, - }, - } as OpenClawConfig; - - const route = await resolveOutboundSessionRoute({ - cfg, - channel: "discord", - agentId: "main", - target: "user:123", - }); - - expect(route?.sessionKey).toBe("agent:main:direct:alice"); - }); - - it("strips chat_* prefixes for BlueBubbles group session keys", async () => { - const route = await resolveOutboundSessionRoute({ - cfg: baseConfig, - channel: "bluebubbles", - agentId: "main", - target: "chat_guid:ABC123", - }); - - expect(route?.sessionKey).toBe("agent:main:bluebubbles:group:abc123"); - expect(route?.from).toBe("group:ABC123"); - }); - - it("treats Zalo Personal DM targets as direct sessions", async () => { - const cfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; - const route = await resolveOutboundSessionRoute({ - cfg, - channel: "zalouser", - agentId: "main", - target: "123456", - }); - - expect(route?.sessionKey).toBe("agent:main:zalouser:direct:123456"); - expect(route?.chatType).toBe("direct"); - }); - - it("uses group session keys for Slack mpim allowlist entries", async () => { - const cfg = { - channels: { - slack: { - dm: { - groupChannels: ["G123"], - }, - }, - }, - } as OpenClawConfig; - - const route = await resolveOutboundSessionRoute({ - cfg, - channel: "slack", - agentId: "main", - target: "channel:G123", - }); - - expect(route?.sessionKey).toBe("agent:main:slack:group:g123"); - expect(route?.from).toBe("slack:group:G123"); - }); -}); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts new file mode 100644 index 00000000000..797d2486ba5 --- /dev/null +++ b/src/infra/outbound/outbound.test.ts @@ -0,0 +1,1084 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { OutboundDeliveryJson } from "./format.js"; +import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; +import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + ackDelivery, + computeBackoffMs, + enqueueDelivery, + failDelivery, + loadPendingDeliveries, + MAX_RETRIES, + moveToFailed, + recoverPendingDeliveries, +} from "./delivery-queue.js"; +import { DirectoryCache } from "./directory-cache.js"; +import { buildOutboundResultEnvelope } from "./envelope.js"; +import { + buildOutboundDeliveryJson, + formatGatewaySummary, + formatOutboundDeliverySummary, +} from "./format.js"; +import { + applyCrossContextDecoration, + buildCrossContextDecoration, + enforceCrossContextPolicy, +} from "./outbound-policy.js"; +import { resolveOutboundSessionRoute } from "./outbound-session.js"; +import { + formatOutboundPayloadLog, + normalizeOutboundPayloads, + normalizeOutboundPayloadsForJson, +} from "./payloads.js"; +import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js"; + +describe("delivery-queue", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-dq-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("enqueue + ack lifecycle", () => { + it("creates and removes a queue entry", async () => { + const id = await enqueueDelivery( + { + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "hello", + mediaUrls: ["https://example.com/file.png"], + }, + }, + tmpDir, + ); + + // Entry file exists after enqueue. + const queueDir = path.join(tmpDir, "delivery-queue"); + const files = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + expect(files[0]).toBe(`${id}.json`); + + // Entry contents are correct. + const entry = JSON.parse(fs.readFileSync(path.join(queueDir, files[0]), "utf-8")); + expect(entry).toMatchObject({ + id, + channel: "whatsapp", + to: "+1555", + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "hello", + mediaUrls: ["https://example.com/file.png"], + }, + retryCount: 0, + }); + expect(entry.payloads).toEqual([{ text: "hello" }]); + + // Ack removes the file. + await ackDelivery(id, tmpDir); + const remaining = fs.readdirSync(queueDir).filter((f) => f.endsWith(".json")); + expect(remaining).toHaveLength(0); + }); + + it("ack is idempotent (no error on missing file)", async () => { + await expect(ackDelivery("nonexistent-id", tmpDir)).resolves.toBeUndefined(); + }); + }); + + describe("failDelivery", () => { + it("increments retryCount and sets lastError", async () => { + const id = await enqueueDelivery( + { + channel: "telegram", + to: "123", + payloads: [{ text: "test" }], + }, + tmpDir, + ); + + await failDelivery(id, "connection refused", tmpDir); + + const queueDir = path.join(tmpDir, "delivery-queue"); + const entry = JSON.parse(fs.readFileSync(path.join(queueDir, `${id}.json`), "utf-8")); + expect(entry.retryCount).toBe(1); + expect(entry.lastError).toBe("connection refused"); + }); + }); + + describe("moveToFailed", () => { + it("moves entry to failed/ subdirectory", async () => { + const id = await enqueueDelivery( + { + channel: "slack", + to: "#general", + payloads: [{ text: "hi" }], + }, + tmpDir, + ); + + await moveToFailed(id, tmpDir); + + const queueDir = path.join(tmpDir, "delivery-queue"); + const failedDir = path.join(queueDir, "failed"); + expect(fs.existsSync(path.join(queueDir, `${id}.json`))).toBe(false); + expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); + }); + }); + + describe("loadPendingDeliveries", () => { + it("returns empty array when queue directory does not exist", async () => { + const nonexistent = path.join(tmpDir, "no-such-dir"); + const entries = await loadPendingDeliveries(nonexistent); + expect(entries).toEqual([]); + }); + + it("loads multiple entries", async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(2); + }); + }); + + describe("computeBackoffMs", () => { + it("returns 0 for retryCount 0", () => { + expect(computeBackoffMs(0)).toBe(0); + }); + + it("returns correct backoff for each retry", () => { + expect(computeBackoffMs(1)).toBe(5_000); + expect(computeBackoffMs(2)).toBe(25_000); + expect(computeBackoffMs(3)).toBe(120_000); + expect(computeBackoffMs(4)).toBe(600_000); + // Beyond defined schedule -- clamps to last value. + expect(computeBackoffMs(5)).toBe(600_000); + }); + }); + + describe("recoverPendingDeliveries", () => { + const noopDelay = async () => {}; + const baseCfg = {}; + + it("recovers entries from a simulated crash", async () => { + // Manually create two queue entries as if gateway crashed before delivery. + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + + const deliver = vi.fn().mockResolvedValue([]); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(deliver).toHaveBeenCalledTimes(2); + expect(result.recovered).toBe(2); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + + // Queue should be empty after recovery. + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(0); + }); + + it("moves entries that exceeded max retries to failed/", async () => { + // Create an entry and manually set retryCount to MAX_RETRIES. + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, + tmpDir, + ); + const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); + const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")); + entry.retryCount = MAX_RETRIES; + fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); + + const deliver = vi.fn(); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(deliver).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + + // Entry should be in failed/ directory. + const failedDir = path.join(tmpDir, "delivery-queue", "failed"); + expect(fs.existsSync(path.join(failedDir, `${id}.json`))).toBe(true); + }); + + it("increments retryCount on failed recovery attempt", async () => { + await enqueueDelivery({ channel: "slack", to: "#ch", payloads: [{ text: "x" }] }, tmpDir); + + const deliver = vi.fn().mockRejectedValue(new Error("network down")); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(result.failed).toBe(1); + expect(result.recovered).toBe(0); + + // Entry should still be in queue with incremented retryCount. + const entries = await loadPendingDeliveries(tmpDir); + expect(entries).toHaveLength(1); + expect(entries[0].retryCount).toBe(1); + expect(entries[0].lastError).toBe("network down"); + }); + + it("passes skipQueue: true to prevent re-enqueueing during recovery", async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + + const deliver = vi.fn().mockResolvedValue([]); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ skipQueue: true })); + }); + + it("replays stored delivery options during recovery", async () => { + await enqueueDelivery( + { + channel: "whatsapp", + to: "+1", + payloads: [{ text: "a" }], + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "a", + mediaUrls: ["https://example.com/a.png"], + }, + }, + tmpDir, + ); + + const deliver = vi.fn().mockResolvedValue([]); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + bestEffort: true, + gifPlayback: true, + silent: true, + mirror: { + sessionKey: "agent:main:main", + text: "a", + mediaUrls: ["https://example.com/a.png"], + }, + }), + ); + }); + + it("respects maxRecoveryMs time budget", async () => { + await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); + await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + await enqueueDelivery({ channel: "slack", to: "#c", payloads: [{ text: "c" }] }, tmpDir); + + const deliver = vi.fn().mockResolvedValue([]); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + maxRecoveryMs: 0, // Immediate timeout -- no entries should be processed. + }); + + expect(deliver).not.toHaveBeenCalled(); + expect(result.recovered).toBe(0); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + + // All entries should still be in the queue. + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(3); + + // Should have logged a warning about deferred entries. + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next restart")); + }); + + it("defers entries when backoff exceeds the recovery budget", async () => { + const id = await enqueueDelivery( + { channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, + tmpDir, + ); + const filePath = path.join(tmpDir, "delivery-queue", `${id}.json`); + const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")); + entry.retryCount = 3; + fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); + + const deliver = vi.fn().mockResolvedValue([]); + const delay = vi.fn(async () => {}); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay, + maxRecoveryMs: 1000, + }); + + expect(deliver).not.toHaveBeenCalled(); + expect(delay).not.toHaveBeenCalled(); + expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 }); + + const remaining = await loadPendingDeliveries(tmpDir); + expect(remaining).toHaveLength(1); + + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("deferred to next restart")); + }); + + it("returns zeros when queue is empty", async () => { + const deliver = vi.fn(); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await recoverPendingDeliveries({ + deliver, + log, + cfg: baseCfg, + stateDir: tmpDir, + delay: noopDelay, + }); + + expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 }); + expect(deliver).not.toHaveBeenCalled(); + }); + }); +}); + +describe("DirectoryCache", () => { + const cfg = {} as OpenClawConfig; + + afterEach(() => { + vi.useRealTimers(); + }); + + it("expires entries after ttl", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const cache = new DirectoryCache(1000, 10); + + cache.set("a", "value-a", cfg); + expect(cache.get("a", cfg)).toBe("value-a"); + + vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); + expect(cache.get("a", cfg)).toBeUndefined(); + }); + + it("evicts oldest keys when max size is exceeded", () => { + const cache = new DirectoryCache(60_000, 2); + cache.set("a", "value-a", cfg); + cache.set("b", "value-b", cfg); + cache.set("c", "value-c", cfg); + + expect(cache.get("a", cfg)).toBeUndefined(); + expect(cache.get("b", cfg)).toBe("value-b"); + expect(cache.get("c", cfg)).toBe("value-c"); + }); + + it("refreshes insertion order on key updates", () => { + const cache = new DirectoryCache(60_000, 2); + cache.set("a", "value-a", cfg); + cache.set("b", "value-b", cfg); + cache.set("a", "value-a2", cfg); + cache.set("c", "value-c", cfg); + + // Updating "a" should keep it and evict older "b". + expect(cache.get("a", cfg)).toBe("value-a2"); + expect(cache.get("b", cfg)).toBeUndefined(); + expect(cache.get("c", cfg)).toBe("value-c"); + }); +}); + +describe("buildOutboundResultEnvelope", () => { + it("flattens delivery-only payloads by default", () => { + const delivery: OutboundDeliveryJson = { + provider: "whatsapp", + via: "gateway", + to: "+1", + messageId: "m1", + mediaUrl: null, + }; + expect(buildOutboundResultEnvelope({ delivery })).toEqual(delivery); + }); + + it("keeps payloads and meta in the envelope", () => { + const envelope = buildOutboundResultEnvelope({ + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }); + expect(envelope).toEqual({ + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }); + }); + + it("includes delivery when payloads are present", () => { + const delivery: OutboundDeliveryJson = { + provider: "telegram", + via: "direct", + to: "123", + messageId: "m2", + mediaUrl: null, + chatId: "c1", + }; + const envelope = buildOutboundResultEnvelope({ + payloads: [], + delivery, + meta: { ok: true }, + }); + expect(envelope).toEqual({ + payloads: [], + meta: { ok: true }, + delivery, + }); + }); + + it("can keep delivery wrapped when requested", () => { + const delivery: OutboundDeliveryJson = { + provider: "discord", + via: "gateway", + to: "channel:C1", + messageId: "m3", + mediaUrl: null, + channelId: "C1", + }; + const envelope = buildOutboundResultEnvelope({ + delivery, + flattenDelivery: false, + }); + expect(envelope).toEqual({ delivery }); + }); +}); + +describe("formatOutboundDeliverySummary", () => { + it("falls back when result is missing", () => { + expect(formatOutboundDeliverySummary("telegram")).toBe( + "✅ Sent via Telegram. Message ID: unknown", + ); + expect(formatOutboundDeliverySummary("imessage")).toBe( + "✅ Sent via iMessage. Message ID: unknown", + ); + }); + + it("adds chat or channel details", () => { + expect( + formatOutboundDeliverySummary("telegram", { + channel: "telegram", + messageId: "m1", + chatId: "c1", + }), + ).toBe("✅ Sent via Telegram. Message ID: m1 (chat c1)"); + + expect( + formatOutboundDeliverySummary("discord", { + channel: "discord", + messageId: "d1", + channelId: "chan", + }), + ).toBe("✅ Sent via Discord. Message ID: d1 (channel chan)"); + }); +}); + +describe("buildOutboundDeliveryJson", () => { + it("builds direct delivery payloads", () => { + expect( + buildOutboundDeliveryJson({ + channel: "telegram", + to: "123", + result: { channel: "telegram", messageId: "m1", chatId: "c1" }, + mediaUrl: "https://example.com/a.png", + }), + ).toEqual({ + channel: "telegram", + via: "direct", + to: "123", + messageId: "m1", + mediaUrl: "https://example.com/a.png", + chatId: "c1", + }); + }); + + it("supports whatsapp metadata when present", () => { + expect( + buildOutboundDeliveryJson({ + channel: "whatsapp", + to: "+1", + result: { channel: "whatsapp", messageId: "w1", toJid: "jid" }, + }), + ).toEqual({ + channel: "whatsapp", + via: "direct", + to: "+1", + messageId: "w1", + mediaUrl: null, + toJid: "jid", + }); + }); + + it("keeps timestamp for signal", () => { + expect( + buildOutboundDeliveryJson({ + channel: "signal", + to: "+1", + result: { channel: "signal", messageId: "s1", timestamp: 123 }, + }), + ).toEqual({ + channel: "signal", + via: "direct", + to: "+1", + messageId: "s1", + mediaUrl: null, + timestamp: 123, + }); + }); +}); + +describe("formatGatewaySummary", () => { + it("formats gateway summaries with channel", () => { + expect(formatGatewaySummary({ channel: "whatsapp", messageId: "m1" })).toBe( + "✅ Sent via gateway (whatsapp). Message ID: m1", + ); + }); + + it("supports custom actions", () => { + expect( + formatGatewaySummary({ + action: "Poll sent", + channel: "discord", + messageId: "p1", + }), + ).toBe("✅ Poll sent via gateway (discord). Message ID: p1"); + }); +}); + +const slackConfig = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as OpenClawConfig; + +const discordConfig = { + channels: { + discord: {}, + }, +} as OpenClawConfig; + +describe("outbound policy", () => { + it("blocks cross-provider sends by default", () => { + expect(() => + enforceCrossContextPolicy({ + cfg: slackConfig, + channel: "telegram", + action: "send", + args: { to: "telegram:@ops" }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + ).toThrow(/Cross-context messaging denied/); + }); + + it("allows cross-provider sends when enabled", () => { + const cfg = { + ...slackConfig, + tools: { + message: { crossContext: { allowAcrossProviders: true } }, + }, + } as OpenClawConfig; + + expect(() => + enforceCrossContextPolicy({ + cfg, + channel: "telegram", + action: "send", + args: { to: "telegram:@ops" }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + ).not.toThrow(); + }); + + it("blocks same-provider cross-context when disabled", () => { + const cfg = { + ...slackConfig, + tools: { message: { crossContext: { allowWithinProvider: false } } }, + } as OpenClawConfig; + + expect(() => + enforceCrossContextPolicy({ + cfg, + channel: "slack", + action: "send", + args: { to: "C99999999" }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + ).toThrow(/Cross-context messaging denied/); + }); + + it("uses components when available and preferred", async () => { + const decoration = await buildCrossContextDecoration({ + cfg: discordConfig, + channel: "discord", + target: "123", + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "discord" }, + }); + + expect(decoration).not.toBeNull(); + const applied = applyCrossContextDecoration({ + message: "hello", + decoration: decoration!, + preferComponents: true, + }); + + expect(applied.usedComponents).toBe(true); + expect(applied.componentsBuilder).toBeDefined(); + expect(applied.componentsBuilder?.("hello").length).toBeGreaterThan(0); + expect(applied.message).toBe("hello"); + }); +}); + +describe("resolveOutboundSessionRoute", () => { + const baseConfig = {} as OpenClawConfig; + + it("builds Slack thread session keys", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "slack", + agentId: "main", + target: "channel:C123", + replyToId: "456", + }); + + expect(route?.sessionKey).toBe("agent:main:slack:channel:c123:thread:456"); + expect(route?.from).toBe("slack:channel:C123"); + expect(route?.to).toBe("channel:C123"); + expect(route?.threadId).toBe("456"); + }); + + it("uses Telegram topic ids in group session keys", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "telegram", + agentId: "main", + target: "-100123456:topic:42", + }); + + expect(route?.sessionKey).toBe("agent:main:telegram:group:-100123456:topic:42"); + expect(route?.from).toBe("telegram:group:-100123456:topic:42"); + expect(route?.to).toBe("telegram:-100123456"); + expect(route?.threadId).toBe(42); + }); + + it("treats Telegram usernames as DMs when unresolved", async () => { + const cfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "telegram", + agentId: "main", + target: "@alice", + }); + + expect(route?.sessionKey).toBe("agent:main:telegram:direct:@alice"); + expect(route?.chatType).toBe("direct"); + }); + + it("honors dmScope identity links", async () => { + const cfg = { + session: { + dmScope: "per-peer", + identityLinks: { + alice: ["discord:123"], + }, + }, + } as OpenClawConfig; + + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "discord", + agentId: "main", + target: "user:123", + }); + + expect(route?.sessionKey).toBe("agent:main:direct:alice"); + }); + + it("strips chat_* prefixes for BlueBubbles group session keys", async () => { + const route = await resolveOutboundSessionRoute({ + cfg: baseConfig, + channel: "bluebubbles", + agentId: "main", + target: "chat_guid:ABC123", + }); + + expect(route?.sessionKey).toBe("agent:main:bluebubbles:group:abc123"); + expect(route?.from).toBe("group:ABC123"); + }); + + it("treats Zalo Personal DM targets as direct sessions", async () => { + const cfg = { session: { dmScope: "per-channel-peer" } } as OpenClawConfig; + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "zalouser", + agentId: "main", + target: "123456", + }); + + expect(route?.sessionKey).toBe("agent:main:zalouser:direct:123456"); + expect(route?.chatType).toBe("direct"); + }); + + it("uses group session keys for Slack mpim allowlist entries", async () => { + const cfg = { + channels: { + slack: { + dm: { + groupChannels: ["G123"], + }, + }, + }, + } as OpenClawConfig; + + const route = await resolveOutboundSessionRoute({ + cfg, + channel: "slack", + agentId: "main", + target: "channel:G123", + }); + + expect(route?.sessionKey).toBe("agent:main:slack:group:g123"); + expect(route?.from).toBe("slack:group:G123"); + }); +}); + +describe("normalizeOutboundPayloadsForJson", () => { + it("normalizes payloads with mediaUrl and mediaUrls", () => { + expect( + normalizeOutboundPayloadsForJson([ + { text: "hi" }, + { text: "photo", mediaUrl: "https://x.test/a.jpg" }, + { text: "multi", mediaUrls: ["https://x.test/1.png"] }, + ]), + ).toEqual([ + { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, + { + text: "photo", + mediaUrl: "https://x.test/a.jpg", + mediaUrls: ["https://x.test/a.jpg"], + channelData: undefined, + }, + { + text: "multi", + mediaUrl: null, + mediaUrls: ["https://x.test/1.png"], + channelData: undefined, + }, + ]); + }); + + it("keeps mediaUrl null for multi MEDIA tags", () => { + expect( + normalizeOutboundPayloadsForJson([ + { + text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + ]), + ).toEqual([ + { + text: "", + mediaUrl: null, + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + channelData: undefined, + }, + ]); + }); +}); + +describe("normalizeOutboundPayloads", () => { + it("keeps channelData-only payloads", () => { + const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; + const normalized = normalizeOutboundPayloads([{ channelData }]); + expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); + }); +}); + +describe("formatOutboundPayloadLog", () => { + it("trims trailing text and appends media lines", () => { + expect( + formatOutboundPayloadLog({ + text: "hello ", + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + }), + ).toBe("hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png"); + }); + + it("logs media-only payloads", () => { + expect( + formatOutboundPayloadLog({ + text: "", + mediaUrls: ["https://x.test/a.png"], + }), + ).toBe("MEDIA:https://x.test/a.png"); + }); +}); + +describe("resolveOutboundTarget", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, + ]), + ); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry()); + }); + + it("rejects whatsapp with empty target even when allowFrom configured", () => { + const cfg: OpenClawConfig = { + channels: { whatsapp: { allowFrom: ["+1555"] } }, + }; + const res = resolveOutboundTarget({ + channel: "whatsapp", + to: "", + cfg, + mode: "explicit", + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain("WhatsApp"); + } + }); + + it.each([ + { + name: "normalizes whatsapp target when provided", + input: { channel: "whatsapp" as const, to: " (555) 123-4567 " }, + expected: { ok: true as const, to: "+5551234567" }, + }, + { + name: "keeps whatsapp group targets", + input: { channel: "whatsapp" as const, to: "120363401234567890@g.us" }, + expected: { ok: true as const, to: "120363401234567890@g.us" }, + }, + { + name: "normalizes prefixed/uppercase whatsapp group targets", + input: { + channel: "whatsapp" as const, + to: " WhatsApp:120363401234567890@G.US ", + }, + expected: { ok: true as const, to: "120363401234567890@g.us" }, + }, + { + name: "rejects whatsapp with empty target and allowFrom (no silent fallback)", + input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects whatsapp with empty target and prefixed allowFrom (no silent fallback)", + input: { + channel: "whatsapp" as const, + to: "", + allowFrom: ["whatsapp:(555) 123-4567"], + }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects invalid whatsapp target", + input: { channel: "whatsapp" as const, to: "wat" }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects whatsapp without to when allowFrom missing", + input: { channel: "whatsapp" as const, to: " " }, + expectedErrorIncludes: "WhatsApp", + }, + { + name: "rejects whatsapp allowFrom fallback when invalid", + input: { channel: "whatsapp" as const, to: "", allowFrom: ["wat"] }, + expectedErrorIncludes: "WhatsApp", + }, + ])("$name", ({ input, expected, expectedErrorIncludes }) => { + const res = resolveOutboundTarget(input); + if (expected) { + expect(res).toEqual(expected); + return; + } + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain(expectedErrorIncludes); + } + }); + + it("rejects telegram with missing target", () => { + const res = resolveOutboundTarget({ channel: "telegram", to: " " }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain("Telegram"); + } + }); + + it("rejects webchat delivery", () => { + const res = resolveOutboundTarget({ channel: "webchat", to: "x" }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain("WebChat"); + } + }); +}); + +describe("resolveSessionDeliveryTarget", () => { + it("derives implicit delivery from the last route", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-1", + updatedAt: 1, + lastChannel: " whatsapp ", + lastTo: " +1555 ", + lastAccountId: " acct-1 ", + }, + requestedChannel: "last", + }); + + expect(resolved).toEqual({ + channel: "whatsapp", + to: "+1555", + accountId: "acct-1", + threadId: undefined, + mode: "implicit", + lastChannel: "whatsapp", + lastTo: "+1555", + lastAccountId: "acct-1", + lastThreadId: undefined, + }); + }); + + it("prefers explicit targets without reusing lastTo", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-2", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+1555", + }, + requestedChannel: "telegram", + }); + + expect(resolved).toEqual({ + channel: "telegram", + to: undefined, + accountId: undefined, + threadId: undefined, + mode: "implicit", + lastChannel: "whatsapp", + lastTo: "+1555", + lastAccountId: undefined, + lastThreadId: undefined, + }); + }); + + it("allows mismatched lastTo when configured", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-3", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+1555", + }, + requestedChannel: "telegram", + allowMismatchedLastTo: true, + }); + + expect(resolved).toEqual({ + channel: "telegram", + to: "+1555", + accountId: undefined, + threadId: undefined, + mode: "implicit", + lastChannel: "whatsapp", + lastTo: "+1555", + lastAccountId: undefined, + lastThreadId: undefined, + }); + }); + + it("falls back to a provided channel when requested is unsupported", () => { + const resolved = resolveSessionDeliveryTarget({ + entry: { + sessionId: "sess-4", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+1555", + }, + requestedChannel: "webchat", + fallbackChannel: "slack", + }); + + expect(resolved).toEqual({ + channel: "slack", + to: undefined, + accountId: undefined, + threadId: undefined, + mode: "implicit", + lastChannel: "whatsapp", + lastTo: "+1555", + lastAccountId: undefined, + lastThreadId: undefined, + }); + }); +}); diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts deleted file mode 100644 index be3f66daf38..00000000000 --- a/src/infra/outbound/payloads.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatOutboundPayloadLog, - normalizeOutboundPayloads, - normalizeOutboundPayloadsForJson, -} from "./payloads.js"; - -describe("normalizeOutboundPayloadsForJson", () => { - it("normalizes payloads with mediaUrl and mediaUrls", () => { - expect( - normalizeOutboundPayloadsForJson([ - { text: "hi" }, - { text: "photo", mediaUrl: "https://x.test/a.jpg" }, - { text: "multi", mediaUrls: ["https://x.test/1.png"] }, - ]), - ).toEqual([ - { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, - { - text: "photo", - mediaUrl: "https://x.test/a.jpg", - mediaUrls: ["https://x.test/a.jpg"], - channelData: undefined, - }, - { - text: "multi", - mediaUrl: null, - mediaUrls: ["https://x.test/1.png"], - channelData: undefined, - }, - ]); - }); - - it("keeps mediaUrl null for multi MEDIA tags", () => { - expect( - normalizeOutboundPayloadsForJson([ - { - text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - ]), - ).toEqual([ - { - text: "", - mediaUrl: null, - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - channelData: undefined, - }, - ]); - }); -}); - -describe("normalizeOutboundPayloads", () => { - it("keeps channelData-only payloads", () => { - const channelData = { line: { flexMessage: { altText: "Card", contents: {} } } }; - const normalized = normalizeOutboundPayloads([{ channelData }]); - expect(normalized).toEqual([{ text: "", mediaUrls: [], channelData }]); - }); -}); - -describe("formatOutboundPayloadLog", () => { - it("trims trailing text and appends media lines", () => { - expect( - formatOutboundPayloadLog({ - text: "hello ", - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - }), - ).toBe("hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png"); - }); - - it("logs media-only payloads", () => { - expect( - formatOutboundPayloadLog({ - text: "", - mediaUrls: ["https://x.test/a.png"], - }), - ).toBe("MEDIA:https://x.test/a.png"); - }); -}); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts deleted file mode 100644 index ff9dee1613a..00000000000 --- a/src/infra/outbound/targets.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; -import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js"; - -describe("resolveOutboundTarget", () => { - beforeEach(() => { - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" }, - { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, - ]), - ); - }); - - it("rejects whatsapp with empty target even when allowFrom configured", () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["+1555"] } }, - }; - const res = resolveOutboundTarget({ - channel: "whatsapp", - to: "", - cfg, - mode: "explicit", - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("WhatsApp"); - } - }); - - it.each([ - { - name: "normalizes whatsapp target when provided", - input: { channel: "whatsapp" as const, to: " (555) 123-4567 " }, - expected: { ok: true as const, to: "+5551234567" }, - }, - { - name: "keeps whatsapp group targets", - input: { channel: "whatsapp" as const, to: "120363401234567890@g.us" }, - expected: { ok: true as const, to: "120363401234567890@g.us" }, - }, - { - name: "normalizes prefixed/uppercase whatsapp group targets", - input: { - channel: "whatsapp" as const, - to: " WhatsApp:120363401234567890@G.US ", - }, - expected: { ok: true as const, to: "120363401234567890@g.us" }, - }, - { - name: "rejects whatsapp with empty target and allowFrom (no silent fallback)", - input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] }, - expectedErrorIncludes: "WhatsApp", - }, - { - name: "rejects whatsapp with empty target and prefixed allowFrom (no silent fallback)", - input: { - channel: "whatsapp" as const, - to: "", - allowFrom: ["whatsapp:(555) 123-4567"], - }, - expectedErrorIncludes: "WhatsApp", - }, - { - name: "rejects invalid whatsapp target", - input: { channel: "whatsapp" as const, to: "wat" }, - expectedErrorIncludes: "WhatsApp", - }, - { - name: "rejects whatsapp without to when allowFrom missing", - input: { channel: "whatsapp" as const, to: " " }, - expectedErrorIncludes: "WhatsApp", - }, - { - name: "rejects whatsapp allowFrom fallback when invalid", - input: { channel: "whatsapp" as const, to: "", allowFrom: ["wat"] }, - expectedErrorIncludes: "WhatsApp", - }, - ])("$name", ({ input, expected, expectedErrorIncludes }) => { - const res = resolveOutboundTarget(input); - if (expected) { - expect(res).toEqual(expected); - return; - } - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain(expectedErrorIncludes); - } - }); - - it("rejects telegram with missing target", () => { - const res = resolveOutboundTarget({ channel: "telegram", to: " " }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("Telegram"); - } - }); - - it("rejects webchat delivery", () => { - const res = resolveOutboundTarget({ channel: "webchat", to: "x" }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("WebChat"); - } - }); -}); - -describe("resolveSessionDeliveryTarget", () => { - it("derives implicit delivery from the last route", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-1", - updatedAt: 1, - lastChannel: " whatsapp ", - lastTo: " +1555 ", - lastAccountId: " acct-1 ", - }, - requestedChannel: "last", - }); - - expect(resolved).toEqual({ - channel: "whatsapp", - to: "+1555", - accountId: "acct-1", - threadId: undefined, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: "acct-1", - lastThreadId: undefined, - }); - }); - - it("prefers explicit targets without reusing lastTo", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-2", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "telegram", - }); - - expect(resolved).toEqual({ - channel: "telegram", - to: undefined, - accountId: undefined, - threadId: undefined, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); - - it("allows mismatched lastTo when configured", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-3", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "telegram", - allowMismatchedLastTo: true, - }); - - expect(resolved).toEqual({ - channel: "telegram", - to: "+1555", - accountId: undefined, - threadId: undefined, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); - - it("falls back to a provided channel when requested is unsupported", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-4", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "webchat", - fallbackChannel: "slack", - }); - - expect(resolved).toEqual({ - channel: "slack", - to: undefined, - accountId: undefined, - threadId: undefined, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); -}); From d75cd4078729c662aa72084ed831429dc350ba52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:45:34 +0000 Subject: [PATCH 050/178] perf(test): consolidate reply utility suites --- src/auto-reply/reply/formatting.test.ts | 280 ------ src/auto-reply/reply/mentions.test.ts | 58 -- src/auto-reply/reply/normalize-reply.test.ts | 48 - src/auto-reply/reply/reply-utils.test.ts | 844 ++++++++++++++++++ .../reply/response-prefix-template.test.ts | 180 ---- src/auto-reply/reply/typing.test.ts | 283 ------ 6 files changed, 844 insertions(+), 849 deletions(-) delete mode 100644 src/auto-reply/reply/formatting.test.ts delete mode 100644 src/auto-reply/reply/mentions.test.ts delete mode 100644 src/auto-reply/reply/normalize-reply.test.ts create mode 100644 src/auto-reply/reply/reply-utils.test.ts delete mode 100644 src/auto-reply/reply/response-prefix-template.test.ts delete mode 100644 src/auto-reply/reply/typing.test.ts diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts deleted file mode 100644 index e6fb0689881..00000000000 --- a/src/auto-reply/reply/formatting.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -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 each enqueued payload separately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - coalescer.enqueue({ text: "Third paragraph" }); - - await Promise.resolve(); - expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); - coalescer.stop(); - }); - - it("still accumulates when flushOnEnqueue is not set (default)", async () => { - vi.useFakeTimers(); - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "First paragraph" }); - coalescer.enqueue({ text: "Second paragraph" }); - - await vi.advanceTimersByTimeAsync(100); - expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); - coalescer.stop(); - }); - - it("flushes short payloads immediately when flushOnEnqueue is set", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - coalescer.enqueue({ text: "Hi" }); - await Promise.resolve(); - expect(flushes).toEqual(["Hi"]); - coalescer.stop(); - }); - - it("resets char budget per paragraph with flushOnEnqueue", async () => { - const flushes: string[] = []; - const coalescer = createBlockReplyCoalescer({ - config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, - shouldAbort: () => false, - onFlush: (payload) => { - flushes.push(payload.text ?? ""); - }, - }); - - // Each 20-char payload fits within maxChars=30 individually - coalescer.enqueue({ text: "12345678901234567890" }); - coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); - - await Promise.resolve(); - // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. - // With flushOnEnqueue, each is sent independently within budget. - expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); - 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("respects replyToMode off even with existingId", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "off", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBeUndefined(); - expect(planner.hasReplied()).toBe(false); - }); - - it("uses existingId once when mode is first", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "first", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.hasReplied()).toBe(true); - expect(planner.use()).toBeUndefined(); - }); - - it("uses existingId on every call when mode is all", () => { - const planner = createReplyReferencePlanner({ - replyToMode: "all", - existingId: "thread-1", - startId: "parent", - }); - expect(planner.use()).toBe("thread-1"); - expect(planner.use()).toBe("thread-1"); - }); - - 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/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index 8b700d23b1f..00000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { matchesMentionWithExplicit } from "./mentions.js"; - -describe("matchesMentionWithExplicit", () => { - const mentionRegexes = [/\bopenclaw\b/i]; - - it("checks mentionPatterns even when explicit mention is available", () => { - const result = matchesMentionWithExplicit({ - text: "@openclaw hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("returns false when explicit is false and no regex match", () => { - const result = matchesMentionWithExplicit({ - text: "<@999999> hello", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: true, - }, - }); - expect(result).toBe(false); - }); - - it("returns true when explicitly mentioned even if regexes do not match", () => { - const result = matchesMentionWithExplicit({ - text: "<@123456>", - mentionRegexes: [], - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: true, - canResolveExplicit: true, - }, - }); - expect(result).toBe(true); - }); - - it("falls back to regex matching when explicit mention cannot be resolved", () => { - const result = matchesMentionWithExplicit({ - text: "openclaw please", - mentionRegexes, - explicit: { - hasAnyMention: true, - isExplicitlyMentioned: false, - canResolveExplicit: false, - }, - }); - expect(result).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/normalize-reply.test.ts b/src/auto-reply/reply/normalize-reply.test.ts deleted file mode 100644 index 26866892669..00000000000 --- a/src/auto-reply/reply/normalize-reply.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SILENT_REPLY_TOKEN } from "../tokens.js"; -import { normalizeReplyPayload } from "./normalize-reply.js"; - -// Keep channelData-only payloads so channel-specific replies survive normalization. -describe("normalizeReplyPayload", () => { - it("keeps channelData-only replies", () => { - const payload = { - channelData: { - line: { - flexMessage: { type: "bubble" }, - }, - }, - }; - - const normalized = normalizeReplyPayload(payload); - - expect(normalized).not.toBeNull(); - expect(normalized?.text).toBeUndefined(); - expect(normalized?.channelData).toEqual(payload.channelData); - }); - - it("records silent skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: SILENT_REPLY_TOKEN }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["silent"]); - }); - - it("records empty skips", () => { - const reasons: string[] = []; - const normalized = normalizeReplyPayload( - { text: " " }, - { - onSkip: (reason) => reasons.push(reason), - }, - ); - - expect(normalized).toBeNull(); - expect(reasons).toEqual(["empty"]); - }); -}); diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts new file mode 100644 index 00000000000..743568b38c1 --- /dev/null +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -0,0 +1,844 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { parseAudioTag } from "./audio-tags.js"; +import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; +import { matchesMentionWithExplicit } from "./mentions.js"; +import { normalizeReplyPayload } from "./normalize-reply.js"; +import { createReplyReferencePlanner } from "./reply-reference.js"; +import { + extractShortModelName, + hasTemplateVariables, + resolveResponsePrefixTemplate, +} from "./response-prefix-template.js"; +import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; +import { createMockTypingController } from "./test-helpers.js"; +import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; +import { createTypingController } from "./typing.js"; + +describe("matchesMentionWithExplicit", () => { + const mentionRegexes = [/\bopenclaw\b/i]; + + it("checks mentionPatterns even when explicit mention is available", () => { + const result = matchesMentionWithExplicit({ + text: "@openclaw hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("returns false when explicit is false and no regex match", () => { + const result = matchesMentionWithExplicit({ + text: "<@999999> hello", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: true, + }, + }); + expect(result).toBe(false); + }); + + it("returns true when explicitly mentioned even if regexes do not match", () => { + const result = matchesMentionWithExplicit({ + text: "<@123456>", + mentionRegexes: [], + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: true, + canResolveExplicit: true, + }, + }); + expect(result).toBe(true); + }); + + it("falls back to regex matching when explicit mention cannot be resolved", () => { + const result = matchesMentionWithExplicit({ + text: "openclaw please", + mentionRegexes, + explicit: { + hasAnyMention: true, + isExplicitlyMentioned: false, + canResolveExplicit: false, + }, + }); + expect(result).toBe(true); + }); +}); + +// Keep channelData-only payloads so channel-specific replies survive normalization. +describe("normalizeReplyPayload", () => { + it("keeps channelData-only replies", () => { + const payload = { + channelData: { + line: { + flexMessage: { type: "bubble" }, + }, + }, + }; + + const normalized = normalizeReplyPayload(payload); + + expect(normalized).not.toBeNull(); + expect(normalized?.text).toBeUndefined(); + expect(normalized?.channelData).toEqual(payload.channelData); + }); + + it("records silent skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: SILENT_REPLY_TOKEN }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["silent"]); + }); + + it("records empty skips", () => { + const reasons: string[] = []; + const normalized = normalizeReplyPayload( + { text: " " }, + { + onSkip: (reason) => reasons.push(reason), + }, + ); + + expect(normalized).toBeNull(); + expect(reasons).toEqual(["empty"]); + }); +}); + +describe("typing controller", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops after run completion and dispatcher idle", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(1_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + }); + + it("keeps typing until both idle and run completion are set", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + }); + + it("does not start typing after run completion", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + typing.markRunComplete(); + await typing.startTypingOnText("late text"); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).not.toHaveBeenCalled(); + }); + + it("does not restart typing after it has stopped", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markRunComplete(); + typing.markDispatchIdle(); + + vi.advanceTimersByTime(5_000); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + // Late callbacks should be ignored and must not restart the interval. + await typing.startTypingOnText("late tool result"); + vi.advanceTimersByTime(5_000); + 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(); + }); +}); + +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 each enqueued payload separately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + coalescer.enqueue({ text: "Third paragraph" }); + + await Promise.resolve(); + expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]); + coalescer.stop(); + }); + + it("still accumulates when flushOnEnqueue is not set (default)", async () => { + vi.useFakeTimers(); + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + + await vi.advanceTimersByTimeAsync(100); + expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); + coalescer.stop(); + }); + + it("flushes short payloads immediately when flushOnEnqueue is set", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + coalescer.enqueue({ text: "Hi" }); + await Promise.resolve(); + expect(flushes).toEqual(["Hi"]); + coalescer.stop(); + }); + + it("resets char budget per paragraph with flushOnEnqueue", async () => { + const flushes: string[] = []; + const coalescer = createBlockReplyCoalescer({ + config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, + shouldAbort: () => false, + onFlush: (payload) => { + flushes.push(payload.text ?? ""); + }, + }); + + // Each 20-char payload fits within maxChars=30 individually + coalescer.enqueue({ text: "12345678901234567890" }); + coalescer.enqueue({ text: "abcdefghijklmnopqrst" }); + + await Promise.resolve(); + // Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split. + // With flushOnEnqueue, each is sent independently within budget. + expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]); + 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("respects replyToMode off even with existingId", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "off", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBeUndefined(); + expect(planner.hasReplied()).toBe(false); + }); + + it("uses existingId once when mode is first", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "first", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.hasReplied()).toBe(true); + expect(planner.use()).toBeUndefined(); + }); + + it("uses existingId on every call when mode is all", () => { + const planner = createReplyReferencePlanner({ + replyToMode: "all", + existingId: "thread-1", + startId: "parent", + }); + expect(planner.use()).toBe("thread-1"); + expect(planner.use()).toBe("thread-1"); + }); + + 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); + }); +}); + +describe("resolveResponsePrefixTemplate", () => { + it("returns undefined for undefined template", () => { + expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); + }); + + it("returns template as-is when no variables present", () => { + expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); + }); + + it("resolves {model} variable", () => { + const result = resolveResponsePrefixTemplate("[{model}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[gpt-5.2]"); + }); + + it("resolves {modelFull} variable", () => { + const result = resolveResponsePrefixTemplate("[{modelFull}]", { + modelFull: "openai-codex/gpt-5.2", + }); + expect(result).toBe("[openai-codex/gpt-5.2]"); + }); + + it("resolves {provider} variable", () => { + const result = resolveResponsePrefixTemplate("[{provider}]", { + provider: "anthropic", + }); + expect(result).toBe("[anthropic]"); + }); + + it("resolves {thinkingLevel} variable", () => { + const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { + thinkingLevel: "high", + }); + expect(result).toBe("think:high"); + }); + + it("resolves {think} as alias for thinkingLevel", () => { + const result = resolveResponsePrefixTemplate("think:{think}", { + thinkingLevel: "low", + }); + expect(result).toBe("think:low"); + }); + + it("resolves {identity.name} variable", () => { + const result = resolveResponsePrefixTemplate("[{identity.name}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("resolves {identityName} as alias", () => { + const result = resolveResponsePrefixTemplate("[{identityName}]", { + identityName: "OpenClaw", + }); + expect(result).toBe("[OpenClaw]"); + }); + + it("resolves multiple variables", () => { + const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", { + model: "claude-opus-4-5", + thinkingLevel: "high", + }); + expect(result).toBe("[claude-opus-4-5 | think:high]"); + }); + + it("leaves unresolved variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{model}]", {}); + expect(result).toBe("[{model}]"); + }); + + it("leaves unrecognized variables as-is", () => { + const result = resolveResponsePrefixTemplate("[{unknownVar}]", { + model: "gpt-5.2", + }); + expect(result).toBe("[{unknownVar}]"); + }); + + it("handles case insensitivity", () => { + const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { + model: "gpt-5.2", + thinkingLevel: "low", + }); + expect(result).toBe("[gpt-5.2 | low]"); + }); + + it("handles mixed resolved and unresolved variables", () => { + const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { + model: "gpt-5.2", + // provider not provided + }); + expect(result).toBe("[gpt-5.2 | {provider}]"); + }); + + it("handles complex template with all variables", () => { + const result = resolveResponsePrefixTemplate( + "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", + { + identityName: "OpenClaw", + provider: "anthropic", + model: "claude-opus-4-5", + thinkingLevel: "high", + }, + ); + expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); + }); +}); + +describe("extractShortModelName", () => { + it("strips provider prefix", () => { + expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2"); + expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5"); + expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); + }); + + it("strips date suffix", () => { + expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); + expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2"); + }); + + it("strips -latest suffix", () => { + expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); + expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet"); + }); + + it("handles model without provider", () => { + expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2"); + expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5"); + }); + + it("handles full path with provider and date suffix", () => { + expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); + }); + + it("preserves version numbers that look like dates but are not", () => { + // Date suffix must be exactly 8 digits at the end + expect(extractShortModelName("model-v1234567")).toBe("model-v1234567"); + expect(extractShortModelName("model-123456789")).toBe("model-123456789"); + }); +}); + +describe("hasTemplateVariables", () => { + it("returns false for undefined", () => { + expect(hasTemplateVariables(undefined)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(hasTemplateVariables("")).toBe(false); + }); + + it("returns false for static prefix", () => { + expect(hasTemplateVariables("[Claude]")).toBe(false); + }); + + it("returns true when template variables present", () => { + expect(hasTemplateVariables("[{model}]")).toBe(true); + expect(hasTemplateVariables("{provider}")).toBe(true); + expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true); + }); + + it("returns true for multiple variables", () => { + expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true); + }); + + it("handles consecutive calls correctly (regex lastIndex reset)", () => { + // First call + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Second call should still work + expect(hasTemplateVariables("[{model}]")).toBe(true); + // Static string should return false + expect(hasTemplateVariables("[Claude]")).toBe(false); + }); +}); diff --git a/src/auto-reply/reply/response-prefix-template.test.ts b/src/auto-reply/reply/response-prefix-template.test.ts deleted file mode 100644 index 41c28e23ed9..00000000000 --- a/src/auto-reply/reply/response-prefix-template.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - extractShortModelName, - hasTemplateVariables, - resolveResponsePrefixTemplate, -} from "./response-prefix-template.js"; - -describe("resolveResponsePrefixTemplate", () => { - it("returns undefined for undefined template", () => { - expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined(); - }); - - it("returns template as-is when no variables present", () => { - expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]"); - }); - - it("resolves {model} variable", () => { - const result = resolveResponsePrefixTemplate("[{model}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[gpt-5.2]"); - }); - - it("resolves {modelFull} variable", () => { - const result = resolveResponsePrefixTemplate("[{modelFull}]", { - modelFull: "openai-codex/gpt-5.2", - }); - expect(result).toBe("[openai-codex/gpt-5.2]"); - }); - - it("resolves {provider} variable", () => { - const result = resolveResponsePrefixTemplate("[{provider}]", { - provider: "anthropic", - }); - expect(result).toBe("[anthropic]"); - }); - - it("resolves {thinkingLevel} variable", () => { - const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", { - thinkingLevel: "high", - }); - expect(result).toBe("think:high"); - }); - - it("resolves {think} as alias for thinkingLevel", () => { - const result = resolveResponsePrefixTemplate("think:{think}", { - thinkingLevel: "low", - }); - expect(result).toBe("think:low"); - }); - - it("resolves {identity.name} variable", () => { - const result = resolveResponsePrefixTemplate("[{identity.name}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves {identityName} as alias", () => { - const result = resolveResponsePrefixTemplate("[{identityName}]", { - identityName: "OpenClaw", - }); - expect(result).toBe("[OpenClaw]"); - }); - - it("resolves multiple variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", { - model: "claude-opus-4-5", - thinkingLevel: "high", - }); - expect(result).toBe("[claude-opus-4-5 | think:high]"); - }); - - it("leaves unresolved variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{model}]", {}); - expect(result).toBe("[{model}]"); - }); - - it("leaves unrecognized variables as-is", () => { - const result = resolveResponsePrefixTemplate("[{unknownVar}]", { - model: "gpt-5.2", - }); - expect(result).toBe("[{unknownVar}]"); - }); - - it("handles case insensitivity", () => { - const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", { - model: "gpt-5.2", - thinkingLevel: "low", - }); - expect(result).toBe("[gpt-5.2 | low]"); - }); - - it("handles mixed resolved and unresolved variables", () => { - const result = resolveResponsePrefixTemplate("[{model} | {provider}]", { - model: "gpt-5.2", - // provider not provided - }); - expect(result).toBe("[gpt-5.2 | {provider}]"); - }); - - it("handles complex template with all variables", () => { - const result = resolveResponsePrefixTemplate( - "[{identity.name}] {provider}/{model} (think:{thinkingLevel})", - { - identityName: "OpenClaw", - provider: "anthropic", - model: "claude-opus-4-5", - thinkingLevel: "high", - }, - ); - expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)"); - }); -}); - -describe("extractShortModelName", () => { - it("strips provider prefix", () => { - expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5"); - expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("strips date suffix", () => { - expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2"); - }); - - it("strips -latest suffix", () => { - expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet"); - }); - - it("handles model without provider", () => { - expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2"); - expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5"); - }); - - it("handles full path with provider and date suffix", () => { - expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5"); - }); - - it("preserves version numbers that look like dates but are not", () => { - // Date suffix must be exactly 8 digits at the end - expect(extractShortModelName("model-v1234567")).toBe("model-v1234567"); - expect(extractShortModelName("model-123456789")).toBe("model-123456789"); - }); -}); - -describe("hasTemplateVariables", () => { - it("returns false for undefined", () => { - expect(hasTemplateVariables(undefined)).toBe(false); - }); - - it("returns false for empty string", () => { - expect(hasTemplateVariables("")).toBe(false); - }); - - it("returns false for static prefix", () => { - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); - - it("returns true when template variables present", () => { - expect(hasTemplateVariables("[{model}]")).toBe(true); - expect(hasTemplateVariables("{provider}")).toBe(true); - expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true); - }); - - it("returns true for multiple variables", () => { - expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true); - }); - - it("handles consecutive calls correctly (regex lastIndex reset)", () => { - // First call - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Second call should still work - expect(hasTemplateVariables("[{model}]")).toBe(true); - // Static string should return false - expect(hasTemplateVariables("[Claude]")).toBe(false); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts deleted file mode 100644 index edefc57f8ee..00000000000 --- a/src/auto-reply/reply/typing.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -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", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("stops after run completion and dispatcher idle", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(1_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(4); - }); - - it("keeps typing until both idle and run completion are set", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markDispatchIdle(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - - typing.markRunComplete(); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).toHaveBeenCalledTimes(3); - }); - - it("does not start typing after run completion", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - typing.markRunComplete(); - await typing.startTypingOnText("late text"); - vi.advanceTimersByTime(2_000); - expect(onReplyStart).not.toHaveBeenCalled(); - }); - - it("does not restart typing after it has stopped", async () => { - vi.useFakeTimers(); - const onReplyStart = vi.fn(async () => {}); - const typing = createTypingController({ - onReplyStart, - typingIntervalSeconds: 1, - typingTtlMs: 30_000, - }); - - await typing.startTypingLoop(); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - typing.markRunComplete(); - typing.markDispatchIdle(); - - vi.advanceTimersByTime(5_000); - expect(onReplyStart).toHaveBeenCalledTimes(1); - - // Late callbacks should be ignored and must not restart the interval. - await typing.startTypingOnText("late tool result"); - vi.advanceTimersByTime(5_000); - 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(); - }); -}); From a1c50b4ee36091de70c739e899a6394acfe55e28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:50:16 +0000 Subject: [PATCH 051/178] perf(test): consolidate channel plugin suites --- .../plugins/base-types-assignability.test.ts | 46 -- src/channels/plugins/catalog.test.ts | 51 --- src/channels/plugins/config-writes.test.ts | 42 -- src/channels/plugins/directory-config.test.ts | 146 ------- src/channels/plugins/index.test.ts | 46 -- src/channels/plugins/load.test.ts | 71 ---- .../plugins/normalize/imessage.test.ts | 14 - src/channels/plugins/normalize/signal.test.ts | 37 -- .../plugins/onboarding/signal.test.ts | 31 -- .../plugins/outbound/telegram.test.ts | 80 ---- .../plugins/outbound/whatsapp.test.ts | 43 -- src/channels/plugins/plugins-channel.test.ts | 201 +++++++++ src/channels/plugins/plugins-core.test.ts | 395 ++++++++++++++++++ 13 files changed, 596 insertions(+), 607 deletions(-) delete mode 100644 src/channels/plugins/base-types-assignability.test.ts delete mode 100644 src/channels/plugins/catalog.test.ts delete mode 100644 src/channels/plugins/config-writes.test.ts delete mode 100644 src/channels/plugins/directory-config.test.ts delete mode 100644 src/channels/plugins/index.test.ts delete mode 100644 src/channels/plugins/load.test.ts delete mode 100644 src/channels/plugins/normalize/imessage.test.ts delete mode 100644 src/channels/plugins/normalize/signal.test.ts delete mode 100644 src/channels/plugins/onboarding/signal.test.ts delete mode 100644 src/channels/plugins/outbound/telegram.test.ts delete mode 100644 src/channels/plugins/outbound/whatsapp.test.ts create mode 100644 src/channels/plugins/plugins-channel.test.ts create mode 100644 src/channels/plugins/plugins-core.test.ts diff --git a/src/channels/plugins/base-types-assignability.test.ts b/src/channels/plugins/base-types-assignability.test.ts deleted file mode 100644 index 839146018fe..00000000000 --- a/src/channels/plugins/base-types-assignability.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expectTypeOf } from "vitest"; -import type { DiscordProbe } from "../../discord/probe.js"; -import type { DiscordTokenResolution } from "../../discord/token.js"; -import type { IMessageProbe } from "../../imessage/probe.js"; -import type { LineProbeResult } from "../../line/types.js"; -import type { SignalProbe } from "../../signal/probe.js"; -import type { SlackProbe } from "../../slack/probe.js"; -import type { TelegramProbe } from "../../telegram/probe.js"; -import type { TelegramTokenResolution } from "../../telegram/token.js"; -import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; - -describe("BaseProbeResult assignability", () => { - it("TelegramProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SlackProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SignalProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("IMessageProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("LineProbeResult satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); - -describe("BaseTokenResolution assignability", () => { - it("TelegramTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts deleted file mode 100644 index d62fac8a8fc..00000000000 --- a/src/channels/plugins/catalog.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; - -describe("channel plugin catalog", () => { - it("includes Microsoft Teams", () => { - const entry = getChannelPluginCatalogEntry("msteams"); - expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); - expect(entry?.meta.aliases).toContain("teams"); - }); - - it("lists plugin catalog entries", () => { - const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); - expect(ids).toContain("msteams"); - }); - - it("includes external catalog entries", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/demo-channel", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo entry", - order: 999, - }, - install: { - npmSpec: "@openclaw/demo-channel", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( - (entry) => entry.id, - ); - expect(ids).toContain("demo-channel"); - }); -}); diff --git a/src/channels/plugins/config-writes.test.ts b/src/channels/plugins/config-writes.test.ts deleted file mode 100644 index 00fe9164f8e..00000000000 --- a/src/channels/plugins/config-writes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveChannelConfigWrites } from "./config-writes.js"; - -describe("resolveChannelConfigWrites", () => { - it("defaults to allow when unset", () => { - const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); - }); - - it("blocks when channel config disables writes", () => { - const cfg = { channels: { slack: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); - }); - - it("account override wins over channel default", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); - - it("matches account ids case-insensitively", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - Work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); -}); diff --git a/src/channels/plugins/directory-config.test.ts b/src/channels/plugins/directory-config.test.ts deleted file mode 100644 index ab043e1b36d..00000000000 --- a/src/channels/plugins/directory-config.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; - -describe("directory (config-backed)", () => { - it("lists Slack peers/groups from config", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U123", "user:U999"] }, - dms: { U234: {} }, - channels: { C111: { users: ["U777"] } }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listSlackDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual([ - "user:u123", - "user:u234", - "user:u777", - "user:u999", - ]); - - const groups = await listSlackDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); - }); - - it("lists Discord peers/groups from config (numeric ids only)", async () => { - const cfg = { - channels: { - discord: { - token: "discord-test", - dm: { allowFrom: ["<@111>", "nope"] }, - dms: { "222": {} }, - guilds: { - "123": { - users: ["<@12345>", "not-an-id"], - channels: { - "555": {}, - "channel:666": {}, - general: {}, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listDiscordDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); - - const groups = await listDiscordDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); - }); - - it("lists Telegram peers/groups from config", async () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - allowFrom: ["123", "alice", "tg:@bob"], - dms: { "456": {} }, - groups: { "-1001": {}, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listTelegramDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); - - const groups = await listTelegramDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["-1001"]); - }); - - it("lists WhatsApp peers/groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["+15550000000", "*", "123@g.us"], - groups: { "999@g.us": { requireMention: true }, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listWhatsAppDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); - - const groups = await listWhatsAppDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); - }); -}); diff --git a/src/channels/plugins/index.test.ts b/src/channels/plugins/index.test.ts deleted file mode 100644 index 63162f09018..00000000000 --- a/src/channels/plugins/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { listChannelPlugins } from "./index.js"; - -describe("channel plugin registry", () => { - const emptyRegistry = createTestRegistry([]); - - const createPlugin = (id: string): ChannelPlugin => ({ - id, - meta: { - id, - label: id, - selectionLabel: id, - docsPath: `/channels/${id}`, - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - }); - - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("sorts channel plugins by configured order", () => { - const registry = createTestRegistry( - ["slack", "telegram", "signal"].map((id) => ({ - pluginId: id, - plugin: createPlugin(id), - source: "test", - })), - ); - setActivePluginRegistry(registry); - const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["telegram", "slack", "signal"]); - }); -}); diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts deleted file mode 100644 index f3daf0543c7..00000000000 --- a/src/channels/plugins/load.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginRegistry } from "../../plugins/registry.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { loadChannelPlugin } from "./load.js"; -import { loadChannelOutboundAdapter } from "./outbound/load.js"; - -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - -const emptyRegistry = createRegistry([]); - -const msteamsOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m1" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), -}; - -const msteamsPlugin: ChannelPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - outbound: msteamsOutbound, -}; - -const registryWithMSTeams = createRegistry([ - { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, -]); - -describe("channel plugin loader", () => { - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithMSTeams); - const plugin = await loadChannelPlugin("msteams"); - expect(plugin).toBe(msteamsPlugin); - }); - - it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithMSTeams); - const outbound = await loadChannelOutboundAdapter("msteams"); - expect(outbound).toBe(msteamsOutbound); - }); -}); diff --git a/src/channels/plugins/normalize/imessage.test.ts b/src/channels/plugins/normalize/imessage.test.ts deleted file mode 100644 index a3cbf0501eb..00000000000 --- a/src/channels/plugins/normalize/imessage.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeIMessageMessagingTarget } from "./imessage.js"; - -describe("imessage target normalization", () => { - it("preserves service prefixes for handles", () => { - expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); - }); - - it("drops service prefixes for chat targets", () => { - expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); - expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); - expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); - }); -}); diff --git a/src/channels/plugins/normalize/signal.test.ts b/src/channels/plugins/normalize/signal.test.ts deleted file mode 100644 index 547a8f30d91..00000000000 --- a/src/channels/plugins/normalize/signal.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js"; - -describe("signal target normalization", () => { - it("normalizes uuid targets by stripping uuid:", () => { - expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("normalizes signal:uuid targets", () => { - expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("preserves case for group targets", () => { - expect( - normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), - ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); - }); - - it("accepts uuid prefixes for target detection", () => { - expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - }); - - it("accepts compact UUIDs for target detection", () => { - expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); - expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); - }); - - it("rejects invalid uuid prefixes", () => { - expect(looksLikeSignalTargetId("uuid:")).toBe(false); - expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); - }); -}); diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts deleted file mode 100644 index 23f218bd4c4..00000000000 --- a/src/channels/plugins/onboarding/signal.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput } from "./signal.js"; - -describe("normalizeSignalAccountInput", () => { - it("accepts already normalized numbers", () => { - expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); - }); - - it("normalizes formatted input", () => { - expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); - }); - - it("rejects empty input", () => { - expect(normalizeSignalAccountInput(" ")).toBeNull(); - }); - - it("rejects non-numeric input", () => { - expect(normalizeSignalAccountInput("ok")).toBeNull(); - expect(normalizeSignalAccountInput("++--")).toBeNull(); - }); - - it("rejects inputs with stray + characters", () => { - expect(normalizeSignalAccountInput("++12345")).toBeNull(); - expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); - }); - - it("rejects numbers that are too short or too long", () => { - expect(normalizeSignalAccountInput("+1234")).toBeNull(); - expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); - }); -}); diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts deleted file mode 100644 index 7981addf566..00000000000 --- a/src/channels/plugins/outbound/telegram.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramOutbound } from "./telegram.js"; - -describe("telegramOutbound.sendPayload", () => { - it("sends text payload with buttons", async () => { - const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Hello", - channelData: { - telegram: { - buttons: [[{ text: "Option", callback_data: "/option" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(1); - expect(sendTelegram).toHaveBeenCalledWith( - "telegram:123", - "Hello", - expect.objectContaining({ - buttons: [[{ text: "Option", callback_data: "/option" }]], - textMode: "html", - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); - }); - - it("sends media payloads and attaches buttons only to first", async () => { - const sendTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) - .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Caption", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - channelData: { - telegram: { - buttons: [[{ text: "Go", callback_data: "/go" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(2); - expect(sendTelegram).toHaveBeenNthCalledWith( - 1, - "telegram:123", - "Caption", - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - buttons: [[{ text: "Go", callback_data: "/go" }]], - }), - ); - const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; - expect(sendTelegram).toHaveBeenNthCalledWith( - 2, - "telegram:123", - "", - expect.objectContaining({ - mediaUrl: "https://example.com/b.png", - }), - ); - expect(secondOpts?.buttons).toBeUndefined(); - expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); - }); -}); diff --git a/src/channels/plugins/outbound/whatsapp.test.ts b/src/channels/plugins/outbound/whatsapp.test.ts deleted file mode 100644 index 7922ed00795..00000000000 --- a/src/channels/plugins/outbound/whatsapp.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { whatsappOutbound } from "./whatsapp.js"; - -describe("whatsappOutbound.resolveTarget", () => { - it("returns error when no target is provided even with allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: undefined, - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("returns error when implicit target is not in allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "+15550000000", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("keeps group JID targets even when allowFrom does not contain them", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "120363401234567890@g.us", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: true, - to: "120363401234567890@g.us", - }); - }); -}); diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts new file mode 100644 index 00000000000..91277158d2e --- /dev/null +++ b/src/channels/plugins/plugins-channel.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; +import { normalizeSignalAccountInput } from "./onboarding/signal.js"; +import { telegramOutbound } from "./outbound/telegram.js"; +import { whatsappOutbound } from "./outbound/whatsapp.js"; + +describe("imessage target normalization", () => { + it("preserves service prefixes for handles", () => { + expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); + }); + + it("drops service prefixes for chat targets", () => { + expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); + expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); + expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); + }); +}); + +describe("signal target normalization", () => { + it("normalizes uuid targets by stripping uuid:", () => { + expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("normalizes signal:uuid targets", () => { + expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("preserves case for group targets", () => { + expect( + normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), + ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + + it("accepts uuid prefixes for target detection", () => { + expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + + it("accepts compact UUIDs for target detection", () => { + expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); + }); + + it("rejects invalid uuid prefixes", () => { + expect(looksLikeSignalTargetId("uuid:")).toBe(false); + expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); + }); +}); + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); + +describe("whatsappOutbound.resolveTarget", () => { + it("returns error when no target is provided even with allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: undefined, + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("returns error when implicit target is not in allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "+15550000000", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("keeps group JID targets even when allowFrom does not contain them", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "120363401234567890@g.us", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: true, + to: "120363401234567890@g.us", + }); + }); +}); + +describe("normalizeSignalAccountInput", () => { + it("accepts already normalized numbers", () => { + expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); + }); + + it("normalizes formatted input", () => { + expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); + }); + + it("rejects empty input", () => { + expect(normalizeSignalAccountInput(" ")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(normalizeSignalAccountInput("ok")).toBeNull(); + expect(normalizeSignalAccountInput("++--")).toBeNull(); + }); + + it("rejects inputs with stray + characters", () => { + expect(normalizeSignalAccountInput("++12345")).toBeNull(); + expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); + }); + + it("rejects numbers that are too short or too long", () => { + expect(normalizeSignalAccountInput("+1234")).toBeNull(); + expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); + }); +}); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts new file mode 100644 index 00000000000..64daeb574a2 --- /dev/null +++ b/src/channels/plugins/plugins-core.test.ts @@ -0,0 +1,395 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import type { DiscordProbe } from "../../discord/probe.js"; +import type { DiscordTokenResolution } from "../../discord/token.js"; +import type { IMessageProbe } from "../../imessage/probe.js"; +import type { LineProbeResult } from "../../line/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { SignalProbe } from "../../signal/probe.js"; +import type { SlackProbe } from "../../slack/probe.js"; +import type { TelegramProbe } from "../../telegram/probe.js"; +import type { TelegramTokenResolution } from "../../telegram/token.js"; +import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; +import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; +import { resolveChannelConfigWrites } from "./config-writes.js"; +import { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { listChannelPlugins } from "./index.js"; +import { loadChannelPlugin } from "./load.js"; +import { loadChannelOutboundAdapter } from "./outbound/load.js"; + +describe("channel plugin registry", () => { + const emptyRegistry = createTestRegistry([]); + + const createPlugin = (id: string): ChannelPlugin => ({ + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }); + + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("sorts channel plugins by configured order", () => { + const registry = createTestRegistry( + ["slack", "telegram", "signal"].map((id) => ({ + pluginId: id, + plugin: createPlugin(id), + source: "test", + })), + ); + setActivePluginRegistry(registry); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + expect(pluginIds).toEqual(["telegram", "slack", "signal"]); + }); +}); + +describe("channel plugin catalog", () => { + it("includes Microsoft Teams", () => { + const entry = getChannelPluginCatalogEntry("msteams"); + expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); + expect(entry?.meta.aliases).toContain("teams"); + }); + + it("lists plugin catalog entries", () => { + const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); + expect(ids).toContain("msteams"); + }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@openclaw/demo-channel", + openclaw: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@openclaw/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); +}); + +const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ + plugins: [], + tools: [], + channels, + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + +const emptyRegistry = createRegistry([]); + +const msteamsOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async () => ({ channel: "msteams", messageId: "m1" }), + sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), +}; + +const msteamsPlugin: ChannelPlugin = { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + outbound: msteamsOutbound, +}; + +const registryWithMSTeams = createRegistry([ + { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, +]); + +describe("channel plugin loader", () => { + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("loads channel plugins from the active registry", async () => { + setActivePluginRegistry(registryWithMSTeams); + const plugin = await loadChannelPlugin("msteams"); + expect(plugin).toBe(msteamsPlugin); + }); + + it("loads outbound adapters from registered plugins", async () => { + setActivePluginRegistry(registryWithMSTeams); + const outbound = await loadChannelOutboundAdapter("msteams"); + expect(outbound).toBe(msteamsOutbound); + }); +}); + +describe("BaseProbeResult assignability", () => { + it("TelegramProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SlackProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SignalProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("IMessageProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("LineProbeResult satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("BaseTokenResolution assignability", () => { + it("TelegramTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("resolveChannelConfigWrites", () => { + it("defaults to allow when unset", () => { + const cfg = {}; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + }); + + it("blocks when channel config disables writes", () => { + const cfg = { channels: { slack: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + }); + + it("account override wins over channel default", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + Work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); +}); + +describe("directory (config-backed)", () => { + it("lists Slack peers/groups from config", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U123", "user:U999"] }, + dms: { U234: {} }, + channels: { C111: { users: ["U777"] } }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listSlackDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual([ + "user:u123", + "user:u234", + "user:u777", + "user:u999", + ]); + + const groups = await listSlackDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); + }); + + it("lists Discord peers/groups from config (numeric ids only)", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + dm: { allowFrom: ["<@111>", "nope"] }, + dms: { "222": {} }, + guilds: { + "123": { + users: ["<@12345>", "not-an-id"], + channels: { + "555": {}, + "channel:666": {}, + general: {}, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listDiscordDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); + + const groups = await listDiscordDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); + }); + + it("lists Telegram peers/groups from config", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + allowFrom: ["123", "alice", "tg:@bob"], + dms: { "456": {} }, + groups: { "-1001": {}, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listTelegramDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); + + const groups = await listTelegramDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["-1001"]); + }); + + it("lists WhatsApp peers/groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["+15550000000", "*", "123@g.us"], + groups: { "999@g.us": { requireMention: true }, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listWhatsAppDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); + + const groups = await listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); + }); +}); From 37086d0c3e4cb72d2ecab8c4b3b0f5473739a3c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:53:33 +0000 Subject: [PATCH 052/178] perf(test): consolidate sessions tool e2e suites --- .../sessions-announce-target.e2e.test.ts | 103 -------- src/agents/tools/sessions-helpers.e2e.test.ts | 58 ----- .../sessions-list-tool.gating.e2e.test.ts | 42 ---- .../sessions-send-tool.gating.e2e.test.ts | 42 ---- src/agents/tools/sessions.e2e.test.ts | 219 ++++++++++++++++++ 5 files changed, 219 insertions(+), 245 deletions(-) delete mode 100644 src/agents/tools/sessions-announce-target.e2e.test.ts delete mode 100644 src/agents/tools/sessions-helpers.e2e.test.ts delete mode 100644 src/agents/tools/sessions-list-tool.gating.e2e.test.ts delete mode 100644 src/agents/tools/sessions-send-tool.gating.e2e.test.ts create mode 100644 src/agents/tools/sessions.e2e.test.ts diff --git a/src/agents/tools/sessions-announce-target.e2e.test.ts b/src/agents/tools/sessions-announce-target.e2e.test.ts deleted file mode 100644 index fe28be7dff9..00000000000 --- a/src/agents/tools/sessions-announce-target.e2e.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); - -const installRegistry = async () => { - const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "discord", - source: "test", - plugin: { - id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test stub.", - }, - capabilities: { chatTypes: ["direct", "channel", "thread"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - { - pluginId: "whatsapp", - source: "test", - plugin: { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp test stub.", - preferSessionLookupForAnnounceTarget: true, - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - }, - }, - ]), - ); -}; - -describe("resolveAnnounceTarget", () => { - beforeEach(async () => { - callGatewayMock.mockReset(); - await installRegistry(); - }); - - it("derives non-WhatsApp announce targets from the session key", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:discord:group:dev", - displayKey: "agent:main:discord:group:dev", - }); - expect(target).toEqual({ channel: "discord", to: "channel:dev" }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("hydrates WhatsApp accountId from sessions.list when available", async () => { - const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); - callGatewayMock.mockResolvedValueOnce({ - sessions: [ - { - key: "agent:main:whatsapp:group:123@g.us", - deliveryContext: { - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }, - }, - ], - }); - - const target = await resolveAnnounceTarget({ - sessionKey: "agent:main:whatsapp:group:123@g.us", - displayKey: "agent:main:whatsapp:group:123@g.us", - }); - expect(target).toEqual({ - channel: "whatsapp", - to: "123@g.us", - accountId: "work", - }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; - expect(first).toBeDefined(); - expect(first?.method).toBe("sessions.list"); - }); -}); diff --git a/src/agents/tools/sessions-helpers.e2e.test.ts b/src/agents/tools/sessions-helpers.e2e.test.ts deleted file mode 100644 index 887cc1f4670..00000000000 --- a/src/agents/tools/sessions-helpers.e2e.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; - -describe("sanitizeTextContent", () => { - it("strips minimax tool call XML and downgraded markers", () => { - const input = - 'Hello payload ' + - "[Tool Call: foo (ID: 1)] world"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Hello world"); - expect(result).not.toContain("invoke"); - expect(result).not.toContain("Tool Call"); - }); - - it("strips thinking tags", () => { - const input = "Before secret after"; - const result = sanitizeTextContent(input).trim(); - expect(result).toBe("Before after"); - }); -}); - -describe("extractAssistantText", () => { - it("sanitizes blocks without injecting newlines", () => { - const message = { - role: "assistant", - content: [ - { type: "text", text: "Hi " }, - { type: "text", text: "secretthere" }, - ], - }; - expect(extractAssistantText(message)).toBe("Hi there"); - }); - - it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { - const message = { - role: "assistant", - stopReason: "error", - errorMessage: "500 Internal Server Error", - content: [{ type: "text", text: "500 Internal Server Error" }], - }; - expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); - }); - - it("keeps normal status text that mentions billing", () => { - const message = { - role: "assistant", - content: [ - { - type: "text", - text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - }, - ], - }; - expect(extractAssistantText(message)).toBe( - "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", - ); - }); -}); diff --git a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts b/src/agents/tools/sessions-list-tool.gating.e2e.test.ts deleted file mode 100644 index 636c2c5a1c3..00000000000 --- a/src/agents/tools/sessions-list-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsListTool } from "./sessions-list-tool.js"; - -describe("sessions_list gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ - path: "/tmp/sessions.json", - sessions: [ - { key: "agent:main:main", kind: "direct" }, - { key: "agent:other:main", kind: "direct" }, - ], - }); - }); - - it("filters out other agents when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - expect(result.details).toMatchObject({ - count: 1, - sessions: [{ key: "agent:main:main" }], - }); - }); -}); diff --git a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts b/src/agents/tools/sessions-send-tool.gating.e2e.test.ts deleted file mode 100644 index 76a242c9898..00000000000 --- a/src/agents/tools/sessions-send-tool.gating.e2e.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const callGatewayMock = vi.fn(); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => - ({ - session: { scope: "per-sender", mainKey: "main" }, - tools: { agentToAgent: { enabled: false } }, - }) as never, - }; -}); - -import { createSessionsSendTool } from "./sessions-send-tool.js"; - -describe("sessions_send gating", () => { - beforeEach(() => { - callGatewayMock.mockReset(); - }); - - it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); - - const result = await tool.execute("call1", { - sessionKey: "agent:other:main", - message: "hi", - timeoutSeconds: 0, - }); - - expect(callGatewayMock).not.toHaveBeenCalled(); - expect(result.details).toMatchObject({ status: "forbidden" }); - }); -}); diff --git a/src/agents/tools/sessions.e2e.test.ts b/src/agents/tools/sessions.e2e.test.ts new file mode 100644 index 00000000000..f94be78d57f --- /dev/null +++ b/src/agents/tools/sessions.e2e.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; + +const callGatewayMock = vi.fn(); +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => + ({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { agentToAgent: { enabled: false } }, + }) as never, + }; +}); + +import { createSessionsListTool } from "./sessions-list-tool.js"; +import { createSessionsSendTool } from "./sessions-send-tool.js"; + +const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js"); + +const installRegistry = async () => { + const { setActivePluginRegistry } = await import("../../plugins/runtime.js"); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test stub.", + }, + capabilities: { chatTypes: ["direct", "channel", "thread"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + { + pluginId: "whatsapp", + source: "test", + plugin: { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp test stub.", + preferSessionLookupForAnnounceTarget: true, + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + }, + }, + ]), + ); +}; + +describe("sanitizeTextContent", () => { + it("strips minimax tool call XML and downgraded markers", () => { + const input = + 'Hello payload ' + + "[Tool Call: foo (ID: 1)] world"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Hello world"); + expect(result).not.toContain("invoke"); + expect(result).not.toContain("Tool Call"); + }); + + it("strips thinking tags", () => { + const input = "Before secret after"; + const result = sanitizeTextContent(input).trim(); + expect(result).toBe("Before after"); + }); +}); + +describe("extractAssistantText", () => { + it("sanitizes blocks without injecting newlines", () => { + const message = { + role: "assistant", + content: [ + { type: "text", text: "Hi " }, + { type: "text", text: "secretthere" }, + ], + }; + expect(extractAssistantText(message)).toBe("Hi there"); + }); + + it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { + const message = { + role: "assistant", + stopReason: "error", + errorMessage: "500 Internal Server Error", + content: [{ type: "text", text: "500 Internal Server Error" }], + }; + expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); + }); + + it("keeps normal status text that mentions billing", () => { + const message = { + role: "assistant", + content: [ + { + type: "text", + text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + }, + ], + }; + expect(extractAssistantText(message)).toBe( + "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", + ); + }); +}); + +describe("resolveAnnounceTarget", () => { + beforeEach(async () => { + callGatewayMock.mockReset(); + await installRegistry(); + }); + + it("derives non-WhatsApp announce targets from the session key", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:discord:group:dev", + displayKey: "agent:main:discord:group:dev", + }); + expect(target).toEqual({ channel: "discord", to: "channel:dev" }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("hydrates WhatsApp accountId from sessions.list when available", async () => { + const { resolveAnnounceTarget } = await loadResolveAnnounceTarget(); + callGatewayMock.mockResolvedValueOnce({ + sessions: [ + { + key: "agent:main:whatsapp:group:123@g.us", + deliveryContext: { + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }, + }, + ], + }); + + const target = await resolveAnnounceTarget({ + sessionKey: "agent:main:whatsapp:group:123@g.us", + displayKey: "agent:main:whatsapp:group:123@g.us", + }); + expect(target).toEqual({ + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; + expect(first).toBeDefined(); + expect(first?.method).toBe("sessions.list"); + }); +}); + +describe("sessions_list gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ + path: "/tmp/sessions.json", + sessions: [ + { key: "agent:main:main", kind: "direct" }, + { key: "agent:other:main", kind: "direct" }, + ], + }); + }); + + it("filters out other agents when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const result = await tool.execute("call1", {}); + expect(result.details).toMatchObject({ + count: 1, + sessions: [{ key: "agent:main:main" }], + }); + }); +}); + +describe("sessions_send gating", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:main", + agentChannel: "whatsapp", + }); + + const result = await tool.execute("call1", { + sessionKey: "agent:other:main", + message: "hi", + timeoutSeconds: 0, + }); + + expect(callGatewayMock).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ status: "forbidden" }); + }); +}); From 722bfaa9c968e003ea1ea407051f25ac25ce1d8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:56:49 +0000 Subject: [PATCH 053/178] perf(test): consolidate reply plumbing/state suites --- .../reply/agent-runner-utils.test.ts | 106 ----- src/auto-reply/reply/history.test.ts | 152 ------- src/auto-reply/reply/memory-flush.test.ts | 133 ------ .../reply-payloads.auto-threading.test.ts | 88 ---- src/auto-reply/reply/reply-plumbing.test.ts | 253 ++++++++++++ src/auto-reply/reply/reply-state.test.ts | 381 ++++++++++++++++++ ...n-updates.incrementcompactioncount.test.ts | 98 ----- src/auto-reply/reply/subagents-utils.test.ts | 61 --- 8 files changed, 634 insertions(+), 638 deletions(-) delete mode 100644 src/auto-reply/reply/agent-runner-utils.test.ts delete mode 100644 src/auto-reply/reply/history.test.ts delete mode 100644 src/auto-reply/reply/memory-flush.test.ts delete mode 100644 src/auto-reply/reply/reply-payloads.auto-threading.test.ts create mode 100644 src/auto-reply/reply/reply-plumbing.test.ts create mode 100644 src/auto-reply/reply/reply-state.test.ts delete mode 100644 src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts delete mode 100644 src/auto-reply/reply/subagents-utils.test.ts diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts deleted file mode 100644 index 145b93bd61d..00000000000 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { TemplateContext } from "../templating.js"; -import { buildThreadingToolContext } from "./agent-runner-utils.js"; - -describe("buildThreadingToolContext", () => { - const cfg = {} as OpenClawConfig; - - it("uses conversation id for WhatsApp", () => { - const sessionCtx = { - Provider: "whatsapp", - From: "123@g.us", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("123@g.us"); - }); - - it("falls back to To for WhatsApp when From is missing", () => { - const sessionCtx = { - Provider: "whatsapp", - To: "+15550001", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("+15550001"); - }); - - it("uses the recipient id for other channels", () => { - const sessionCtx = { - Provider: "telegram", - From: "user:42", - To: "chat:99", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat:99"); - }); - - it("uses the sender handle for iMessage direct chats", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "direct", - From: "imessage:+15550001", - To: "chat_id:12", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("imessage:+15550001"); - }); - - it("uses chat_id for iMessage groups", () => { - const sessionCtx = { - Provider: "imessage", - ChatType: "group", - From: "imessage:group:7", - To: "chat_id:7", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: cfg, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("chat_id:7"); - }); - - it("prefers MessageThreadId for Slack tool threading", () => { - const sessionCtx = { - Provider: "slack", - To: "channel:C1", - MessageThreadId: "123.456", - } as TemplateContext; - - const result = buildThreadingToolContext({ - sessionCtx, - config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, - hasRepliedRef: undefined, - }); - - expect(result.currentChannelId).toBe("C1"); - expect(result.currentThreadTs).toBe("123.456"); - }); -}); diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts deleted file mode 100644 index 7991731daf6..00000000000 --- a/src/auto-reply/reply/history.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - appendHistoryEntry, - buildHistoryContext, - buildHistoryContextFromEntries, - buildHistoryContextFromMap, - buildPendingHistoryContextFromMap, - clearHistoryEntriesIfEnabled, - HISTORY_CONTEXT_MARKER, - recordPendingHistoryEntryIfEnabled, -} from "./history.js"; -import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; - -describe("history helpers", () => { - it("returns current message when history is empty", () => { - const result = buildHistoryContext({ - historyText: " ", - currentMessage: "hello", - }); - expect(result).toBe("hello"); - }); - - it("wraps history entries and excludes current by default", () => { - const result = buildHistoryContextFromEntries({ - entries: [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ], - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).not.toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("trims history to configured limit", () => { - const historyMap = new Map(); - - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "A", body: "one" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - appendHistoryEntry({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "C", body: "three" }, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); - }); - - it("builds context from map and appends entry", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - entry: { sender: "C", body: "three" }, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).not.toContain("C: three"); - }); - - it("builds context from pending map without appending", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - const result = buildPendingHistoryContextFromMap({ - historyMap, - historyKey: "group", - limit: 3, - currentMessage: "current", - formatEntry: (entry) => `${entry.sender}: ${entry.body}`, - }); - - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - expect(result).toContain(HISTORY_CONTEXT_MARKER); - expect(result).toContain("A: one"); - expect(result).toContain("B: two"); - expect(result).toContain(CURRENT_MESSAGE_MARKER); - expect(result).toContain("current"); - }); - - it("records pending entries only when enabled", () => { - const historyMap = new Map(); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 0, - entry: { sender: "A", body: "one" }, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: null, - }); - expect(historyMap.get("group")).toEqual(undefined); - - recordPendingHistoryEntryIfEnabled({ - historyMap, - historyKey: "group", - limit: 2, - entry: { sender: "B", body: "two" }, - }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); - }); - - it("clears history entries only when enabled", () => { - const historyMap = new Map(); - historyMap.set("group", [ - { sender: "A", body: "one" }, - { sender: "B", body: "two" }, - ]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); - expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); - - clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); - expect(historyMap.get("group")).toEqual([]); - }); -}); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts deleted file mode 100644 index e3dcc124e18..00000000000 --- a/src/auto-reply/reply/memory-flush.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - resolveMemoryFlushContextWindowTokens, - resolveMemoryFlushSettings, - shouldRunMemoryFlush, -} from "./memory-flush.js"; - -describe("memory flush settings", () => { - it("defaults to enabled with fallback prompt and system prompt", () => { - const settings = resolveMemoryFlushSettings(); - expect(settings).not.toBeNull(); - expect(settings?.enabled).toBe(true); - expect(settings?.prompt.length).toBeGreaterThan(0); - expect(settings?.systemPrompt.length).toBeGreaterThan(0); - }); - - it("respects disable flag", () => { - expect( - resolveMemoryFlushSettings({ - agents: { - defaults: { compaction: { memoryFlush: { enabled: false } } }, - }, - }), - ).toBeNull(); - }); - - it("appends NO_REPLY hint when missing", () => { - const settings = resolveMemoryFlushSettings({ - agents: { - defaults: { - compaction: { - memoryFlush: { - prompt: "Write memories now.", - systemPrompt: "Flush memory.", - }, - }, - }, - }, - }); - expect(settings?.prompt).toContain("NO_REPLY"); - expect(settings?.systemPrompt).toContain("NO_REPLY"); - }); -}); - -describe("shouldRunMemoryFlush", () => { - it("requires totalTokens and threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 0 }, - contextWindowTokens: 16_000, - reserveTokensFloor: 20_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when entry is missing", () => { - expect( - shouldRunMemoryFlush({ - entry: undefined, - contextWindowTokens: 16_000, - reserveTokensFloor: 1_000, - softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, - }), - ).toBe(false); - }); - - it("skips when under threshold", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 10_000 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 20_000, - softThresholdTokens: 10_000, - }), - ).toBe(false); - }); - - it("triggers at the threshold boundary", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 85 }, - contextWindowTokens: 100, - reserveTokensFloor: 10, - softThresholdTokens: 5, - }), - ).toBe(true); - }); - - it("skips when already flushed for current compaction count", () => { - expect( - shouldRunMemoryFlush({ - entry: { - totalTokens: 90_000, - compactionCount: 2, - memoryFlushCompactionCount: 2, - }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); - - it("runs when above threshold and not flushed", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(true); - }); - - it("ignores stale cached totals", () => { - expect( - shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, - contextWindowTokens: 100_000, - reserveTokensFloor: 5_000, - softThresholdTokens: 2_000, - }), - ).toBe(false); - }); -}); - -describe("resolveMemoryFlushContextWindowTokens", () => { - it("falls back to agent config or default tokens", () => { - expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); - }); -}); diff --git a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts deleted file mode 100644 index 80578f4b721..00000000000 --- a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { applyReplyThreading } from "./reply-payloads.js"; - -describe("applyReplyThreading auto-threading", () => { - it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { - const result = applyReplyThreading({ - payloads: [{ text: "Hello" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - }); - - it("threads only first payload when mode is 'first'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "first", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBeUndefined(); - }); - - it("threads all payloads when mode is 'all'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }, { text: "B" }], - replyToMode: "all", - currentMessageId: "42", - }); - - expect(result).toHaveLength(2); - expect(result[0].replyToId).toBe("42"); - expect(result[1].replyToId).toBe("42"); - }); - - it("strips replyToId when mode is 'off'", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("does not bypass off mode for Slack when reply is implicit", () => { - const result = applyReplyThreading({ - payloads: [{ text: "A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); - }); - - it("keeps explicit tags for Slack when off mode allows tags", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "slack", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); - - it("keeps explicit tags for Telegram when off mode is enabled", () => { - const result = applyReplyThreading({ - payloads: [{ text: "[[reply_to_current]]A" }], - replyToMode: "off", - replyToChannel: "telegram", - currentMessageId: "42", - }); - - expect(result).toHaveLength(1); - expect(result[0].replyToId).toBe("42"); - expect(result[0].replyToTag).toBe(true); - }); -}); diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts new file mode 100644 index 00000000000..2b1d1367ac3 --- /dev/null +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { TemplateContext } from "../templating.js"; +import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; +import { buildThreadingToolContext } from "./agent-runner-utils.js"; +import { applyReplyThreading } from "./reply-payloads.js"; +import { + formatRunLabel, + formatRunStatus, + resolveSubagentLabel, + sortSubagentRuns, +} from "./subagents-utils.js"; + +describe("buildThreadingToolContext", () => { + const cfg = {} as OpenClawConfig; + + it("uses conversation id for WhatsApp", () => { + const sessionCtx = { + Provider: "whatsapp", + From: "123@g.us", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("123@g.us"); + }); + + it("falls back to To for WhatsApp when From is missing", () => { + const sessionCtx = { + Provider: "whatsapp", + To: "+15550001", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("+15550001"); + }); + + it("uses the recipient id for other channels", () => { + const sessionCtx = { + Provider: "telegram", + From: "user:42", + To: "chat:99", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat:99"); + }); + + it("uses the sender handle for iMessage direct chats", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "direct", + From: "imessage:+15550001", + To: "chat_id:12", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("imessage:+15550001"); + }); + + it("uses chat_id for iMessage groups", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "group", + From: "imessage:group:7", + To: "chat_id:7", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat_id:7"); + }); + + it("prefers MessageThreadId for Slack tool threading", () => { + const sessionCtx = { + Provider: "slack", + To: "channel:C1", + MessageThreadId: "123.456", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("C1"); + expect(result.currentThreadTs).toBe("123.456"); + }); +}); + +describe("applyReplyThreading auto-threading", () => { + it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { + const result = applyReplyThreading({ + payloads: [{ text: "Hello" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + }); + + it("threads only first payload when mode is 'first'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "first", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBeUndefined(); + }); + + it("threads all payloads when mode is 'all'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }, { text: "B" }], + replyToMode: "all", + currentMessageId: "42", + }); + + expect(result).toHaveLength(2); + expect(result[0].replyToId).toBe("42"); + expect(result[1].replyToId).toBe("42"); + }); + + it("strips replyToId when mode is 'off'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("does not bypass off mode for Slack when reply is implicit", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("keeps explicit tags for Slack when off mode allows tags", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); + + it("keeps explicit tags for Telegram when off mode is enabled", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "telegram", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); +}); + +const baseRun: SubagentRunRecord = { + runId: "run-1", + childSessionKey: "agent:main:subagent:abc", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + cleanup: "keep", + createdAt: 1000, + startedAt: 1000, +}; + +describe("subagents utils", () => { + it("resolves labels from label, task, or fallback", () => { + expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); + expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( + "fallback", + ); + }); + + it("formats run labels with truncation", () => { + const long = "x".repeat(100); + const run = { ...baseRun, label: long }; + const formatted = formatRunLabel(run, { maxLength: 10 }); + expect(formatted.startsWith("x".repeat(10))).toBe(true); + expect(formatted.endsWith("…")).toBe(true); + }); + + it("sorts subagent runs by newest start/created time", () => { + const runs: SubagentRunRecord[] = [ + { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, + { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, + { ...baseRun, runId: "run-3", createdAt: 900 }, + ]; + const sorted = sortSubagentRuns(runs); + expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); + }); + + it("formats run status from outcome and timestamps", () => { + expect(formatRunStatus({ ...baseRun })).toBe("running"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); + expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( + "timeout", + ); + }); + + it("formats duration compact for seconds and minutes", () => { + expect(formatDurationCompact(45_000)).toBe("45s"); + expect(formatDurationCompact(65_000)).toBe("1m5s"); + }); +}); diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts new file mode 100644 index 00000000000..182506b4e48 --- /dev/null +++ b/src/auto-reply/reply/reply-state.test.ts @@ -0,0 +1,381 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import { + appendHistoryEntry, + buildHistoryContext, + buildHistoryContextFromEntries, + buildHistoryContextFromMap, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + HISTORY_CONTEXT_MARKER, + recordPendingHistoryEntryIfEnabled, +} from "./history.js"; +import { + DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushSettings, + shouldRunMemoryFlush, +} from "./memory-flush.js"; +import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; +import { incrementCompactionCount } from "./session-updates.js"; + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +describe("history helpers", () => { + it("returns current message when history is empty", () => { + const result = buildHistoryContext({ + historyText: " ", + currentMessage: "hello", + }); + expect(result).toBe("hello"); + }); + + it("wraps history entries and excludes current by default", () => { + const result = buildHistoryContextFromEntries({ + entries: [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ], + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).not.toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("trims history to configured limit", () => { + const historyMap = new Map(); + + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "A", body: "one" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + appendHistoryEntry({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "C", body: "three" }, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]); + }); + + it("builds context from map and appends entry", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + entry: { sender: "C", body: "three" }, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).not.toContain("C: three"); + }); + + it("builds context from pending map without appending", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + const result = buildPendingHistoryContextFromMap({ + historyMap, + historyKey: "group", + limit: 3, + currentMessage: "current", + formatEntry: (entry) => `${entry.sender}: ${entry.body}`, + }); + + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + expect(result).toContain(HISTORY_CONTEXT_MARKER); + expect(result).toContain("A: one"); + expect(result).toContain("B: two"); + expect(result).toContain(CURRENT_MESSAGE_MARKER); + expect(result).toContain("current"); + }); + + it("records pending entries only when enabled", () => { + const historyMap = new Map(); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 0, + entry: { sender: "A", body: "one" }, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: null, + }); + expect(historyMap.get("group")).toEqual(undefined); + + recordPendingHistoryEntryIfEnabled({ + historyMap, + historyKey: "group", + limit: 2, + entry: { sender: "B", body: "two" }, + }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]); + }); + + it("clears history entries only when enabled", () => { + const historyMap = new Map(); + historyMap.set("group", [ + { sender: "A", body: "one" }, + { sender: "B", body: "two" }, + ]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 }); + expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]); + + clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 }); + expect(historyMap.get("group")).toEqual([]); + }); +}); + +describe("memory flush settings", () => { + it("defaults to enabled with fallback prompt and system prompt", () => { + const settings = resolveMemoryFlushSettings(); + expect(settings).not.toBeNull(); + expect(settings?.enabled).toBe(true); + expect(settings?.prompt.length).toBeGreaterThan(0); + expect(settings?.systemPrompt.length).toBeGreaterThan(0); + }); + + it("respects disable flag", () => { + expect( + resolveMemoryFlushSettings({ + agents: { + defaults: { compaction: { memoryFlush: { enabled: false } } }, + }, + }), + ).toBeNull(); + }); + + it("appends NO_REPLY hint when missing", () => { + const settings = resolveMemoryFlushSettings({ + agents: { + defaults: { + compaction: { + memoryFlush: { + prompt: "Write memories now.", + systemPrompt: "Flush memory.", + }, + }, + }, + }, + }); + expect(settings?.prompt).toContain("NO_REPLY"); + expect(settings?.systemPrompt).toContain("NO_REPLY"); + }); +}); + +describe("shouldRunMemoryFlush", () => { + it("requires totalTokens and threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 0 }, + contextWindowTokens: 16_000, + reserveTokensFloor: 20_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when entry is missing", () => { + expect( + shouldRunMemoryFlush({ + entry: undefined, + contextWindowTokens: 16_000, + reserveTokensFloor: 1_000, + softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS, + }), + ).toBe(false); + }); + + it("skips when under threshold", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 10_000 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 20_000, + softThresholdTokens: 10_000, + }), + ).toBe(false); + }); + + it("triggers at the threshold boundary", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 85 }, + contextWindowTokens: 100, + reserveTokensFloor: 10, + softThresholdTokens: 5, + }), + ).toBe(true); + }); + + it("skips when already flushed for current compaction count", () => { + expect( + shouldRunMemoryFlush({ + entry: { + totalTokens: 90_000, + compactionCount: 2, + memoryFlushCompactionCount: 2, + }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); + + it("runs when above threshold and not flushed", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(true); + }); + + it("ignores stale cached totals", () => { + expect( + shouldRunMemoryFlush({ + entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 }, + contextWindowTokens: 100_000, + reserveTokensFloor: 5_000, + softThresholdTokens: 2_000, + }), + ).toBe(false); + }); +}); + +describe("resolveMemoryFlushContextWindowTokens", () => { + it("falls back to agent config or default tokens", () => { + expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000); + }); +}); + +describe("incrementCompactionCount", () => { + it("increments compaction count", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + const count = await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + expect(count).toBe(3); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(3); + }); + + it("updates totalTokens when tokensAfter is provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + inputTokens: 170_000, + outputTokens: 10_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + tokensAfter: 12_000, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + expect(stored[sessionKey].totalTokens).toBe(12_000); + // input/output cleared since we only have the total estimate + expect(stored[sessionKey].inputTokens).toBeUndefined(); + expect(stored[sessionKey].outputTokens).toBeUndefined(); + }); + + it("does not update totalTokens when tokensAfter is not provided", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const entry = { + sessionId: "s1", + updatedAt: Date.now(), + compactionCount: 0, + totalTokens: 180_000, + } as SessionEntry; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + + await incrementCompactionCount({ + sessionEntry: entry, + sessionStore, + sessionKey, + storePath, + }); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].compactionCount).toBe(1); + // totalTokens unchanged + expect(stored[sessionKey].totalTokens).toBe(180_000); + }); +}); diff --git a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts b/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts deleted file mode 100644 index 5a90b4ed5f8..00000000000 --- a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts +++ /dev/null @@ -1,98 +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 { SessionEntry } from "../../config/sessions.js"; -import { incrementCompactionCount } from "./session-updates.js"; - -async function seedSessionStore(params: { - storePath: string; - sessionKey: string; - entry: Record; -}) { - await fs.mkdir(path.dirname(params.storePath), { recursive: true }); - await fs.writeFile( - params.storePath, - JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), - "utf-8", - ); -} - -describe("incrementCompactionCount", () => { - it("increments compaction count", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - const count = await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - expect(count).toBe(3); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(3); - }); - - it("updates totalTokens when tokensAfter is provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - inputTokens: 170_000, - outputTokens: 10_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - tokensAfter: 12_000, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - expect(stored[sessionKey].totalTokens).toBe(12_000); - // input/output cleared since we only have the total estimate - expect(stored[sessionKey].inputTokens).toBeUndefined(); - expect(stored[sessionKey].outputTokens).toBeUndefined(); - }); - - it("does not update totalTokens when tokensAfter is not provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - updatedAt: Date.now(), - compactionCount: 0, - totalTokens: 180_000, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - }); - - const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); - expect(stored[sessionKey].compactionCount).toBe(1); - // totalTokens unchanged - expect(stored[sessionKey].totalTokens).toBe(180_000); - }); -}); diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts deleted file mode 100644 index b66a70680da..00000000000 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; -import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; -import { - formatRunLabel, - formatRunStatus, - resolveSubagentLabel, - sortSubagentRuns, -} from "./subagents-utils.js"; - -const baseRun: SubagentRunRecord = { - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "do thing", - cleanup: "keep", - createdAt: 1000, - startedAt: 1000, -}; - -describe("subagents utils", () => { - it("resolves labels from label, task, or fallback", () => { - expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task"); - expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe( - "fallback", - ); - }); - - it("formats run labels with truncation", () => { - const long = "x".repeat(100); - const run = { ...baseRun, label: long }; - const formatted = formatRunLabel(run, { maxLength: 10 }); - expect(formatted.startsWith("x".repeat(10))).toBe(true); - expect(formatted.endsWith("…")).toBe(true); - }); - - it("sorts subagent runs by newest start/created time", () => { - const runs: SubagentRunRecord[] = [ - { ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 }, - { ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 }, - { ...baseRun, runId: "run-3", createdAt: 900 }, - ]; - const sorted = sortSubagentRuns(runs); - expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]); - }); - - it("formats run status from outcome and timestamps", () => { - expect(formatRunStatus({ ...baseRun })).toBe("running"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done"); - expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe( - "timeout", - ); - }); - - it("formats duration compact for seconds and minutes", () => { - expect(formatDurationCompact(45_000)).toBe("45s"); - expect(formatDurationCompact(65_000)).toBe("1m5s"); - }); -}); From c59a472ca2256de1f6e8bc5ffe62a021c70a88dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 21:58:27 +0000 Subject: [PATCH 054/178] perf(test): consolidate memory tool e2e suites --- ...-tool.does-not-crash-on-errors.e2e.test.ts | 65 --------------- ...ns.e2e.test.ts => memory-tool.e2e.test.ts} | 82 ++++++++++++++++--- 2 files changed, 70 insertions(+), 77 deletions(-) delete mode 100644 src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts rename src/agents/tools/{memory-tool.citations.e2e.test.ts => memory-tool.e2e.test.ts} (69%) diff --git a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts b/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts deleted file mode 100644 index 85535cedfe5..00000000000 --- a/src/agents/tools/memory-tool.does-not-crash-on-errors.e2e.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("../../memory/index.js", () => { - return { - getMemorySearchManager: async () => { - return { - manager: { - search: async () => { - throw new Error("openai embeddings failed: 429 insufficient_quota"); - }, - readFile: async () => { - throw new Error("path required"); - }, - status: () => ({ - files: 0, - chunks: 0, - dirty: true, - workspaceDir: "/tmp", - dbPath: "/tmp/index.sqlite", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", - }), - }, - }; - }, - }; -}); - -import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; - -describe("memory tools", () => { - it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemorySearchTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_1", { query: "hello" }); - expect(result.details).toEqual({ - results: [], - disabled: true, - error: "openai embeddings failed: 429 insufficient_quota", - }); - }); - - it("does not throw when memory_get fails", async () => { - const cfg = { agents: { list: [{ id: "main", default: true }] } }; - const tool = createMemoryGetTool({ config: cfg }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("tool missing"); - } - - const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); - expect(result.details).toEqual({ - path: "memory/NOPE.md", - text: "", - disabled: true, - error: "path required", - }); - }); -}); diff --git a/src/agents/tools/memory-tool.citations.e2e.test.ts b/src/agents/tools/memory-tool.e2e.test.ts similarity index 69% rename from src/agents/tools/memory-tool.citations.e2e.test.ts rename to src/agents/tools/memory-tool.e2e.test.ts index 8e4d5c1b7fd..38e2caab24d 100644 --- a/src/agents/tools/memory-tool.citations.e2e.test.ts +++ b/src/agents/tools/memory-tool.e2e.test.ts @@ -1,18 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let backend: "builtin" | "qmd" = "builtin"; +let searchImpl: () => Promise = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, +]; +let readFileImpl: () => Promise = async () => ""; + const stubManager = { - search: vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 5, - endLine: 7, - score: 0.9, - snippet: "@@ -5,3 @@\nAssistant: noted", - source: "memory" as const, - }, - ]), - readFile: vi.fn(), + search: vi.fn(async () => await searchImpl()), + readFile: vi.fn(async () => await readFileImpl()), status: () => ({ backend, files: 1, @@ -37,9 +40,21 @@ vi.mock("../../memory/index.js", () => { }; }); -import { createMemorySearchTool } from "./memory-tool.js"; +import { createMemoryGetTool, createMemorySearchTool } from "./memory-tool.js"; beforeEach(() => { + backend = "builtin"; + searchImpl = async () => [ + { + path: "MEMORY.md", + startLine: 5, + endLine: 7, + score: 0.9, + snippet: "@@ -5,3 @@\nAssistant: noted", + source: "memory" as const, + }, + ]; + readFileImpl = async () => ""; vi.clearAllMocks(); }); @@ -121,3 +136,46 @@ describe("memory search citations", () => { expect(details.results[0]?.snippet).not.toMatch(/Source:/); }); }); + +describe("memory tools", () => { + it("does not throw when memory_search fails (e.g. embeddings 429)", async () => { + searchImpl = async () => { + throw new Error("openai embeddings failed: 429 insufficient_quota"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemorySearchTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_1", { query: "hello" }); + expect(result.details).toEqual({ + results: [], + disabled: true, + error: "openai embeddings failed: 429 insufficient_quota", + }); + }); + + it("does not throw when memory_get fails", async () => { + readFileImpl = async () => { + throw new Error("path required"); + }; + + const cfg = { agents: { list: [{ id: "main", default: true }] } }; + const tool = createMemoryGetTool({ config: cfg }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("tool missing"); + } + + const result = await tool.execute("call_2", { path: "memory/NOPE.md" }); + expect(result.details).toEqual({ + path: "memory/NOPE.md", + text: "", + disabled: true, + error: "path required", + }); + }); +}); From 74294a4653425097389f43f3b9a44d6436a478f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:01:40 +0000 Subject: [PATCH 055/178] perf(test): consolidate web auto-reply suites --- .../auto-reply/monitor/message-line.test.ts | 82 -------------- src/web/auto-reply/session-snapshot.test.ts | 47 -------- src/web/auto-reply/util.test.ts | 60 ---------- ...test.ts => web-auto-reply-monitor.test.ts} | 91 ++++++++++++++- ...s.test.ts => web-auto-reply-utils.test.ts} | 104 +++++++++++++++++- 5 files changed, 189 insertions(+), 195 deletions(-) delete mode 100644 src/web/auto-reply/monitor/message-line.test.ts delete mode 100644 src/web/auto-reply/session-snapshot.test.ts delete mode 100644 src/web/auto-reply/util.test.ts rename src/web/auto-reply/{monitor/group-gating.test.ts => web-auto-reply-monitor.test.ts} (76%) rename src/web/auto-reply/{mentions.test.ts => web-auto-reply-utils.test.ts} (51%) diff --git a/src/web/auto-reply/monitor/message-line.test.ts b/src/web/auto-reply/monitor/message-line.test.ts deleted file mode 100644 index 4fad746d407..00000000000 --- a/src/web/auto-reply/monitor/message-line.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildInboundLine, formatReplyContext } from "./message-line.js"; - -describe("buildInboundLine", () => { - it("prefixes group messages with sender", () => { - const line = buildInboundLine({ - cfg: { - agents: { defaults: { workspace: "/tmp/openclaw" } }, - channels: { whatsapp: { messagePrefix: "" } }, - } as never, - agentId: "main", - msg: { - from: "123@g.us", - conversationId: "123@g.us", - to: "+15550009999", - accountId: "default", - body: "ping", - timestamp: 1700000000000, - chatType: "group", - chatId: "123@g.us", - senderJid: "111@s.whatsapp.net", - senderE164: "+15550001111", - senderName: "Bob", - sendComposing: async () => undefined, - reply: async () => undefined, - sendMedia: async () => undefined, - } as never, - }); - - expect(line).toContain("Bob (+15550001111):"); - expect(line).toContain("ping"); - }); - - it("includes reply-to context blocks when replyToBody is present", () => { - const line = buildInboundLine({ - cfg: { - agents: { defaults: { workspace: "/tmp/openclaw" } }, - channels: { whatsapp: { messagePrefix: "" } }, - } as never, - agentId: "main", - msg: { - from: "+1555", - to: "+1555", - body: "hello", - chatType: "direct", - replyToId: "q1", - replyToBody: "original", - replyToSender: "+1999", - } as never, - envelope: { includeTimestamp: false }, - }); - - expect(line).toContain("[Replying to +1999 id:q1]"); - expect(line).toContain("original"); - expect(line).toContain("[/Replying]"); - }); - - it("applies the WhatsApp messagePrefix when configured", () => { - const line = buildInboundLine({ - cfg: { - agents: { defaults: { workspace: "/tmp/openclaw" } }, - channels: { whatsapp: { messagePrefix: "[PFX]" } }, - } as never, - agentId: "main", - msg: { - from: "+1555", - to: "+2666", - body: "ping", - chatType: "direct", - } as never, - envelope: { includeTimestamp: false }, - }); - - expect(line).toContain("[PFX] ping"); - }); -}); - -describe("formatReplyContext", () => { - it("returns null when replyToBody is missing", () => { - expect(formatReplyContext({} as never)).toBeNull(); - }); -}); diff --git a/src/web/auto-reply/session-snapshot.test.ts b/src/web/auto-reply/session-snapshot.test.ts deleted file mode 100644 index 1f9d6dfc9f4..00000000000 --- a/src/web/auto-reply/session-snapshot.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { saveSessionStore } from "../../config/sessions.js"; -import { getSessionSnapshot } from "./session-snapshot.js"; - -describe("getSessionSnapshot", () => { - it("uses channel reset overrides when configured", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); - try { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-snapshot-")); - const storePath = path.join(root, "sessions.json"); - const sessionKey = "agent:main:whatsapp:dm:s1"; - - await saveSessionStore(storePath, { - [sessionKey]: { - sessionId: "snapshot-session", - updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), - lastChannel: "whatsapp", - }, - }); - - const cfg = { - session: { - store: storePath, - reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, - resetByChannel: { - whatsapp: { mode: "idle", idleMinutes: 360 }, - }, - }, - } as Parameters[0]; - - const snapshot = getSessionSnapshot(cfg, "whatsapp:+15550001111", true, { - sessionKey, - }); - - expect(snapshot.resetPolicy.mode).toBe("idle"); - expect(snapshot.resetPolicy.idleMinutes).toBe(360); - expect(snapshot.fresh).toBe(true); - expect(snapshot.dailyResetAt).toBeUndefined(); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/web/auto-reply/util.test.ts b/src/web/auto-reply/util.test.ts deleted file mode 100644 index a9327657d32..00000000000 --- a/src/web/auto-reply/util.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { elide, isLikelyWhatsAppCryptoError } from "./util.js"; - -describe("web auto-reply util", () => { - describe("elide", () => { - it("returns undefined for undefined input", () => { - expect(elide(undefined)).toBe(undefined); - }); - - it("returns input when under limit", () => { - expect(elide("hi", 10)).toBe("hi"); - }); - - it("returns input when exactly at limit", () => { - expect(elide("12345", 5)).toBe("12345"); - }); - - it("truncates and annotates when over limit", () => { - expect(elide("abcdef", 3)).toBe("abc… (truncated 3 chars)"); - }); - }); - - describe("isLikelyWhatsAppCryptoError", () => { - it("returns false for non-matching reasons", () => { - expect(isLikelyWhatsAppCryptoError(new Error("boom"))).toBe(false); - expect(isLikelyWhatsAppCryptoError("boom")).toBe(false); - expect(isLikelyWhatsAppCryptoError({ message: "bad mac" })).toBe(false); - }); - - it("matches known Baileys crypto auth errors (string)", () => { - expect( - isLikelyWhatsAppCryptoError( - "baileys: unsupported state or unable to authenticate data (noise-handler)", - ), - ).toBe(true); - expect(isLikelyWhatsAppCryptoError("bad mac in aesDecryptGCM (baileys)")).toBe(true); - }); - - it("matches known Baileys crypto auth errors (Error)", () => { - const err = new Error("bad mac"); - err.stack = "at something\nat @whiskeysockets/baileys/noise-handler\n"; - expect(isLikelyWhatsAppCryptoError(err)).toBe(true); - }); - - it("does not throw on circular objects", () => { - const circular: Record = {}; - circular.self = circular; - expect(isLikelyWhatsAppCryptoError(circular)).toBe(false); - }); - - it("handles non-string reasons without throwing", () => { - expect(isLikelyWhatsAppCryptoError(null)).toBe(false); - expect(isLikelyWhatsAppCryptoError(123)).toBe(false); - expect(isLikelyWhatsAppCryptoError(true)).toBe(false); - expect(isLikelyWhatsAppCryptoError(123n)).toBe(false); - expect(isLikelyWhatsAppCryptoError(Symbol("bad mac"))).toBe(false); - expect(isLikelyWhatsAppCryptoError(function namedFn() {})).toBe(false); - }); - }); -}); diff --git a/src/web/auto-reply/monitor/group-gating.test.ts b/src/web/auto-reply/web-auto-reply-monitor.test.ts similarity index 76% rename from src/web/auto-reply/monitor/group-gating.test.ts rename to src/web/auto-reply/web-auto-reply-monitor.test.ts index 74bce52197a..766bd0f932e 100644 --- a/src/web/auto-reply/monitor/group-gating.test.ts +++ b/src/web/auto-reply/web-auto-reply-monitor.test.ts @@ -2,9 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { buildMentionConfig } from "../mentions.js"; -import { applyGroupGating } from "./group-gating.js"; +import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { buildMentionConfig } from "./mentions.js"; +import { applyGroupGating } from "./monitor/group-gating.js"; +import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js"; let sessionDir: string | undefined; let sessionStorePath: string; @@ -32,10 +33,10 @@ const makeConfig = (overrides: Record) => }, session: { store: sessionStorePath }, ...overrides, - }) as unknown as ReturnType; + }) as unknown as ReturnType; function runGroupGating(params: { - cfg: ReturnType; + cfg: ReturnType; msg: Record; conversationId?: string; agentId?: string; @@ -340,3 +341,83 @@ describe("applyGroupGating", () => { expect(result.shouldProcess).toBe(false); }); }); + +describe("buildInboundLine", () => { + it("prefixes group messages with sender", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/openclaw" } }, + channels: { whatsapp: { messagePrefix: "" } }, + } as never, + agentId: "main", + msg: { + from: "123@g.us", + conversationId: "123@g.us", + to: "+15550009999", + accountId: "default", + body: "ping", + timestamp: 1700000000000, + chatType: "group", + chatId: "123@g.us", + senderJid: "111@s.whatsapp.net", + senderE164: "+15550001111", + senderName: "Bob", + sendComposing: async () => undefined, + reply: async () => undefined, + sendMedia: async () => undefined, + } as never, + }); + + expect(line).toContain("Bob (+15550001111):"); + expect(line).toContain("ping"); + }); + + it("includes reply-to context blocks when replyToBody is present", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/openclaw" } }, + channels: { whatsapp: { messagePrefix: "" } }, + } as never, + agentId: "main", + msg: { + from: "+1555", + to: "+1555", + body: "hello", + chatType: "direct", + replyToId: "q1", + replyToBody: "original", + replyToSender: "+1999", + } as never, + envelope: { includeTimestamp: false }, + }); + + expect(line).toContain("[Replying to +1999 id:q1]"); + expect(line).toContain("original"); + expect(line).toContain("[/Replying]"); + }); + + it("applies the WhatsApp messagePrefix when configured", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/openclaw" } }, + channels: { whatsapp: { messagePrefix: "[PFX]" } }, + } as never, + agentId: "main", + msg: { + from: "+1555", + to: "+2666", + body: "ping", + chatType: "direct", + } as never, + envelope: { includeTimestamp: false }, + }); + + expect(line).toContain("[PFX] ping"); + }); +}); + +describe("formatReplyContext", () => { + it("returns null when replyToBody is missing", () => { + expect(formatReplyContext({} as never)).toBeNull(); + }); +}); diff --git a/src/web/auto-reply/mentions.test.ts b/src/web/auto-reply/web-auto-reply-utils.test.ts similarity index 51% rename from src/web/auto-reply/mentions.test.ts rename to src/web/auto-reply/web-auto-reply-utils.test.ts index 27e4f426bc3..cd35a9a2374 100644 --- a/src/web/auto-reply/mentions.test.ts +++ b/src/web/auto-reply/web-auto-reply-utils.test.ts @@ -1,9 +1,12 @@ 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 type { WebInboundMsg } from "./types.js"; +import { saveSessionStore } from "../../config/sessions.js"; import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js"; +import { getSessionSnapshot } from "./session-snapshot.js"; +import { elide, isLikelyWhatsAppCryptoError } from "./util.js"; const makeMsg = (overrides: Partial): WebInboundMsg => ({ @@ -116,3 +119,102 @@ describe("resolveMentionTargets with @lid mapping", () => { } }); }); + +describe("getSessionSnapshot", () => { + it("uses channel reset overrides when configured", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + try { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-snapshot-")); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:s1"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: "snapshot-session", + updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), + lastChannel: "whatsapp", + }, + }); + + const cfg = { + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, + resetByChannel: { + whatsapp: { mode: "idle", idleMinutes: 360 }, + }, + }, + } as Parameters[0]; + + const snapshot = getSessionSnapshot(cfg, "whatsapp:+15550001111", true, { + sessionKey, + }); + + expect(snapshot.resetPolicy.mode).toBe("idle"); + expect(snapshot.resetPolicy.idleMinutes).toBe(360); + expect(snapshot.fresh).toBe(true); + expect(snapshot.dailyResetAt).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("web auto-reply util", () => { + describe("elide", () => { + it("returns undefined for undefined input", () => { + expect(elide(undefined)).toBe(undefined); + }); + + it("returns input when under limit", () => { + expect(elide("hi", 10)).toBe("hi"); + }); + + it("returns input when exactly at limit", () => { + expect(elide("12345", 5)).toBe("12345"); + }); + + it("truncates and annotates when over limit", () => { + expect(elide("abcdef", 3)).toBe("abc… (truncated 3 chars)"); + }); + }); + + describe("isLikelyWhatsAppCryptoError", () => { + it("returns false for non-matching reasons", () => { + expect(isLikelyWhatsAppCryptoError(new Error("boom"))).toBe(false); + expect(isLikelyWhatsAppCryptoError("boom")).toBe(false); + expect(isLikelyWhatsAppCryptoError({ message: "bad mac" })).toBe(false); + }); + + it("matches known Baileys crypto auth errors (string)", () => { + expect( + isLikelyWhatsAppCryptoError( + "baileys: unsupported state or unable to authenticate data (noise-handler)", + ), + ).toBe(true); + expect(isLikelyWhatsAppCryptoError("bad mac in aesDecryptGCM (baileys)")).toBe(true); + }); + + it("matches known Baileys crypto auth errors (Error)", () => { + const err = new Error("bad mac"); + err.stack = "at something\nat @whiskeysockets/baileys/noise-handler\n"; + expect(isLikelyWhatsAppCryptoError(err)).toBe(true); + }); + + it("does not throw on circular objects", () => { + const circular: Record = {}; + circular.self = circular; + expect(isLikelyWhatsAppCryptoError(circular)).toBe(false); + }); + + it("handles non-string reasons without throwing", () => { + expect(isLikelyWhatsAppCryptoError(null)).toBe(false); + expect(isLikelyWhatsAppCryptoError(123)).toBe(false); + expect(isLikelyWhatsAppCryptoError(true)).toBe(false); + expect(isLikelyWhatsAppCryptoError(123n)).toBe(false); + expect(isLikelyWhatsAppCryptoError(Symbol("bad mac"))).toBe(false); + expect(isLikelyWhatsAppCryptoError(function namedFn() {})).toBe(false); + }); + }); +}); From a7f6c956751a3cc7d79990ef847e0a6270f8baaf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:06:54 +0000 Subject: [PATCH 056/178] perf(test): consolidate slack monitor suites --- src/slack/monitor/channel-config.test.ts | 56 ---- src/slack/monitor/context.test.ts | 119 -------- .../media.thread-starter-cache.test.ts | 87 ------ .../prepare.sender-prefix.test.ts | 154 ---------- ...bound-contract.test.ts => prepare.test.ts} | 152 +++++++++ src/slack/monitor/monitor.test.ts | 289 ++++++++++++++++++ src/slack/monitor/thread-resolution.test.ts | 30 -- 7 files changed, 441 insertions(+), 446 deletions(-) delete mode 100644 src/slack/monitor/channel-config.test.ts delete mode 100644 src/slack/monitor/context.test.ts delete mode 100644 src/slack/monitor/media.thread-starter-cache.test.ts delete mode 100644 src/slack/monitor/message-handler/prepare.sender-prefix.test.ts rename src/slack/monitor/message-handler/{prepare.inbound-contract.test.ts => prepare.test.ts} (75%) create mode 100644 src/slack/monitor/monitor.test.ts delete mode 100644 src/slack/monitor/thread-resolution.test.ts diff --git a/src/slack/monitor/channel-config.test.ts b/src/slack/monitor/channel-config.test.ts deleted file mode 100644 index 9303605a99d..00000000000 --- a/src/slack/monitor/channel-config.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveSlackChannelConfig } from "./channel-config.js"; - -describe("resolveSlackChannelConfig", () => { - it("uses defaultRequireMention when channels config is empty", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: {}, - defaultRequireMention: false, - }); - expect(res).toEqual({ allowed: true, requireMention: false }); - }); - - it("defaults defaultRequireMention to true when not provided", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: {}, - }); - expect(res).toEqual({ allowed: true, requireMention: true }); - }); - - it("prefers explicit channel/fallback requireMention over defaultRequireMention", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { "*": { requireMention: true } }, - defaultRequireMention: false, - }); - expect(res).toMatchObject({ requireMention: true }); - }); - - it("uses wildcard entries when no direct channel config exists", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { "*": { allow: true, requireMention: false } }, - defaultRequireMention: true, - }); - expect(res).toMatchObject({ - allowed: true, - requireMention: false, - matchKey: "*", - matchSource: "wildcard", - }); - }); - - it("uses direct match metadata when channel config exists", () => { - const res = resolveSlackChannelConfig({ - channelId: "C1", - channels: { C1: { allow: true, requireMention: false } }, - defaultRequireMention: true, - }); - expect(res).toMatchObject({ - matchKey: "C1", - matchSource: "direct", - }); - }); -}); diff --git a/src/slack/monitor/context.test.ts b/src/slack/monitor/context.test.ts deleted file mode 100644 index 0afde23461c..00000000000 --- a/src/slack/monitor/context.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { App } from "@slack/bolt"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { createSlackMonitorContext, normalizeSlackChannelType } from "./context.js"; - -const baseParams = () => ({ - cfg: {} as OpenClawConfig, - accountId: "default", - botToken: "token", - app: { client: {} } as App, - runtime: {} as RuntimeEnv, - botUserId: "B1", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - sessionScope: "per-sender" as const, - mainKey: "main", - dmEnabled: true, - dmPolicy: "open" as const, - allowFrom: [], - groupDmEnabled: true, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open" as const, - useAccessGroups: false, - reactionMode: "off" as const, - reactionAllowlist: [], - replyToMode: "off" as const, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 4000, - ackReactionScope: "group-mentions", - mediaMaxBytes: 1, - removeAckAfterReply: false, -}); - -describe("normalizeSlackChannelType", () => { - it("infers channel types from ids when missing", () => { - expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); - expect(normalizeSlackChannelType(undefined, "D123")).toBe("im"); - expect(normalizeSlackChannelType(undefined, "G123")).toBe("group"); - }); - - it("prefers explicit channel_type values", () => { - expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); - }); -}); - -describe("resolveSlackSystemEventSessionKey", () => { - it("defaults missing channel_type to channel sessions", () => { - const ctx = createSlackMonitorContext(baseParams()); - expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( - "agent:main:slack:channel:c123", - ); - }); -}); - -describe("isChannelAllowed with groupPolicy and channelsConfig", () => { - it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => { - // Bug fix: when groupPolicy="open" and channels has some entries, - // unlisted channels should still be allowed (not blocked) - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: { - C_LISTED: { requireMention: true }, - }, - }); - // Listed channel should be allowed - expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); - // Unlisted channel should ALSO be allowed when policy is "open" - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); - }); - - it("blocks unlisted channels when groupPolicy is allowlist", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "allowlist", - channelsConfig: { - C_LISTED: { requireMention: true }, - }, - }); - // Listed channel should be allowed - expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); - // Unlisted channel should be blocked when policy is "allowlist" - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false); - }); - - it("blocks explicitly denied channels even when groupPolicy is open", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: { - C_ALLOWED: { allow: true }, - C_DENIED: { allow: false }, - }, - }); - // Explicitly allowed channel - expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true); - // Explicitly denied channel should be blocked even with open policy - expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false); - // Unlisted channel should be allowed with open policy - expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); - }); - - it("allows all channels when groupPolicy is open and channelsConfig is empty", () => { - const ctx = createSlackMonitorContext({ - ...baseParams(), - groupPolicy: "open", - channelsConfig: undefined, - }); - expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true); - }); -}); diff --git a/src/slack/monitor/media.thread-starter-cache.test.ts b/src/slack/monitor/media.thread-starter-cache.test.ts deleted file mode 100644 index 45278acfe62..00000000000 --- a/src/slack/monitor/media.thread-starter-cache.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetSlackThreadStarterCacheForTest, resolveSlackThreadStarter } from "./media.js"; - -describe("resolveSlackThreadStarter cache", () => { - afterEach(() => { - resetSlackThreadStarterCacheForTest(); - vi.useRealTimers(); - }); - - it("returns cached thread starter without refetching within ttl", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - const first = await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - const second = await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - expect(first).toEqual(second); - expect(replies).toHaveBeenCalledTimes(1); - }); - - it("expires stale cache entries and refetches after ttl", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - vi.setSystemTime(new Date("2026-01-01T07:00:00.000Z")); - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.1", - client, - }); - - expect(replies).toHaveBeenCalledTimes(2); - }); - - it("evicts oldest entries once cache exceeds bounded size", async () => { - const replies = vi.fn(async () => ({ - messages: [{ text: "root message", user: "U1", ts: "1000.1" }], - })); - const client = { - conversations: { replies }, - } as unknown as Parameters[0]["client"]; - - // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. - for (let i = 0; i <= 2000; i += 1) { - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: `1000.${i}`, - client, - }); - } - const callsAfterFill = replies.mock.calls.length; - - // Oldest key should be evicted and require fetch again. - await resolveSlackThreadStarter({ - channelId: "C1", - threadTs: "1000.0", - client, - }); - - expect(replies.mock.calls.length).toBe(callsAfterFill + 1); - }); -}); diff --git a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts deleted file mode 100644 index 30cfdc1ef9d..00000000000 --- a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { SlackMonitorContext } from "../context.js"; -import { prepareSlackMessage } from "./prepare.js"; - -describe("prepareSlackMessage sender prefix", () => { - it("prefixes channel bodies with sender label", async () => { - const ctx = { - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { slack: {} }, - }, - accountId: "default", - botToken: "xoxb", - app: { client: {} }, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "BOT", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - channelHistories: new Map(), - sessionScope: "per-sender", - mainKey: "agent:main:main", - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - groupDmEnabled: false, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open", - useAccessGroups: false, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: "off", - threadHistoryScope: "channel", - threadInheritParent: false, - slashCommand: { command: "/openclaw", enabled: true }, - textLimit: 2000, - ackReactionScope: "off", - mediaMaxBytes: 1000, - removeAckAfterReply: false, - logger: { info: vi.fn(), warn: vi.fn() }, - markMessageSeen: () => false, - shouldDropMismatchedSlackEvent: () => false, - resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", - isChannelAllowed: () => true, - resolveChannelName: async () => ({ - name: "general", - type: "channel", - }), - resolveUserName: async () => ({ name: "Alice" }), - setSlackThreadStatus: async () => undefined, - } satisfies SlackMonitorContext; - - const result = await prepareSlackMessage({ - ctx, - account: { accountId: "default", config: {} } as never, - message: { - type: "message", - channel: "C1", - channel_type: "channel", - text: "<@BOT> hello", - user: "U1", - ts: "1700000000.0001", - event_ts: "1700000000.0001", - } as never, - opts: { source: "message", wasMentioned: true }, - }); - - expect(result).not.toBeNull(); - const body = result?.ctxPayload.Body ?? ""; - expect(body).toContain("Alice (U1): <@BOT> hello"); - }); - - it("detects /new as control command when prefixed with Slack mention", async () => { - const ctx = { - cfg: { - agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, - channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } } }, - }, - accountId: "default", - botToken: "xoxb", - app: { client: {} }, - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "BOT", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - channelHistories: new Map(), - sessionScope: "per-sender", - mainKey: "agent:main:main", - dmEnabled: true, - dmPolicy: "open", - allowFrom: ["U1"], - groupDmEnabled: false, - groupDmChannels: [], - defaultRequireMention: true, - groupPolicy: "open", - useAccessGroups: true, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: "off", - threadHistoryScope: "channel", - threadInheritParent: false, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 2000, - ackReactionScope: "off", - mediaMaxBytes: 1000, - removeAckAfterReply: false, - logger: { info: vi.fn(), warn: vi.fn() }, - markMessageSeen: () => false, - shouldDropMismatchedSlackEvent: () => false, - resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", - isChannelAllowed: () => true, - resolveChannelName: async () => ({ name: "general", type: "channel" }), - resolveUserName: async () => ({ name: "Alice" }), - setSlackThreadStatus: async () => undefined, - } satisfies SlackMonitorContext; - - const result = await prepareSlackMessage({ - ctx, - account: { accountId: "default", config: {} } as never, - message: { - type: "message", - channel: "C1", - channel_type: "channel", - text: "<@BOT> /new", - user: "U1", - ts: "1700000000.0002", - event_ts: "1700000000.0002", - } as never, - opts: { source: "message", wasMentioned: true }, - }); - - expect(result).not.toBeNull(); - expect(result?.ctxPayload.CommandAuthorized).toBe(true); - }); -}); diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.test.ts similarity index 75% rename from src/slack/monitor/message-handler/prepare.inbound-contract.test.ts rename to src/slack/monitor/message-handler/prepare.test.ts index 4e2023da6a2..91c9670e76b 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../../config/config.js"; import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; +import type { SlackMonitorContext } from "../context.js"; import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; @@ -484,3 +485,154 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id"); }); }); + +describe("prepareSlackMessage sender prefix", () => { + it("prefixes channel bodies with sender label", async () => { + const ctx = { + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { slack: {} }, + }, + accountId: "default", + botToken: "xoxb", + app: { client: {} }, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "BOT", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + channelHistories: new Map(), + sessionScope: "per-sender", + mainKey: "agent:main:main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "channel", + threadInheritParent: false, + slashCommand: { command: "/openclaw", enabled: true }, + textLimit: 2000, + ackReactionScope: "off", + mediaMaxBytes: 1000, + removeAckAfterReply: false, + logger: { info: vi.fn(), warn: vi.fn() }, + markMessageSeen: () => false, + shouldDropMismatchedSlackEvent: () => false, + resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", + isChannelAllowed: () => true, + resolveChannelName: async () => ({ + name: "general", + type: "channel", + }), + resolveUserName: async () => ({ name: "Alice" }), + setSlackThreadStatus: async () => undefined, + } satisfies SlackMonitorContext; + + const result = await prepareSlackMessage({ + ctx, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> hello", + user: "U1", + ts: "1700000000.0001", + event_ts: "1700000000.0001", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + const body = result?.ctxPayload.Body ?? ""; + expect(body).toContain("Alice (U1): <@BOT> hello"); + }); + + it("detects /new as control command when prefixed with Slack mention", async () => { + const ctx = { + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, + channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } } }, + }, + accountId: "default", + botToken: "xoxb", + app: { client: {} }, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "BOT", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + channelHistories: new Map(), + sessionScope: "per-sender", + mainKey: "agent:main:main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: ["U1"], + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: true, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "channel", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 2000, + ackReactionScope: "off", + mediaMaxBytes: 1000, + removeAckAfterReply: false, + logger: { info: vi.fn(), warn: vi.fn() }, + markMessageSeen: () => false, + shouldDropMismatchedSlackEvent: () => false, + resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:c1", + isChannelAllowed: () => true, + resolveChannelName: async () => ({ name: "general", type: "channel" }), + resolveUserName: async () => ({ name: "Alice" }), + setSlackThreadStatus: async () => undefined, + } satisfies SlackMonitorContext; + + const result = await prepareSlackMessage({ + ctx, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> /new", + user: "U1", + ts: "1700000000.0002", + event_ts: "1700000000.0002", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + expect(result?.ctxPayload.CommandAuthorized).toBe(true); + }); +}); diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts new file mode 100644 index 00000000000..0194642f799 --- /dev/null +++ b/src/slack/monitor/monitor.test.ts @@ -0,0 +1,289 @@ +import type { App } from "@slack/bolt"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { SlackMessageEvent } from "../types.js"; +import { resolveSlackChannelConfig } from "./channel-config.js"; +import { createSlackMonitorContext, normalizeSlackChannelType } from "./context.js"; +import { resetSlackThreadStarterCacheForTest, resolveSlackThreadStarter } from "./media.js"; +import { createSlackThreadTsResolver } from "./thread-resolution.js"; + +describe("resolveSlackChannelConfig", () => { + it("uses defaultRequireMention when channels config is empty", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: {}, + defaultRequireMention: false, + }); + expect(res).toEqual({ allowed: true, requireMention: false }); + }); + + it("defaults defaultRequireMention to true when not provided", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: {}, + }); + expect(res).toEqual({ allowed: true, requireMention: true }); + }); + + it("prefers explicit channel/fallback requireMention over defaultRequireMention", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { "*": { requireMention: true } }, + defaultRequireMention: false, + }); + expect(res).toMatchObject({ requireMention: true }); + }); + + it("uses wildcard entries when no direct channel config exists", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { "*": { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ + allowed: true, + requireMention: false, + matchKey: "*", + matchSource: "wildcard", + }); + }); + + it("uses direct match metadata when channel config exists", () => { + const res = resolveSlackChannelConfig({ + channelId: "C1", + channels: { C1: { allow: true, requireMention: false } }, + defaultRequireMention: true, + }); + expect(res).toMatchObject({ + matchKey: "C1", + matchSource: "direct", + }); + }); +}); + +const baseParams = () => ({ + cfg: {} as OpenClawConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender" as const, + mainKey: "main", + dmEnabled: true, + dmPolicy: "open" as const, + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open" as const, + useAccessGroups: false, + reactionMode: "off" as const, + reactionAllowlist: [], + replyToMode: "off" as const, + slashCommand: { + enabled: false, + name: "openclaw", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1, + removeAckAfterReply: false, +}); + +describe("normalizeSlackChannelType", () => { + it("infers channel types from ids when missing", () => { + expect(normalizeSlackChannelType(undefined, "C123")).toBe("channel"); + expect(normalizeSlackChannelType(undefined, "D123")).toBe("im"); + expect(normalizeSlackChannelType(undefined, "G123")).toBe("group"); + }); + + it("prefers explicit channel_type values", () => { + expect(normalizeSlackChannelType("mpim", "C123")).toBe("mpim"); + }); +}); + +describe("resolveSlackSystemEventSessionKey", () => { + it("defaults missing channel_type to channel sessions", () => { + const ctx = createSlackMonitorContext(baseParams()); + expect(ctx.resolveSlackSystemEventSessionKey({ channelId: "C123" })).toBe( + "agent:main:slack:channel:c123", + ); + }); +}); + +describe("isChannelAllowed with groupPolicy and channelsConfig", () => { + it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => { + // Bug fix: when groupPolicy="open" and channels has some entries, + // unlisted channels should still be allowed (not blocked) + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: { + C_LISTED: { requireMention: true }, + }, + }); + // Listed channel should be allowed + expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); + // Unlisted channel should ALSO be allowed when policy is "open" + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); + }); + + it("blocks unlisted channels when groupPolicy is allowlist", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "allowlist", + channelsConfig: { + C_LISTED: { requireMention: true }, + }, + }); + // Listed channel should be allowed + expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true); + // Unlisted channel should be blocked when policy is "allowlist" + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false); + }); + + it("blocks explicitly denied channels even when groupPolicy is open", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: { + C_ALLOWED: { allow: true }, + C_DENIED: { allow: false }, + }, + }); + // Explicitly allowed channel + expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true); + // Explicitly denied channel should be blocked even with open policy + expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false); + // Unlisted channel should be allowed with open policy + expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true); + }); + + it("allows all channels when groupPolicy is open and channelsConfig is empty", () => { + const ctx = createSlackMonitorContext({ + ...baseParams(), + groupPolicy: "open", + channelsConfig: undefined, + }); + expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true); + }); +}); + +describe("resolveSlackThreadStarter cache", () => { + afterEach(() => { + resetSlackThreadStarterCacheForTest(); + vi.useRealTimers(); + }); + + it("returns cached thread starter without refetching within ttl", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + const first = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + const second = await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(first).toEqual(second); + expect(replies).toHaveBeenCalledTimes(1); + }); + + it("expires stale cache entries and refetches after ttl", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + vi.setSystemTime(new Date("2026-01-01T07:00:00.000Z")); + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.1", + client, + }); + + expect(replies).toHaveBeenCalledTimes(2); + }); + + it("evicts oldest entries once cache exceeds bounded size", async () => { + const replies = vi.fn(async () => ({ + messages: [{ text: "root message", user: "U1", ts: "1000.1" }], + })); + const client = { + conversations: { replies }, + } as unknown as Parameters[0]["client"]; + + // Cache cap is 2000; add enough distinct keys to force eviction of earliest keys. + for (let i = 0; i <= 2000; i += 1) { + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: `1000.${i}`, + client, + }); + } + const callsAfterFill = replies.mock.calls.length; + + // Oldest key should be evicted and require fetch again. + await resolveSlackThreadStarter({ + channelId: "C1", + threadTs: "1000.0", + client, + }); + + expect(replies.mock.calls.length).toBe(callsAfterFill + 1); + }); +}); + +describe("createSlackThreadTsResolver", () => { + it("caches resolved thread_ts lookups", async () => { + const historyMock = vi.fn().mockResolvedValue({ + messages: [{ ts: "1", thread_ts: "9" }], + }); + const resolver = createSlackThreadTsResolver({ + // oxlint-disable-next-line typescript/no-explicit-any + client: { conversations: { history: historyMock } } as any, + cacheTtlMs: 60_000, + maxSize: 5, + }); + + const message = { + channel: "C1", + parent_user_id: "U2", + ts: "1", + } as SlackMessageEvent; + + const first = await resolver.resolve({ message, source: "message" }); + const second = await resolver.resolve({ message, source: "message" }); + + expect(first.thread_ts).toBe("9"); + expect(second.thread_ts).toBe("9"); + expect(historyMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/slack/monitor/thread-resolution.test.ts b/src/slack/monitor/thread-resolution.test.ts deleted file mode 100644 index 5de8c74bd8f..00000000000 --- a/src/slack/monitor/thread-resolution.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { SlackMessageEvent } from "../types.js"; -import { createSlackThreadTsResolver } from "./thread-resolution.js"; - -describe("createSlackThreadTsResolver", () => { - it("caches resolved thread_ts lookups", async () => { - const historyMock = vi.fn().mockResolvedValue({ - messages: [{ ts: "1", thread_ts: "9" }], - }); - const resolver = createSlackThreadTsResolver({ - // oxlint-disable-next-line typescript/no-explicit-any - client: { conversations: { history: historyMock } } as any, - cacheTtlMs: 60_000, - maxSize: 5, - }); - - const message = { - channel: "C1", - parent_user_id: "U2", - ts: "1", - } as SlackMessageEvent; - - const first = await resolver.resolve({ message, source: "message" }); - const second = await resolver.resolve({ message, source: "message" }); - - expect(first.thread_ts).toBe("9"); - expect(second.thread_ts).toBe("9"); - expect(historyMock).toHaveBeenCalledTimes(1); - }); -}); From c5288300a1ad385fbc4d1d1a3451e7f9cb29f6dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:13:15 +0000 Subject: [PATCH 057/178] perf(test): consolidate reply flow suites --- src/auto-reply/reply/inbound.test.ts | 216 --- src/auto-reply/reply/line-directives.test.ts | 377 ----- .../reply/queue.collect-routing.test.ts | 427 ------ src/auto-reply/reply/reply-flow.test.ts | 1317 +++++++++++++++++ src/auto-reply/reply/reply-routing.test.ts | 249 ---- 5 files changed, 1317 insertions(+), 1269 deletions(-) delete mode 100644 src/auto-reply/reply/inbound.test.ts delete mode 100644 src/auto-reply/reply/line-directives.test.ts delete mode 100644 src/auto-reply/reply/queue.collect-routing.test.ts create mode 100644 src/auto-reply/reply/reply-flow.test.ts delete mode 100644 src/auto-reply/reply/reply-routing.test.ts diff --git a/src/auto-reply/reply/inbound.test.ts b/src/auto-reply/reply/inbound.test.ts deleted file mode 100644 index b92d7acf513..00000000000 --- a/src/auto-reply/reply/inbound.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MsgContext, TemplateContext } from "../templating.js"; -import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; -import { finalizeInboundContext } from "./inbound-context.js"; -import { buildInboundUserContextPrefix } from "./inbound-meta.js"; -import { normalizeInboundTextNewlines } from "./inbound-text.js"; - -describe("buildInboundUserContextPrefix", () => { - it("omits conversation label block for direct chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "direct", - ConversationLabel: "openclaw-tui", - } as TemplateContext); - - expect(text).toBe(""); - }); - - it("keeps conversation label for group chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "group", - ConversationLabel: "ops-room", - } as TemplateContext); - - expect(text).toContain("Conversation info (untrusted metadata):"); - expect(text).toContain('"conversation_label": "ops-room"'); - }); -}); - -describe("normalizeInboundTextNewlines", () => { - it("converts CRLF to LF", () => { - expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); - }); - - it("converts CR to LF", () => { - expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); - }); - - it("preserves literal backslash-n sequences in Windows paths", () => { - const windowsPath = "C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); - }); - - it("preserves backslash-n in messages containing Windows paths", () => { - const message = "Please read the file at C:\\Work\\nxxx\\README.md"; - expect(normalizeInboundTextNewlines(message)).toBe( - "Please read the file at C:\\Work\\nxxx\\README.md", - ); - }); - - it("preserves multiple backslash-n sequences", () => { - const message = "C:\\new\\notes\\nested"; - expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); - }); - - it("still normalizes actual CRLF while preserving backslash-n", () => { - const message = "Line 1\r\nC:\\Work\\nxxx"; - expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); - }); -}); - -describe("inbound context contract (providers + extensions)", () => { - const cases: Array<{ name: string; ctx: MsgContext }> = [ - { - name: "whatsapp group", - ctx: { - Provider: "whatsapp", - Surface: "whatsapp", - ChatType: "group", - From: "123@g.us", - To: "+15550001111", - Body: "[WhatsApp 123@g.us] hi", - RawBody: "hi", - CommandBody: "hi", - SenderName: "Alice", - }, - }, - { - name: "telegram group", - ctx: { - Provider: "telegram", - Surface: "telegram", - ChatType: "group", - From: "group:123", - To: "telegram:123", - Body: "[Telegram group:123] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Telegram Group", - SenderName: "Alice", - }, - }, - { - name: "slack channel", - ctx: { - Provider: "slack", - Surface: "slack", - ChatType: "channel", - From: "slack:channel:C123", - To: "channel:C123", - Body: "[Slack #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "discord channel", - ctx: { - Provider: "discord", - Surface: "discord", - ChatType: "channel", - From: "group:123", - To: "channel:123", - Body: "[Discord #general] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "signal dm", - ctx: { - Provider: "signal", - Surface: "signal", - ChatType: "direct", - From: "signal:+15550001111", - To: "signal:+15550002222", - Body: "[Signal] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "imessage group", - ctx: { - Provider: "imessage", - Surface: "imessage", - ChatType: "group", - From: "group:chat_id:123", - To: "chat_id:123", - Body: "[iMessage Group] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "iMessage Group", - SenderName: "Alice", - }, - }, - { - name: "matrix channel", - ctx: { - Provider: "matrix", - Surface: "matrix", - ChatType: "channel", - From: "matrix:channel:!room:example.org", - To: "room:!room:example.org", - Body: "[Matrix] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "#general", - SenderName: "Alice", - }, - }, - { - name: "msteams channel", - ctx: { - Provider: "msteams", - Surface: "msteams", - ChatType: "channel", - From: "msteams:channel:19:abc@thread.tacv2", - To: "msteams:channel:19:abc@thread.tacv2", - Body: "[Teams] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Teams Channel", - SenderName: "Alice", - }, - }, - { - name: "zalo dm", - ctx: { - Provider: "zalo", - Surface: "zalo", - ChatType: "direct", - From: "zalo:123", - To: "zalo:123", - Body: "[Zalo] hi", - RawBody: "hi", - CommandBody: "hi", - }, - }, - { - name: "zalouser group", - ctx: { - Provider: "zalouser", - Surface: "zalouser", - ChatType: "group", - From: "group:123", - To: "zalouser:123", - Body: "[Zalo Personal] hi", - RawBody: "hi", - CommandBody: "hi", - GroupSubject: "Zalouser Group", - SenderName: "Alice", - }, - }, - ]; - - for (const entry of cases) { - it(entry.name, () => { - const ctx = finalizeInboundContext({ ...entry.ctx }); - expectInboundContextContract(ctx); - }); - } -}); diff --git a/src/auto-reply/reply/line-directives.test.ts b/src/auto-reply/reply/line-directives.test.ts deleted file mode 100644 index bf60232b854..00000000000 --- a/src/auto-reply/reply/line-directives.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; - -const getLineData = (result: ReturnType) => - (result.channelData?.line as Record | undefined) ?? {}; - -describe("hasLineDirectives", () => { - it("detects quick_replies directive", () => { - expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); - }); - - it("detects location directive", () => { - expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); - }); - - it("detects confirm directive", () => { - expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); - }); - - it("detects buttons directive", () => { - expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); - }); - - it("returns false for regular text", () => { - expect(hasLineDirectives("Just regular text")).toBe(false); - }); - - it("returns false for similar but invalid patterns", () => { - expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); - }); - - it("detects media_player directive", () => { - expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); - }); - - it("detects event directive", () => { - expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); - }); - - it("detects agenda directive", () => { - expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); - }); - - it("detects device directive", () => { - expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); - }); - - it("detects appletv_remote directive", () => { - expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); - }); -}); - -describe("parseLineDirectives", () => { - describe("quick_replies", () => { - it("parses quick_replies and removes from text", () => { - const result = parseLineDirectives({ - text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", - }); - - expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); - expect(result.text).toBe("Choose one:"); - }); - - it("handles quick_replies in middle of text", () => { - const result = parseLineDirectives({ - text: "Before [[quick_replies: A, B]] After", - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - expect(result.text).toBe("Before After"); - }); - - it("merges with existing quickReplies", () => { - const result = parseLineDirectives({ - text: "Text [[quick_replies: C, D]]", - channelData: { line: { quickReplies: ["A", "B"] } }, - }); - - expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); - }); - }); - - describe("location", () => { - it("parses location with all fields", () => { - const result = parseLineDirectives({ - text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", - }); - - expect(getLineData(result).location).toEqual({ - title: "Tokyo Station", - address: "Tokyo, Japan", - latitude: 35.6812, - longitude: 139.7671, - }); - expect(result.text).toBe("Here's the location:"); - }); - - it("ignores invalid coordinates", () => { - const result = parseLineDirectives({ - text: "[[location: Place | Address | invalid | 139.7]]", - }); - - expect(getLineData(result).location).toBeUndefined(); - }); - - it("does not override existing location", () => { - const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; - const result = parseLineDirectives({ - text: "[[location: New | New Addr | 35.6 | 139.7]]", - channelData: { line: { location: existing } }, - }); - - expect(getLineData(result).location).toEqual(existing); - }); - }); - - describe("confirm", () => { - it("parses simple confirm", () => { - const result = parseLineDirectives({ - text: "[[confirm: Delete this item? | Yes | No]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Delete this item?", - confirmLabel: "Yes", - confirmData: "yes", - cancelLabel: "No", - cancelData: "no", - altText: "Delete this item?", - }); - // Text is undefined when directive consumes entire text - expect(result.text).toBeUndefined(); - }); - - it("parses confirm with custom data", () => { - const result = parseLineDirectives({ - text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "confirm", - text: "Proceed?", - confirmLabel: "OK", - confirmData: "action=confirm", - cancelLabel: "Cancel", - cancelData: "action=cancel", - altText: "Proceed?", - }); - }); - }); - - describe("buttons", () => { - it("parses buttons with message actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", - }); - - expect(getLineData(result).templateMessage).toEqual({ - type: "buttons", - title: "Menu", - text: "Select an option", - actions: [ - { type: "message", label: "Help", data: "/help" }, - { type: "message", label: "Status", data: "/status" }, - ], - altText: "Menu: Select an option", - }); - }); - - it("parses buttons with uri actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Links | Visit us | Site:https://example.com]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "uri", - label: "Site", - uri: "https://example.com", - }); - } - }); - - it("parses buttons with postback actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.[0]).toEqual({ - type: "postback", - label: "Select", - data: "action=select&id=1", - }); - } - }); - - it("limits to 4 actions", () => { - const result = parseLineDirectives({ - text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", - }); - - const templateMessage = getLineData(result).templateMessage as { - type?: string; - actions?: Array>; - }; - expect(templateMessage?.type).toBe("buttons"); - if (templateMessage?.type === "buttons") { - expect(templateMessage.actions?.length).toBe(4); - } - }); - }); - - describe("media_player", () => { - it("parses media_player with all fields", () => { - const result = parseLineDirectives({ - text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - altText?: string; - contents?: { footer?: { contents?: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen"); - const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; - expect(contents.footer?.contents?.length).toBeGreaterThan(0); - expect(result.text).toBe("Now playing:"); - }); - - it("parses media_player with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[media_player: Unknown Track]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("🎵 Unknown Track"); - }); - - it("handles paused status", () => { - const result = parseLineDirectives({ - text: "[[media_player: Song | Artist | Player | | paused]]", - }); - - const flexMessage = getLineData(result).flexMessage as { - contents?: { body: { contents: unknown[] } }; - }; - expect(flexMessage).toBeDefined(); - const contents = flexMessage?.contents as { body: { contents: unknown[] } }; - expect(contents).toBeDefined(); - }); - }); - - describe("event", () => { - it("parses event with all fields", () => { - const result = parseLineDirectives({ - text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); - }); - - it("parses event with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[event: Birthday Party | March 15]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15"); - }); - }); - - describe("agenda", () => { - it("parses agenda with multiple events", () => { - const result = parseLineDirectives({ - text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)"); - }); - - it("parses agenda with events without times", () => { - const result = parseLineDirectives({ - text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📋 Tasks (3 events)"); - }); - }); - - describe("device", () => { - it("parses device with controls", () => { - const result = parseLineDirectives({ - text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 TV: Playing"); - }); - - it("parses device with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[device: Speaker]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toBe("📱 Speaker"); - }); - }); - - describe("appletv_remote", () => { - it("parses appletv_remote with status", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV | Playing]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - expect(flexMessage?.altText).toContain("Apple TV"); - }); - - it("parses appletv_remote with minimal fields", () => { - const result = parseLineDirectives({ - text: "[[appletv_remote: Apple TV]]", - }); - - const flexMessage = getLineData(result).flexMessage as { altText?: string }; - expect(flexMessage).toBeDefined(); - }); - }); - - describe("combined directives", () => { - it("handles text with no directives", () => { - const result = parseLineDirectives({ - text: "Just plain text here", - }); - - expect(result.text).toBe("Just plain text here"); - expect(getLineData(result).quickReplies).toBeUndefined(); - expect(getLineData(result).location).toBeUndefined(); - expect(getLineData(result).templateMessage).toBeUndefined(); - }); - - it("preserves other payload fields", () => { - const result = parseLineDirectives({ - text: "Hello [[quick_replies: A, B]]", - mediaUrl: "https://example.com/image.jpg", - replyToId: "msg123", - }); - - expect(result.mediaUrl).toBe("https://example.com/image.jpg"); - expect(result.replyToId).toBe("msg123"); - expect(getLineData(result).quickReplies).toEqual(["A", "B"]); - }); - }); -}); diff --git a/src/auto-reply/reply/queue.collect-routing.test.ts b/src/auto-reply/reply/queue.collect-routing.test.ts deleted file mode 100644 index e1afe6eab67..00000000000 --- a/src/auto-reply/reply/queue.collect-routing.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; -import { defaultRuntime } from "../../runtime.js"; -import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -let previousRuntimeError: typeof defaultRuntime.error; - -beforeAll(() => { - previousRuntimeError = defaultRuntime.error; - defaultRuntime.error = undefined; -}); - -afterAll(() => { - defaultRuntime.error = previousRuntimeError; -}); - -const COLLECT_SETTINGS: QueueSettings = { - mode: "collect", - debounceMs: 0, - cap: 50, - dropPolicy: "summarize", -}; - -function createRun(params: { - prompt: string; - messageId?: string; - originatingChannel?: FollowupRun["originatingChannel"]; - originatingTo?: string; - originatingAccountId?: string; - originatingThreadId?: string | number; -}): FollowupRun { - return { - prompt: params.prompt, - messageId: params.messageId, - enqueuedAt: Date.now(), - originatingChannel: params.originatingChannel, - originatingTo: params.originatingTo, - originatingAccountId: params.originatingAccountId, - originatingThreadId: params.originatingThreadId, - run: { - agentId: "agent", - agentDir: "/tmp", - sessionId: "sess", - sessionFile: "/tmp/session.json", - workspaceDir: "/tmp", - config: {} as OpenClawConfig, - provider: "openai", - model: "gpt-test", - timeoutMs: 10_000, - blockReplyBreak: "text_end", - }, - }; -} - -function createHarness(params: { - expectedCalls: number; - runFollowup?: ( - run: FollowupRun, - ctx: { - calls: FollowupRun[]; - done: ReturnType>; - expectedCalls: number; - }, - ) => Promise; -}) { - const calls: FollowupRun[] = []; - const done = createDeferred(); - const expectedCalls = params.expectedCalls; - const runFollowup = async (run: FollowupRun) => { - if (params.runFollowup) { - await params.runFollowup(run, { calls, done, expectedCalls }); - return; - } - calls.push(run); - if (calls.length >= expectedCalls) { - done.resolve(); - } - }; - return { calls, done, runFollowup, expectedCalls }; -} - -describe("followup queue deduplication", () => { - it("deduplicates messages with same Discord message_id", async () => { - const key = `test-dedup-message-id-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(first).toBe(true); - - // Second enqueue with same message id should be deduplicated - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] Hello (dupe)", - messageId: "m1", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(second).toBe(false); - - // Third enqueue with different message id should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "[Discord Guild #test channel id:123] World", - messageId: "m2", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - COLLECT_SETTINGS, - ); - expect(third).toBe(true); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - // Should collect both unique messages - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - }); - - it("deduplicates exact prompt when routing matches and no message id", async () => { - const key = `test-dedup-whatsapp-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - // First enqueue should succeed - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - // Second enqueue with same prompt should be allowed (default dedupe: message id only) - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(second).toBe(true); - - // Third enqueue with different prompt should succeed - const third = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world 2", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(third).toBe(true); - }); - - it("does not deduplicate across different providers without message id", async () => { - const key = `test-dedup-cross-provider-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Same text", - originatingChannel: "discord", - originatingTo: "channel:123", - }), - settings, - ); - expect(second).toBe(true); - }); - - it("can opt-in to prompt-based dedupe when message id is absent", async () => { - const key = `test-dedup-prompt-mode-${Date.now()}`; - const settings = COLLECT_SETTINGS; - - const first = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(first).toBe(true); - - const second = enqueueFollowupRun( - key, - createRun({ - prompt: "Hello world", - originatingChannel: "whatsapp", - originatingTo: "+1234567890", - }), - settings, - "prompt", - ); - expect(second).toBe(false); - }); -}); - -describe("followup queue collect routing", () => { - it("does not collect when destinations differ", async () => { - const key = `test-collect-diff-to-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 2 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:B", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - }); - - it("collects when channel+destination match", async () => { - const key = `test-collect-same-to-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingChannel).toBe("slack"); - expect(calls[0]?.originatingTo).toBe("channel:A"); - }); - - it("collects Slack messages in same thread and preserves string thread id", async () => { - const key = `test-collect-slack-thread-same-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 1 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - }); - - it("does not collect Slack messages when thread ids differ", async () => { - const key = `test-collect-slack-thread-diff-${Date.now()}`; - const { calls, done, runFollowup } = createHarness({ expectedCalls: 2 }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun( - key, - createRun({ - prompt: "one", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000001", - }), - settings, - ); - enqueueFollowupRun( - key, - createRun({ - prompt: "two", - originatingChannel: "slack", - originatingTo: "channel:A", - originatingThreadId: "1706000000.000002", - }), - settings, - ); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toBe("one"); - expect(calls[1]?.prompt).toBe("two"); - expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); - expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); - }); - - it("retries collect-mode batches without losing queued items", async () => { - const key = `test-collect-retry-${Date.now()}`; - let attempt = 0; - const { calls, done, runFollowup } = createHarness({ - expectedCalls: 1, - runFollowup: async (run, ctx) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - ctx.calls.push(run); - if (ctx.calls.length >= ctx.expectedCalls) { - ctx.done.resolve(); - } - }, - }); - const settings = COLLECT_SETTINGS; - - enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("Queued #1\none"); - expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); - }); - - it("retries overflow summary delivery without losing dropped previews", async () => { - const key = `test-overflow-summary-retry-${Date.now()}`; - let attempt = 0; - const { calls, done, runFollowup } = createHarness({ - expectedCalls: 1, - runFollowup: async (run, ctx) => { - attempt += 1; - if (attempt === 1) { - throw new Error("transient failure"); - } - ctx.calls.push(run); - if (ctx.calls.length >= ctx.expectedCalls) { - ctx.done.resolve(); - } - }, - }); - const settings: QueueSettings = { - mode: "followup", - debounceMs: 0, - cap: 1, - dropPolicy: "summarize", - }; - - enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); - enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); - - scheduleFollowupDrain(key, runFollowup); - await done.promise; - expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); - expect(calls[0]?.prompt).toContain("- first"); - }); -}); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts new file mode 100644 index 00000000000..c314997929f --- /dev/null +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -0,0 +1,1317 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; +import { defaultRuntime } from "../../runtime.js"; +import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { finalizeInboundContext } from "./inbound-context.js"; +import { buildInboundUserContextPrefix } from "./inbound-meta.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; +import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; +import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; +import { createReplyDispatcher } from "./reply-dispatcher.js"; +import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; + +describe("buildInboundUserContextPrefix", () => { + it("omits conversation label block for direct chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "direct", + ConversationLabel: "openclaw-tui", + } as TemplateContext); + + expect(text).toBe(""); + }); + + it("keeps conversation label for group chats", () => { + const text = buildInboundUserContextPrefix({ + ChatType: "group", + ConversationLabel: "ops-room", + } as TemplateContext); + + expect(text).toContain("Conversation info (untrusted metadata):"); + expect(text).toContain('"conversation_label": "ops-room"'); + }); +}); + +describe("normalizeInboundTextNewlines", () => { + it("converts CRLF to LF", () => { + expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); + }); + + it("converts CR to LF", () => { + expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld"); + }); + + it("preserves literal backslash-n sequences in Windows paths", () => { + const windowsPath = "C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md"); + }); + + it("preserves backslash-n in messages containing Windows paths", () => { + const message = "Please read the file at C:\\Work\\nxxx\\README.md"; + expect(normalizeInboundTextNewlines(message)).toBe( + "Please read the file at C:\\Work\\nxxx\\README.md", + ); + }); + + it("preserves multiple backslash-n sequences", () => { + const message = "C:\\new\\notes\\nested"; + expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested"); + }); + + it("still normalizes actual CRLF while preserving backslash-n", () => { + const message = "Line 1\r\nC:\\Work\\nxxx"; + expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx"); + }); +}); + +describe("inbound context contract (providers + extensions)", () => { + const cases: Array<{ name: string; ctx: MsgContext }> = [ + { + name: "whatsapp group", + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "group", + From: "123@g.us", + To: "+15550001111", + Body: "[WhatsApp 123@g.us] hi", + RawBody: "hi", + CommandBody: "hi", + SenderName: "Alice", + }, + }, + { + name: "telegram group", + ctx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + From: "group:123", + To: "telegram:123", + Body: "[Telegram group:123] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Telegram Group", + SenderName: "Alice", + }, + }, + { + name: "slack channel", + ctx: { + Provider: "slack", + Surface: "slack", + ChatType: "channel", + From: "slack:channel:C123", + To: "channel:C123", + Body: "[Slack #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "discord channel", + ctx: { + Provider: "discord", + Surface: "discord", + ChatType: "channel", + From: "group:123", + To: "channel:123", + Body: "[Discord #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "signal dm", + ctx: { + Provider: "signal", + Surface: "signal", + ChatType: "direct", + From: "signal:+15550001111", + To: "signal:+15550002222", + Body: "[Signal] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "imessage group", + ctx: { + Provider: "imessage", + Surface: "imessage", + ChatType: "group", + From: "group:chat_id:123", + To: "chat_id:123", + Body: "[iMessage Group] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "iMessage Group", + SenderName: "Alice", + }, + }, + { + name: "matrix channel", + ctx: { + Provider: "matrix", + Surface: "matrix", + ChatType: "channel", + From: "matrix:channel:!room:example.org", + To: "room:!room:example.org", + Body: "[Matrix] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "msteams channel", + ctx: { + Provider: "msteams", + Surface: "msteams", + ChatType: "channel", + From: "msteams:channel:19:abc@thread.tacv2", + To: "msteams:channel:19:abc@thread.tacv2", + Body: "[Teams] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Teams Channel", + SenderName: "Alice", + }, + }, + { + name: "zalo dm", + ctx: { + Provider: "zalo", + Surface: "zalo", + ChatType: "direct", + From: "zalo:123", + To: "zalo:123", + Body: "[Zalo] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "zalouser group", + ctx: { + Provider: "zalouser", + Surface: "zalouser", + ChatType: "group", + From: "group:123", + To: "zalouser:123", + Body: "[Zalo Personal] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Zalouser Group", + SenderName: "Alice", + }, + }, + ]; + + for (const entry of cases) { + it(entry.name, () => { + const ctx = finalizeInboundContext({ ...entry.ctx }); + expectInboundContextContract(ctx); + }); + } +}); + +const getLineData = (result: ReturnType) => + (result.channelData?.line as Record | undefined) ?? {}; + +describe("hasLineDirectives", () => { + it("detects quick_replies directive", () => { + expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true); + }); + + it("detects location directive", () => { + expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true); + }); + + it("detects confirm directive", () => { + expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true); + }); + + it("detects buttons directive", () => { + expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true); + }); + + it("returns false for regular text", () => { + expect(hasLineDirectives("Just regular text")).toBe(false); + }); + + it("returns false for similar but invalid patterns", () => { + expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false); + }); + + it("detects media_player directive", () => { + expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true); + }); + + it("detects event directive", () => { + expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true); + }); + + it("detects agenda directive", () => { + expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true); + }); + + it("detects device directive", () => { + expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true); + }); + + it("detects appletv_remote directive", () => { + expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true); + }); +}); + +describe("parseLineDirectives", () => { + describe("quick_replies", () => { + it("parses quick_replies and removes from text", () => { + const result = parseLineDirectives({ + text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]", + }); + + expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]); + expect(result.text).toBe("Choose one:"); + }); + + it("handles quick_replies in middle of text", () => { + const result = parseLineDirectives({ + text: "Before [[quick_replies: A, B]] After", + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + expect(result.text).toBe("Before After"); + }); + + it("merges with existing quickReplies", () => { + const result = parseLineDirectives({ + text: "Text [[quick_replies: C, D]]", + channelData: { line: { quickReplies: ["A", "B"] } }, + }); + + expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]); + }); + }); + + describe("location", () => { + it("parses location with all fields", () => { + const result = parseLineDirectives({ + text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]", + }); + + expect(getLineData(result).location).toEqual({ + title: "Tokyo Station", + address: "Tokyo, Japan", + latitude: 35.6812, + longitude: 139.7671, + }); + expect(result.text).toBe("Here's the location:"); + }); + + it("ignores invalid coordinates", () => { + const result = parseLineDirectives({ + text: "[[location: Place | Address | invalid | 139.7]]", + }); + + expect(getLineData(result).location).toBeUndefined(); + }); + + it("does not override existing location", () => { + const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 }; + const result = parseLineDirectives({ + text: "[[location: New | New Addr | 35.6 | 139.7]]", + channelData: { line: { location: existing } }, + }); + + expect(getLineData(result).location).toEqual(existing); + }); + }); + + describe("confirm", () => { + it("parses simple confirm", () => { + const result = parseLineDirectives({ + text: "[[confirm: Delete this item? | Yes | No]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Delete this item?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + altText: "Delete this item?", + }); + // Text is undefined when directive consumes entire text + expect(result.text).toBeUndefined(); + }); + + it("parses confirm with custom data", () => { + const result = parseLineDirectives({ + text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "confirm", + text: "Proceed?", + confirmLabel: "OK", + confirmData: "action=confirm", + cancelLabel: "Cancel", + cancelData: "action=cancel", + altText: "Proceed?", + }); + }); + }); + + describe("buttons", () => { + it("parses buttons with message actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]", + }); + + expect(getLineData(result).templateMessage).toEqual({ + type: "buttons", + title: "Menu", + text: "Select an option", + actions: [ + { type: "message", label: "Help", data: "/help" }, + { type: "message", label: "Status", data: "/status" }, + ], + altText: "Menu: Select an option", + }); + }); + + it("parses buttons with uri actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Links | Visit us | Site:https://example.com]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "uri", + label: "Site", + uri: "https://example.com", + }); + } + }); + + it("parses buttons with postback actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Actions | Choose | Select:action=select&id=1]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.[0]).toEqual({ + type: "postback", + label: "Select", + data: "action=select&id=1", + }); + } + }); + + it("limits to 4 actions", () => { + const result = parseLineDirectives({ + text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]", + }); + + const templateMessage = getLineData(result).templateMessage as { + type?: string; + actions?: Array>; + }; + expect(templateMessage?.type).toBe("buttons"); + if (templateMessage?.type === "buttons") { + expect(templateMessage.actions?.length).toBe(4); + } + }); + }); + + describe("media_player", () => { + it("parses media_player with all fields", () => { + const result = parseLineDirectives({ + text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + altText?: string; + contents?: { footer?: { contents?: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen"); + const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } }; + expect(contents.footer?.contents?.length).toBeGreaterThan(0); + expect(result.text).toBe("Now playing:"); + }); + + it("parses media_player with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[media_player: Unknown Track]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("🎵 Unknown Track"); + }); + + it("handles paused status", () => { + const result = parseLineDirectives({ + text: "[[media_player: Song | Artist | Player | | paused]]", + }); + + const flexMessage = getLineData(result).flexMessage as { + contents?: { body: { contents: unknown[] } }; + }; + expect(flexMessage).toBeDefined(); + const contents = flexMessage?.contents as { body: { contents: unknown[] } }; + expect(contents).toBeDefined(); + }); + }); + + describe("event", () => { + it("parses event with all fields", () => { + const result = parseLineDirectives({ + text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM"); + }); + + it("parses event with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[event: Birthday Party | March 15]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15"); + }); + }); + + describe("agenda", () => { + it("parses agenda with multiple events", () => { + const result = parseLineDirectives({ + text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)"); + }); + + it("parses agenda with events without times", () => { + const result = parseLineDirectives({ + text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📋 Tasks (3 events)"); + }); + }); + + describe("device", () => { + it("parses device with controls", () => { + const result = parseLineDirectives({ + text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📱 TV: Playing"); + }); + + it("parses device with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[device: Speaker]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toBe("📱 Speaker"); + }); + }); + + describe("appletv_remote", () => { + it("parses appletv_remote with status", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV | Playing]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + expect(flexMessage?.altText).toContain("Apple TV"); + }); + + it("parses appletv_remote with minimal fields", () => { + const result = parseLineDirectives({ + text: "[[appletv_remote: Apple TV]]", + }); + + const flexMessage = getLineData(result).flexMessage as { altText?: string }; + expect(flexMessage).toBeDefined(); + }); + }); + + describe("combined directives", () => { + it("handles text with no directives", () => { + const result = parseLineDirectives({ + text: "Just plain text here", + }); + + expect(result.text).toBe("Just plain text here"); + expect(getLineData(result).quickReplies).toBeUndefined(); + expect(getLineData(result).location).toBeUndefined(); + expect(getLineData(result).templateMessage).toBeUndefined(); + }); + + it("preserves other payload fields", () => { + const result = parseLineDirectives({ + text: "Hello [[quick_replies: A, B]]", + mediaUrl: "https://example.com/image.jpg", + replyToId: "msg123", + }); + + expect(result.mediaUrl).toBe("https://example.com/image.jpg"); + expect(result.replyToId).toBe("msg123"); + expect(getLineData(result).quickReplies).toEqual(["A", "B"]); + }); + }); +}); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let previousRuntimeError: typeof defaultRuntime.error; + +beforeAll(() => { + previousRuntimeError = defaultRuntime.error; + defaultRuntime.error = undefined; +}); + +afterAll(() => { + defaultRuntime.error = previousRuntimeError; +}); + +function createRun(params: { + prompt: string; + messageId?: string; + originatingChannel?: FollowupRun["originatingChannel"]; + originatingTo?: string; + originatingAccountId?: string; + originatingThreadId?: string | number; +}): FollowupRun { + return { + prompt: params.prompt, + messageId: params.messageId, + enqueuedAt: Date.now(), + originatingChannel: params.originatingChannel, + originatingTo: params.originatingTo, + originatingAccountId: params.originatingAccountId, + originatingThreadId: params.originatingThreadId, + run: { + agentId: "agent", + agentDir: "/tmp", + sessionId: "sess", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp", + config: {} as OpenClawConfig, + provider: "openai", + model: "gpt-test", + timeoutMs: 10_000, + blockReplyBreak: "text_end", + }, + }; +} + +describe("followup queue deduplication", () => { + it("deduplicates messages with same Discord message_id", async () => { + const key = `test-dedup-message-id-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same message id should be deduplicated + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] Hello (dupe)", + messageId: "m1", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(false); + + // Third enqueue with different message id should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "[Discord Guild #test channel id:123] World", + messageId: "m2", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(third).toBe(true); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + // Should collect both unique messages + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + }); + + it("deduplicates exact prompt when routing matches and no message id", async () => { + const key = `test-dedup-whatsapp-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + // First enqueue should succeed + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + // Second enqueue with same prompt should be allowed (default dedupe: message id only) + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(second).toBe(true); + + // Third enqueue with different prompt should succeed + const third = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world 2", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(third).toBe(true); + }); + + it("does not deduplicate across different providers without message id", async () => { + const key = `test-dedup-cross-provider-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Same text", + originatingChannel: "discord", + originatingTo: "channel:123", + }), + settings, + ); + expect(second).toBe(true); + }); + + it("can opt-in to prompt-based dedupe when message id is absent", async () => { + const key = `test-dedup-prompt-mode-${Date.now()}`; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + const first = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(first).toBe(true); + + const second = enqueueFollowupRun( + key, + createRun({ + prompt: "Hello world", + originatingChannel: "whatsapp", + originatingTo: "+1234567890", + }), + settings, + "prompt", + ); + expect(second).toBe(false); + }); +}); + +describe("followup queue collect routing", () => { + it("does not collect when destinations differ", async () => { + const key = `test-collect-diff-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:B", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + }); + + it("collects when channel+destination match", async () => { + const key = `test-collect-same-to-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingChannel).toBe("slack"); + expect(calls[0]?.originatingTo).toBe("channel:A"); + }); + + it("collects Slack messages in same thread and preserves string thread id", async () => { + const key = `test-collect-slack-thread-same-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queued messages while agent was busy]"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + }); + + it("does not collect Slack messages when thread ids differ", async () => { + const key = `test-collect-slack-thread-diff-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 2; + const runFollowup = async (run: FollowupRun) => { + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun( + key, + createRun({ + prompt: "one", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000001", + }), + settings, + ); + enqueueFollowupRun( + key, + createRun({ + prompt: "two", + originatingChannel: "slack", + originatingTo: "channel:A", + originatingThreadId: "1706000000.000002", + }), + settings, + ); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toBe("one"); + expect(calls[1]?.prompt).toBe("two"); + expect(calls[0]?.originatingThreadId).toBe("1706000000.000001"); + expect(calls[1]?.originatingThreadId).toBe("1706000000.000002"); + }); + + it("retries collect-mode batches without losing queued items", async () => { + const key = `test-collect-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "collect", + debounceMs: 0, + cap: 50, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "one" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "two" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("Queued #1\none"); + expect(calls[0]?.prompt).toContain("Queued #2\ntwo"); + }); + + it("retries overflow summary delivery without losing dropped previews", async () => { + const key = `test-overflow-summary-retry-${Date.now()}`; + const calls: FollowupRun[] = []; + const done = createDeferred(); + const expectedCalls = 1; + let attempt = 0; + const runFollowup = async (run: FollowupRun) => { + attempt += 1; + if (attempt === 1) { + throw new Error("transient failure"); + } + calls.push(run); + if (calls.length >= expectedCalls) { + done.resolve(); + } + }; + const settings: QueueSettings = { + mode: "followup", + debounceMs: 0, + cap: 1, + dropPolicy: "summarize", + }; + + enqueueFollowupRun(key, createRun({ prompt: "first" }), settings); + enqueueFollowupRun(key, createRun({ prompt: "second" }), settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + expect(calls[0]?.prompt).toContain("[Queue overflow] Dropped 1 message due to cap."); + expect(calls[0]?.prompt).toContain("- first"); + }); +}); + +const emptyCfg = {} as OpenClawConfig; + +describe("createReplyDispatcher", () => { + it("drops empty payloads and silent tokens without media", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ deliver }); + + expect(dispatcher.sendFinalReply({})).toBe(false); + expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); + + await dispatcher.waitForIdle(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("strips heartbeat tokens and applies responsePrefix", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const onHeartbeatStrip = vi.fn(); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + onHeartbeatStrip, + }); + + expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); + expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); + expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); + }); + + it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + responsePrefix: "PFX", + }); + + expect( + dispatcher.sendFinalReply({ + text: "PFX already", + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: HEARTBEAT_TOKEN, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: `${SILENT_REPLY_TOKEN} -- explanation`, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); + + await dispatcher.waitForIdle(); + + expect(deliver).toHaveBeenCalledTimes(3); + expect(deliver.mock.calls[0][0].text).toBe("PFX already"); + expect(deliver.mock.calls[1][0].text).toBe(""); + expect(deliver.mock.calls[2][0].text).toBe(""); + }); + + it("preserves ordering across tool, block, and final replies", async () => { + const delivered: string[] = []; + const deliver = vi.fn(async (_payload, info) => { + delivered.push(info.kind); + if (info.kind === "tool") { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }); + const dispatcher = createReplyDispatcher({ deliver }); + + dispatcher.sendToolResult({ text: "tool" }); + dispatcher.sendBlockReply({ text: "block" }); + dispatcher.sendFinalReply({ text: "final" }); + + await dispatcher.waitForIdle(); + expect(delivered).toEqual(["tool", "block", "final"]); + }); + + it("fires onIdle when the queue drains", async () => { + const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); + const onIdle = vi.fn(); + const dispatcher = createReplyDispatcher({ deliver, onIdle }); + + dispatcher.sendToolResult({ text: "one" }); + dispatcher.sendFinalReply({ text: "two" }); + + await dispatcher.waitForIdle(); + dispatcher.markComplete(); + await Promise.resolve(); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it("delays block replies after the first when humanDelay is natural", async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "natural" }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(799); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + randomSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("uses custom bounds for humanDelay and clamps when max <= min", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue(undefined); + const dispatcher = createReplyDispatcher({ + deliver, + humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, + }); + + dispatcher.sendBlockReply({ text: "first" }); + await Promise.resolve(); + expect(deliver).toHaveBeenCalledTimes(1); + + dispatcher.sendBlockReply({ text: "second" }); + await vi.advanceTimersByTimeAsync(1199); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); +}); + +describe("resolveReplyToMode", () => { + it("defaults to off for Telegram", () => { + expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); + }); + + 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 OpenClawConfig; + 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 OpenClawConfig; + 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 OpenClawConfig; + 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 OpenClawConfig; + 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", { allowExplicitReplyTagsWhenOff: 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-routing.test.ts b/src/auto-reply/reply/reply-routing.test.ts deleted file mode 100644 index 78a4010c53c..00000000000 --- a/src/auto-reply/reply/reply-routing.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } 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 OpenClawConfig; - -describe("createReplyDispatcher", () => { - it("drops empty payloads and silent tokens without media", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ deliver }); - - expect(dispatcher.sendFinalReply({})).toBe(false); - expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); - expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); - - await dispatcher.waitForIdle(); - expect(deliver).not.toHaveBeenCalled(); - }); - - it("strips heartbeat tokens and applies responsePrefix", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const onHeartbeatStrip = vi.fn(); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - onHeartbeatStrip, - }); - - expect(dispatcher.sendFinalReply({ text: HEARTBEAT_TOKEN })).toBe(false); - expect(dispatcher.sendToolResult({ text: `${HEARTBEAT_TOKEN} hello` })).toBe(true); - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(1); - expect(deliver.mock.calls[0][0].text).toBe("PFX hello"); - expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); - }); - - it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - responsePrefix: "PFX", - }); - - expect( - dispatcher.sendFinalReply({ - text: "PFX already", - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: HEARTBEAT_TOKEN, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - expect( - dispatcher.sendFinalReply({ - text: `${SILENT_REPLY_TOKEN} -- explanation`, - mediaUrl: "file:///tmp/photo.jpg", - }), - ).toBe(true); - - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(3); - expect(deliver.mock.calls[0][0].text).toBe("PFX already"); - expect(deliver.mock.calls[1][0].text).toBe(""); - expect(deliver.mock.calls[2][0].text).toBe(""); - }); - - it("preserves ordering across tool, block, and final replies", async () => { - const delivered: string[] = []; - const deliver = vi.fn(async (_payload, info) => { - delivered.push(info.kind); - if (info.kind === "tool") { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - }); - const dispatcher = createReplyDispatcher({ deliver }); - - dispatcher.sendToolResult({ text: "tool" }); - dispatcher.sendBlockReply({ text: "block" }); - dispatcher.sendFinalReply({ text: "final" }); - - await dispatcher.waitForIdle(); - expect(delivered).toEqual(["tool", "block", "final"]); - }); - - it("fires onIdle when the queue drains", async () => { - const deliver = vi.fn(async () => await new Promise((resolve) => setTimeout(resolve, 5))); - const onIdle = vi.fn(); - const dispatcher = createReplyDispatcher({ deliver, onIdle }); - - dispatcher.sendToolResult({ text: "one" }); - dispatcher.sendFinalReply({ text: "two" }); - - await dispatcher.waitForIdle(); - dispatcher.markComplete(); - await Promise.resolve(); - expect(onIdle).toHaveBeenCalledTimes(1); - }); - - it("delays block replies after the first when humanDelay is natural", async () => { - vi.useFakeTimers(); - const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "natural" }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(799); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - randomSpy.mockRestore(); - vi.useRealTimers(); - }); - - it("uses custom bounds for humanDelay and clamps when max <= min", async () => { - vi.useFakeTimers(); - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - humanDelay: { mode: "custom", minMs: 1200, maxMs: 400 }, - }); - - dispatcher.sendBlockReply({ text: "first" }); - await Promise.resolve(); - expect(deliver).toHaveBeenCalledTimes(1); - - dispatcher.sendBlockReply({ text: "second" }); - await vi.advanceTimersByTimeAsync(1199); - expect(deliver).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); -}); - -describe("resolveReplyToMode", () => { - it("defaults to off for Telegram", () => { - expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off"); - }); - - 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 OpenClawConfig; - 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 OpenClawConfig; - 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 OpenClawConfig; - 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 OpenClawConfig; - 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", { allowExplicitReplyTagsWhenOff: 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(); - }); -}); From 0e2d8b8a1ee052437fce80a58e6fa89d206d19d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:25:08 +0000 Subject: [PATCH 058/178] perf(test): consolidate channel action suites --- src/channels/plugins/actions/actions.test.ts | 529 ++++++++++++++++++ src/channels/plugins/actions/discord.test.ts | 166 ------ .../actions/discord/handle-action.test.ts | 58 -- src/channels/plugins/actions/signal.test.ts | 150 ----- src/channels/plugins/actions/telegram.test.ts | 143 ----- src/channels/plugins/slack.actions.test.ts | 57 -- 6 files changed, 529 insertions(+), 574 deletions(-) create mode 100644 src/channels/plugins/actions/actions.test.ts delete mode 100644 src/channels/plugins/actions/discord.test.ts delete mode 100644 src/channels/plugins/actions/discord/handle-action.test.ts delete mode 100644 src/channels/plugins/actions/signal.test.ts delete mode 100644 src/channels/plugins/actions/telegram.test.ts delete mode 100644 src/channels/plugins/slack.actions.test.ts diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts new file mode 100644 index 00000000000..1b33f41377c --- /dev/null +++ b/src/channels/plugins/actions/actions.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); +const handleTelegramAction = vi.fn(async () => ({ ok: true })); +const sendReactionSignal = vi.fn(async () => ({ ok: true })); +const removeReactionSignal = vi.fn(async () => ({ ok: true })); +const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); + +vi.mock("../../../agents/tools/discord-actions.js", () => ({ + handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), +})); + +vi.mock("../../../agents/tools/telegram-actions.js", () => ({ + handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), +})); + +vi.mock("../../../signal/send-reactions.js", () => ({ + sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), + removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), +})); + +vi.mock("../../../agents/tools/slack-actions.js", () => ({ + handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), +})); + +const { discordMessageActions } = await import("./discord.js"); +const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); +const { telegramMessageActions } = await import("./telegram.js"); +const { signalMessageActions } = await import("./signal.js"); +const { createSlackActions } = await import("../slack.actions.js"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("discord message actions", () => { + it("lists channel and upload actions by default", async () => { + const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).toContain("emoji-upload"); + expect(actions).toContain("sticker-upload"); + expect(actions).toContain("channel-create"); + }); + + it("respects disabled channel actions", async () => { + const cfg = { + channels: { discord: { token: "d0", actions: { channels: false } } }, + } as OpenClawConfig; + const actions = discordMessageActions.listActions?.({ cfg }) ?? []; + + expect(actions).not.toContain("channel-create"); + }); +}); + +describe("handleDiscordMessageAction", () => { + it("forwards context accountId for send", async () => { + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + accountId: "ops", + to: "channel:123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards legacy embeds for send", async () => { + const embeds = [{ title: "Legacy", description: "Use components v2." }]; + + await handleDiscordMessageAction({ + action: "send", + params: { + to: "channel:123", + message: "hi", + embeds, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "channel:123", + content: "hi", + embeds, + }), + expect.any(Object), + ); + }); + + it("falls back to params accountId when context missing", async () => { + await handleDiscordMessageAction({ + action: "poll", + params: { + to: "channel:123", + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], + accountId: "marve", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + accountId: "marve", + to: "channel:123", + question: "Ready?", + answers: ["Yes", "No"], + }), + expect.any(Object), + ); + }); + + it("forwards accountId for thread replies", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "123", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("accepts threadId for thread replies (tool compatibility)", async () => { + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + // The `message` tool uses `threadId`. + threadId: "999", + // Include a conflicting channelId to ensure threadId takes precedence. + channelId: "123", + message: "hi", + }, + cfg: {} as OpenClawConfig, + accountId: "ops", + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadReply", + accountId: "ops", + channelId: "999", + content: "hi", + }), + expect.any(Object), + ); + }); + + it("forwards thread-create message as content", async () => { + await handleDiscordMessageAction({ + action: "thread-create", + params: { + to: "channel:123456789", + threadName: "Forum thread", + message: "Initial forum post body", + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "threadCreate", + channelId: "123456789", + name: "Forum thread", + content: "Initial forum post body", + }), + expect.any(Object), + ); + }); + + it("forwards thread edit fields for channel-edit", async () => { + await handleDiscordMessageAction({ + action: "channel-edit", + params: { + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }, + cfg: {} as OpenClawConfig, + }); + + expect(handleDiscordAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "channelEdit", + channelId: "123456789", + archived: true, + locked: false, + autoArchiveDuration: 1440, + }), + expect.any(Object), + ); + }); +}); + +describe("telegramMessageActions", () => { + it("excludes sticker actions when not enabled", () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const actions = telegramMessageActions.listActions({ cfg }); + expect(actions).not.toContain("sticker"); + expect(actions).not.toContain("sticker-search"); + }); + + it("allows media-only sends and passes asVoice", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "123", + media: "https://example.com/voice.ogg", + asVoice: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "123", + content: "", + mediaUrl: "https://example.com/voice.ogg", + asVoice: true, + }), + cfg, + ); + }); + + it("passes silent flag for silent sends", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "send", + params: { + to: "456", + message: "Silent notification test", + silent: true, + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "456", + content: "Silent notification test", + silent: true, + }), + cfg, + ); + }); + + it("maps edit action params into editMessage", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: 42, + message: "Updated", + buttons: [], + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "editMessage", + chatId: "123", + messageId: 42, + content: "Updated", + buttons: [], + accountId: undefined, + }, + cfg, + ); + }); + + it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await expect( + telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: "nope", + message: "Updated", + }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(); + + expect(handleTelegramAction).not.toHaveBeenCalled(); + }); + + it("accepts numeric messageId and channelId for reactions", async () => { + const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + + await telegramMessageActions.handleAction({ + action: "react", + params: { + channelId: 123, + messageId: 456, + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0] as Record; + expect(call.action).toBe("react"); + expect(String(call.chatId)).toBe("123"); + expect(String(call.messageId)).toBe("456"); + expect(call.emoji).toBe("ok"); + }); +}); + +describe("signalMessageActions", () => { + it("returns no actions when no configured accounts exist", () => { + const cfg = {} as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual([]); + }); + + it("hides react when reactions are disabled", () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); + }); + + it("enables react when at least one account allows reactions", () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); + }); + + it("skips send for plugin dispatch", () => { + expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); + expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); + }); + + it("blocks reactions when action gate is disabled", async () => { + const cfg = { + channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "✅" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/actions\.reactions/); + }); + + it("uses account-level actions when enabled", async () => { + const cfg = { + channels: { + signal: { + actions: { reactions: false }, + accounts: { + work: { account: "+15550001111", actions: { reactions: true } }, + }, + }, + }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { to: "+15550001111", messageId: "123", emoji: "👍" }, + cfg, + accountId: "work", + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", { + accountId: "work", + }); + }); + + it("normalizes uuid recipients", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "🔥", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith( + "123e4567-e89b-12d3-a456-426614174000", + 123, + "🔥", + { accountId: undefined }, + ); + }); + + it("requires targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await expect( + signalMessageActions.handleAction({ + action: "react", + params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(/targetAuthor/); + }); + + it("passes groupId and targetAuthor for group reactions", async () => { + const cfg = { + channels: { signal: { account: "+15550001111" } }, + } as OpenClawConfig; + + await signalMessageActions.handleAction({ + action: "react", + params: { + to: "signal:group:group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + messageId: "123", + emoji: "✅", + }, + cfg, + accountId: undefined, + }); + + expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", { + accountId: undefined, + groupId: "group-id", + targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", + targetAuthorUuid: undefined, + }); + }); +}); + +describe("slack actions adapter", () => { + it("forwards threadId for read", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "read", + cfg, + params: { + channelId: "C1", + threadId: "171234.567", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "readMessages", + channelId: "C1", + threadId: "171234.567", + }); + }); + + it("forwards normalized limit for emoji-list", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + + await actions.handleAction?.({ + channel: "slack", + action: "emoji-list", + cfg, + params: { + limit: "2.9", + }, + }); + + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject({ + action: "emojiList", + limit: 2, + }); + }); +}); diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts deleted file mode 100644 index 63426c89c26..00000000000 --- a/src/channels/plugins/actions/discord.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -type SendMessageDiscord = typeof import("../../../discord/send.js").sendMessageDiscord; -type SendPollDiscord = typeof import("../../../discord/send.js").sendPollDiscord; - -const sendMessageDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); -const sendPollDiscord = vi.fn, ReturnType>( - async () => ({ ok: true }) as Awaited>, -); - -vi.mock("../../../discord/send.js", async () => { - const actual = await vi.importActual( - "../../../discord/send.js", - ); - return { - ...actual, - sendMessageDiscord: (...args: Parameters) => sendMessageDiscord(...args), - sendPollDiscord: (...args: Parameters) => sendPollDiscord(...args), - }; -}); - -const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); -const { discordMessageActions } = await import("./discord.js"); - -describe("discord message actions", () => { - it("lists channel and upload actions by default", async () => { - const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).toContain("emoji-upload"); - expect(actions).toContain("sticker-upload"); - expect(actions).toContain("channel-create"); - }); - - it("respects disabled channel actions", async () => { - const cfg = { - channels: { discord: { token: "d0", actions: { channels: false } } }, - } as OpenClawConfig; - const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - - expect(actions).not.toContain("channel-create"); - }); -}); - -describe("handleDiscordMessageAction", () => { - it("forwards context accountId for send", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "send", - params: { - to: "channel:123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("forwards legacy embeds for send", async () => { - sendMessageDiscord.mockClear(); - - const embeds = [{ title: "Legacy", description: "Use components v2." }]; - - await handleDiscordMessageAction({ - action: "send", - params: { - to: "channel:123", - message: "hi", - embeds, - }, - cfg: {} as OpenClawConfig, - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - embeds, - }), - ); - }); - - it("falls back to params accountId when context missing", async () => { - sendPollDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "poll", - params: { - to: "channel:123", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - accountId: "marve", - }, - cfg: {} as OpenClawConfig, - }); - - expect(sendPollDiscord).toHaveBeenCalledWith( - "channel:123", - expect.objectContaining({ - question: "Ready?", - options: ["Yes", "No"], - }), - expect.objectContaining({ - accountId: "marve", - }), - ); - }); - - it("forwards accountId for thread replies", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:123", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); - - it("accepts threadId for thread replies (tool compatibility)", async () => { - sendMessageDiscord.mockClear(); - - await handleDiscordMessageAction({ - action: "thread-reply", - params: { - // The `message` tool uses `threadId`. - threadId: "999", - // Include a conflicting channelId to ensure threadId takes precedence. - channelId: "123", - message: "hi", - }, - cfg: {} as OpenClawConfig, - accountId: "ops", - }); - - expect(sendMessageDiscord).toHaveBeenCalledWith( - "channel:999", - "hi", - expect.objectContaining({ - accountId: "ops", - }), - ); - }); -}); diff --git a/src/channels/plugins/actions/discord/handle-action.test.ts b/src/channels/plugins/actions/discord/handle-action.test.ts deleted file mode 100644 index 425c7d5a50e..00000000000 --- a/src/channels/plugins/actions/discord/handle-action.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { handleDiscordMessageAction } from "./handle-action.js"; - -const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../../../agents/tools/discord-actions.js", () => ({ - handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args), -})); - -describe("handleDiscordMessageAction", () => { - beforeEach(() => { - handleDiscordAction.mockClear(); - }); - - it("forwards thread-create message as content", async () => { - await handleDiscordMessageAction({ - action: "thread-create", - params: { - to: "channel:123456789", - threadName: "Forum thread", - message: "Initial forum post body", - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "threadCreate", - channelId: "123456789", - name: "Forum thread", - content: "Initial forum post body", - }), - expect.any(Object), - ); - }); - - it("forwards thread edit fields for channel-edit", async () => { - await handleDiscordMessageAction({ - action: "channel-edit", - params: { - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }, - cfg: {}, - }); - expect(handleDiscordAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "channelEdit", - channelId: "123456789", - archived: true, - locked: false, - autoArchiveDuration: 1440, - }), - expect.any(Object), - ); - }); -}); diff --git a/src/channels/plugins/actions/signal.test.ts b/src/channels/plugins/actions/signal.test.ts deleted file mode 100644 index 613b725f77a..00000000000 --- a/src/channels/plugins/actions/signal.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { signalMessageActions } from "./signal.js"; - -const sendReactionSignal = vi.fn(async () => ({ ok: true })); -const removeReactionSignal = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../signal/send-reactions.js", () => ({ - sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args), - removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args), -})); - -describe("signalMessageActions", () => { - it("returns no actions when no configured accounts exist", () => { - const cfg = {} as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual([]); - }); - - it("hides react when reactions are disabled", () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]); - }); - - it("enables react when at least one account allows reactions", () => { - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]); - }); - - it("skips send for plugin dispatch", () => { - expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false); - expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true); - }); - - it("blocks reactions when action gate is disabled", async () => { - const cfg = { - channels: { signal: { account: "+15550001111", actions: { reactions: false } } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "✅" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/actions\.reactions/); - }); - - it("uses account-level actions when enabled", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { - signal: { - actions: { reactions: false }, - accounts: { - work: { account: "+15550001111", actions: { reactions: true } }, - }, - }, - }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { to: "+15550001111", messageId: "123", emoji: "👍" }, - cfg, - accountId: "work", - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", { - accountId: "work", - }); - }); - - it("normalizes uuid recipients", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - recipient: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "🔥", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith( - "123e4567-e89b-12d3-a456-426614174000", - 123, - "🔥", - { accountId: undefined }, - ); - }); - - it("requires targetAuthor for group reactions", async () => { - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await expect( - signalMessageActions.handleAction({ - action: "react", - params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(/targetAuthor/); - }); - - it("passes groupId and targetAuthor for group reactions", async () => { - sendReactionSignal.mockClear(); - const cfg = { - channels: { signal: { account: "+15550001111" } }, - } as OpenClawConfig; - - await signalMessageActions.handleAction({ - action: "react", - params: { - to: "signal:group:group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - messageId: "123", - emoji: "✅", - }, - cfg, - accountId: undefined, - }); - - expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", { - accountId: undefined, - groupId: "group-id", - targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000", - targetAuthorUuid: undefined, - }); - }); -}); diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts deleted file mode 100644 index 21922905e53..00000000000 --- a/src/channels/plugins/actions/telegram.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramMessageActions } from "./telegram.js"; - -const handleTelegramAction = vi.fn(async () => ({ ok: true })); - -vi.mock("../../../agents/tools/telegram-actions.js", () => ({ - handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args), -})); - -describe("telegramMessageActions", () => { - it("excludes sticker actions when not enabled", () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - const actions = telegramMessageActions.listActions({ cfg }); - expect(actions).not.toContain("sticker"); - expect(actions).not.toContain("sticker-search"); - }); - - it("allows media-only sends and passes asVoice", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "123", - media: "https://example.com/voice.ogg", - asVoice: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "123", - content: "", - mediaUrl: "https://example.com/voice.ogg", - asVoice: true, - }), - cfg, - ); - }); - - it("passes silent flag for silent sends", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "send", - params: { - to: "456", - message: "Silent notification test", - silent: true, - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - expect.objectContaining({ - action: "sendMessage", - to: "456", - content: "Silent notification test", - silent: true, - }), - cfg, - ); - }); - - it("maps edit action params into editMessage", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: 42, - message: "Updated", - buttons: [], - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledWith( - { - action: "editMessage", - chatId: "123", - messageId: 42, - content: "Updated", - buttons: [], - accountId: undefined, - }, - cfg, - ); - }); - - it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await expect( - telegramMessageActions.handleAction({ - action: "edit", - params: { - chatId: "123", - messageId: "nope", - message: "Updated", - }, - cfg, - accountId: undefined, - }), - ).rejects.toThrow(); - - expect(handleTelegramAction).not.toHaveBeenCalled(); - }); - - it("accepts numeric messageId and channelId for reactions", async () => { - handleTelegramAction.mockClear(); - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; - - await telegramMessageActions.handleAction({ - action: "react", - params: { - channelId: 123, - messageId: 456, - emoji: "ok", - }, - cfg, - accountId: undefined, - }); - - expect(handleTelegramAction).toHaveBeenCalledTimes(1); - const call = handleTelegramAction.mock.calls[0]?.[0] as Record; - expect(call.action).toBe("react"); - expect(String(call.chatId)).toBe("123"); - expect(String(call.messageId)).toBe("456"); - expect(call.emoji).toBe("ok"); - }); -}); diff --git a/src/channels/plugins/slack.actions.test.ts b/src/channels/plugins/slack.actions.test.ts deleted file mode 100644 index 844da4f09ad..00000000000 --- a/src/channels/plugins/slack.actions.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { createSlackActions } from "./slack.actions.js"; - -const handleSlackAction = vi.fn(async () => ({ details: { ok: true } })); - -vi.mock("../../agents/tools/slack-actions.js", () => ({ - handleSlackAction: (...args: unknown[]) => handleSlackAction(...args), -})); - -describe("slack actions adapter", () => { - beforeEach(() => { - handleSlackAction.mockClear(); - }); - - it("forwards threadId for read", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "read", - cfg, - params: { - channelId: "C1", - threadId: "171234.567", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "readMessages", - channelId: "C1", - threadId: "171234.567", - }); - }); - - it("forwards normalized limit for emoji-list", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "emoji-list", - cfg, - params: { - limit: "2.9", - }, - }); - - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ - action: "emojiList", - limit: 2, - }); - }); -}); From bbcbabab74a3412f55f66a40402cd7735a8da34e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:45:44 +0000 Subject: [PATCH 059/178] fix(ci): repair e2e mocks and tool schemas --- src/agents/bash-tools.process.ts | 5 +- .../openclaw-tools.sessions.e2e.test.ts | 2 +- ...gents.sessions-spawn.allowlist.e2e.test.ts | 6 +-- ...gents.sessions-spawn.lifecycle.e2e.test.ts | 52 ++++++++++--------- ...subagents.sessions-spawn.model.e2e.test.ts | 4 +- src/agents/tools/sessions-spawn-tool.ts | 17 +++--- ...y.triggers.group-intro-prompts.e2e.test.ts | 14 +++-- src/cli/program.test-mocks.ts | 7 +++ src/commands/status.e2e.test.ts | 1 + 9 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index b5966ab79b0..014926b7637 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -65,8 +65,9 @@ const processSchema = Type.Object({ offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), timeout: Type.Optional( - Type.Union([Type.Number(), Type.String()], { + Type.Number({ description: "For poll: wait up to this many milliseconds before returning", + minimum: 0, }), ), }); @@ -138,7 +139,7 @@ export function createProcessTool( eof?: boolean; offset?: number; limit?: number; - timeout?: number | string; + timeout?: unknown; }; if (params.action === "list") { diff --git a/src/agents/openclaw-tools.sessions.e2e.test.ts b/src/agents/openclaw-tools.sessions.e2e.test.ts index df8e1bb7186..b9a9c56dd2b 100644 --- a/src/agents/openclaw-tools.sessions.e2e.test.ts +++ b/src/agents/openclaw-tools.sessions.e2e.test.ts @@ -783,7 +783,7 @@ describe("sessions tools", () => { text?: string; }; expect(details.status).toBe("ok"); - expect(details.text).toContain("tokens 1k (in 12 / out 1k)"); + expect(details.text).toMatch(/tokens 1(\.0)?k \(in 12 \/ out 1(\.0)?k\)/); expect(details.text).toContain("prompt/cache 197k"); expect(details.text).not.toContain("1.0k io"); } finally { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index 937d6f2826a..4e155a71586 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -79,7 +79,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows cross-agent spawning when configured", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -133,7 +133,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn allows any agent when allowlist is *", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", @@ -187,7 +187,7 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { it("sessions_spawn normalizes allowlisted agent ids", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ + setSessionsSpawnConfigOverride({ session: { mainKey: "main", scope: "per-sender", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index faac951d7b4..6dbe1060798 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; +import { sleep } from "../utils.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, @@ -112,6 +113,16 @@ function setupSessionsSpawnGatewayMock(opts: { }; } +const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`timed out waiting for condition (timeoutMs=${timeoutMs})`); + } + await sleep(10); + } +}; + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -120,16 +131,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - let patchParams: { key?: string; label?: string } = {}; + const patchCalls: Array<{ key?: string; label?: string }> = []; const ctx = setupSessionsSpawnGatewayMock({ includeSessionsList: true, includeChatHistory: true, onSessionsPatch: (params) => { const rec = params as { key?: string; label?: string } | undefined; - if (typeof rec?.label === "string" && rec.label.trim()) { - patchParams = { key: rec.key, label: rec.label }; - } + patchCalls.push({ key: rec?.key, label: rec?.label }); }, }); @@ -165,18 +174,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => patchCalls.some((call) => call.label === "my-task")); + await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); // Cleanup should patch the label - expect(patchParams.key).toBe(child.sessionKey); - expect(patchParams.label).toBe("my-task"); + const labelPatch = patchCalls.find((call) => call.label === "my-task"); + expect(labelPatch?.key).toBe(child.sessionKey); + expect(labelPatch?.label).toBe("my-task"); // Two agent calls: subagent spawn + main agent trigger const agentCalls = ctx.calls.filter((c) => c.method === "agent"); @@ -325,14 +332,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } - const child = ctx.getChild(); + if (!child.runId) { + throw new Error("missing child runId"); + } + await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => Boolean(deletedKey)); + const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); @@ -415,12 +422,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - vi.useFakeTimers(); - try { - await vi.advanceTimersByTimeAsync(500); - } finally { - vi.useRealTimers(); - } + await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); const mainAgentCall = calls .filter((call) => call.method === "agent") diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 91aa41c494d..1bbc6d70e53 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -289,8 +289,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { const request = opts as { method?: string; params?: unknown }; calls.push(request); if (request.method === "sessions.patch") { - const params = request.params as { model?: unknown } | undefined; - if (typeof params?.model === "string" && params.model.trim()) { + const model = (request.params as { model?: unknown } | undefined)?.model; + if (model === "bad-model") { throw new Error("invalid model: bad-model"); } return { ok: true }; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1e28861a9dc..867aa85c9d9 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -28,6 +28,7 @@ const SessionsSpawnToolSchema = Type.Object({ model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + // Back-compat: older callers used timeoutSeconds for this tool. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); @@ -98,14 +99,16 @@ export function createSessionsSpawnTool(opts?: { }); // Default to 0 (no timeout) when omitted. Sub-agent runs are long-lived // by default and should not inherit the main agent 600s timeout. - const legacyTimeoutSeconds = - typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds) - ? Math.max(0, Math.floor(params.timeoutSeconds)) - : undefined; + const timeoutSecondsCandidate = + typeof params.runTimeoutSeconds === "number" + ? params.runTimeoutSeconds + : typeof params.timeoutSeconds === "number" + ? params.timeoutSeconds + : undefined; const runTimeoutSeconds = - typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) - ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : (legacyTimeoutSeconds ?? 0); + typeof timeoutSecondsCandidate === "number" && Number.isFinite(timeoutSecondsCandidate) + ? Math.max(0, Math.floor(timeoutSecondsCandidate)) + : 0; let modelWarning: string | undefined; let modelApplied = false; diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts index 5ac5281acb6..6322d7c9a8d 100644 --- a/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts +++ b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts @@ -127,7 +127,10 @@ describe("group intro prompts", () => { vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain( - `You are replying inside a Discord group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -158,8 +161,12 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); + expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain( - `You are replying inside a WhatsApp group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `WhatsApp IDs: SenderId is the participant JID (group participant id).`, + ); + expect(extraSystemPrompt).toContain( + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). WhatsApp IDs: SenderId is the participant JID (group participant id). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); @@ -190,8 +197,9 @@ describe("group intro prompts", () => { const extraSystemPrompt = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? ""; expect(extraSystemPrompt).toContain('"channel": "telegram"'); + expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain( - `You are replying inside a Telegram group chat. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, + `Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`, ); }); }); diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index 524c6b3a88e..ab0d6b497bf 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -43,6 +43,13 @@ export function installBaseProgramMocks() { ], configureCommand, configureCommandWithSections, + configureCommandFromSectionsArg: (sections: unknown, runtime: unknown) => { + const resolved = Array.isArray(sections) ? sections : []; + if (resolved.length > 0) { + return configureCommandWithSections(resolved, runtime); + } + return configureCommand({}, runtime); + }, })); vi.mock("../commands/setup.js", () => ({ setupCommand })); vi.mock("../commands/onboard.js", () => ({ onboardCommand })); diff --git a/src/commands/status.e2e.test.ts b/src/commands/status.e2e.test.ts index d5a8dcb0944..f3957a41c07 100644 --- a/src/commands/status.e2e.test.ts +++ b/src/commands/status.e2e.test.ts @@ -249,6 +249,7 @@ vi.mock("../infra/update-check.js", () => ({ }, registry: { latestVersion: "0.0.0" }, }), + formatGitInstallLabel: vi.fn(() => "main · @ deadbeef"), compareSemverStrings: vi.fn(() => 0), })); vi.mock("../config/config.js", async (importOriginal) => { From 89155aa6c63a1318ca8e628158da69bd3c06c377 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 22:56:06 +0000 Subject: [PATCH 060/178] fix(test): load sessions_spawn harness before tools --- ...openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index 4e155a71586..a400fb3eac9 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -9,7 +9,6 @@ import { import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); -const setConfigOverride = setSessionsSpawnConfigOverride; describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { From eef13235ad0188d5d20de81d89da10fd87751bc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:05:15 +0000 Subject: [PATCH 061/178] fix(test): make sessions_spawn e2e harness ordering stable --- ...gents.sessions-spawn.allowlist.e2e.test.ts | 49 ++++++------- ...gents.sessions-spawn.lifecycle.e2e.test.ts | 49 ++++++------- ...subagents.sessions-spawn.model.e2e.test.ts | 70 ++++++++----------- 3 files changed, 75 insertions(+), 93 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index a400fb3eac9..2e568714b71 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -10,6 +9,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -19,13 +31,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call6", { task: "do thing", @@ -57,13 +66,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call9", { task: "do thing", @@ -109,13 +115,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call7", { task: "do thing", @@ -163,13 +166,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call8", { task: "do thing", @@ -217,13 +217,10 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call10", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 6dbe1060798..e82d4e2dc6a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; import { sleep } from "../utils.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -18,6 +17,19 @@ vi.mock("./pi-embedded.js", () => ({ const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + type GatewayRequest = { method?: string; params?: unknown }; type AgentWaitCall = { runId?: string; timeoutMs?: number }; @@ -142,13 +154,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call2", { task: "do thing", @@ -220,13 +229,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1", { task: "do thing", @@ -314,13 +320,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call1b", { task: "do thing", @@ -404,13 +407,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-timeout", { task: "do thing", @@ -474,14 +474,11 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", agentAccountId: "kev", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-announce-account", { task: "do thing", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 1bbc6d70e53..288f3b44611 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; import { getCallGatewayMock, resetSessionsSpawnConfigOverride, @@ -11,6 +10,19 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = getCallGatewayMock(); +type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; +type CreateOpenClawToolsOpts = Parameters[0]; + +async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { + // Dynamic import: ensure harness mocks are installed before tool modules load. + const { createOpenClawTools } = await import("./openclaw-tools.js"); + const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + return tool; +} + describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -46,13 +58,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call3", { task: "do thing", @@ -93,13 +102,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking", { task: "do thing", @@ -126,13 +132,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-thinking-invalid", { task: "do thing", @@ -166,13 +169,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-default-model", { task: "do thing", @@ -207,13 +207,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-runtime-default-model", { task: "do thing", @@ -255,13 +252,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "agent:research:main", agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call-agent-model", { task: "do thing", @@ -313,13 +307,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call4", { task: "do thing", @@ -351,13 +342,10 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { return {}; }); - const tool = createOpenClawTools({ + const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } + }); const result = await tool.execute("call5", { task: "do thing", From 632b71c7f84b8f805df3ae59c35c25654b79505a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:05:22 +0000 Subject: [PATCH 062/178] fix(test): avoid inheriting process.env in nix config e2e --- src/config/config.nix-integration-u3-u5-u9.e2e.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts index da574e9a4d8..371b1da121c 100644 --- a/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts +++ b/src/config/config.nix-integration-u3-u5-u9.e2e.test.ts @@ -12,7 +12,8 @@ import { import { withTempHome } from "./test-helpers.js"; function envWith(overrides: Record): NodeJS.ProcessEnv { - return { ...process.env, ...overrides }; + // Hermetic env: don't inherit process.env because other tests may mutate it. + return { ...overrides }; } function loadConfigForHome(home: string) { From bed0e076205cc040416c588645e7b7cd444d5413 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:12:28 +0000 Subject: [PATCH 063/178] fix(cli): clear plugin manifest cache after install --- src/cli/plugins-cli.ts | 6 ++++++ src/plugins/manifest-registry.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 3d3a3341115..5f897351c5f 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; +import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildPluginStatusReport } from "../plugins/status.js"; @@ -591,6 +592,9 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Plugin CLI registrars may have warmed the manifest registry cache before install; + // force a rescan so config validation sees the freshly installed plugin. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; @@ -640,6 +644,8 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(result.error); process.exit(1); } + // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. + clearPluginManifestRegistryCache(); let next = enablePluginInConfig(cfg, result.pluginId); next = recordPluginInstall(next, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 9dc3102a6f8..fce573bafd7 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -61,6 +61,10 @@ const registryCache = new Map Date: Sun, 15 Feb 2026 23:14:38 +0000 Subject: [PATCH 064/178] refactor(test): reuse shared env snapshots --- src/agents/sandbox-skills.e2e.test.ts | 22 ++++---------------- src/infra/process-respawn.test.ts | 20 +++--------------- src/media/store.test.ts | 29 +++++++++------------------ src/test-utils/env.ts | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 54 deletions(-) diff --git a/src/agents/sandbox-skills.e2e.test.ts b/src/agents/sandbox-skills.e2e.test.ts index ae37f2a9fe9..0280c5d529a 100644 --- a/src/agents/sandbox-skills.e2e.test.ts +++ b/src/agents/sandbox-skills.e2e.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { captureFullEnv } from "../test-utils/env.js"; import { resolveSandboxContext } from "./sandbox.js"; vi.mock("./sandbox/docker.js", () => ({ @@ -27,30 +28,15 @@ async function writeSkill(params: { dir: string; name: string; description: stri ); } -function restoreEnv(snapshot: Record) { - for (const key of Object.keys(process.env)) { - if (!(key in snapshot)) { - delete process.env[key]; - } - } - for (const [key, value] of Object.entries(snapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("sandbox skill mirroring", () => { - let envSnapshot: Record; + let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = { ...process.env }; + envSnapshot = captureFullEnv(); }); afterEach(() => { - restoreEnv(envSnapshot); + envSnapshot.restore(); }); const runContext = async (workspaceAccess: "none" | "ro") => { diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index d7ea3649af3..324282ec990 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureFullEnv } from "../test-utils/env.js"; const spawnMock = vi.hoisted(() => vi.fn()); @@ -8,27 +9,12 @@ vi.mock("node:child_process", () => ({ import { restartGatewayProcessWithFreshPid } from "./process-respawn.js"; -const originalEnv = { ...process.env }; const originalArgv = [...process.argv]; const originalExecArgv = [...process.execArgv]; - -function restoreEnv() { - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key]; - } - } - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} +const envSnapshot = captureFullEnv(); afterEach(() => { - restoreEnv(); + envSnapshot.restore(); process.argv = [...originalArgv]; process.execArgv = [...originalExecArgv]; spawnMock.mockReset(); diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 5e7f510a829..642ab1c371c 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -5,30 +5,21 @@ import path from "node:path"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { isPathWithinBase } from "../../test/helpers/paths.js"; +import { captureEnv } from "../test-utils/env.js"; describe("media store", () => { let store: typeof import("./store.js"); let home = ""; - const envSnapshot: Record = {}; - - const snapshotEnv = () => { - for (const key of ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "OPENCLAW_STATE_DIR"]) { - envSnapshot[key] = process.env[key]; - } - }; - - const restoreEnv = () => { - for (const [key, value] of Object.entries(envSnapshot)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; + let envSnapshot: ReturnType; beforeAll(async () => { - snapshotEnv(); + envSnapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-home-")); process.env.HOME = home; process.env.USERPROFILE = home; @@ -45,7 +36,7 @@ describe("media store", () => { }); afterAll(async () => { - restoreEnv(); + envSnapshot.restore(); try { await fs.rm(home, { recursive: true, force: true }); } catch { diff --git a/src/test-utils/env.ts b/src/test-utils/env.ts index 9e813dcff4f..36c9b137fc4 100644 --- a/src/test-utils/env.ts +++ b/src/test-utils/env.ts @@ -17,6 +17,27 @@ export function captureEnv(keys: string[]) { }; } +export function captureFullEnv() { + const snapshot: Record = { ...process.env }; + + return { + restore() { + for (const key of Object.keys(process.env)) { + if (!(key in snapshot)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }, + }; +} + export function withEnv(env: Record, fn: () => T): T { const snapshot = captureEnv(Object.keys(env)); try { From 31980bcaf1b8a4b4c49267b712fcd4df911a6bd7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:18:16 +0000 Subject: [PATCH 065/178] refactor(test): dedupe gateway env restores --- src/gateway/server.sessions-send.e2e.test.ts | 18 ++++-------------- src/gateway/server.skills-status.e2e.test.ts | 17 +++++------------ src/gateway/server.talk-config.e2e.test.ts | 8 ++------ src/gateway/test-helpers.server.ts | 4 +++- 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/src/gateway/server.sessions-send.e2e.test.ts b/src/gateway/server.sessions-send.e2e.test.ts index 58f7d65b19e..af3b14361df 100644 --- a/src/gateway/server.sessions-send.e2e.test.ts +++ b/src/gateway/server.sessions-send.e2e.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createOpenClawTools } from "../agents/openclaw-tools.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { captureEnv } from "../test-utils/env.js"; import { agentCommand, getFreePort, @@ -16,13 +17,11 @@ installGatewayTestHooks({ scope: "suite" }); let server: Awaited>; let gatewayPort: number; -let prevGatewayPort: string | undefined; -let prevGatewayToken: string | undefined; const gatewayToken = "test-token"; +let envSnapshot: ReturnType; beforeAll(async () => { - prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT; - prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]); gatewayPort = await getFreePort(); testState.gatewayAuth = { mode: "token", token: gatewayToken }; process.env.OPENCLAW_GATEWAY_PORT = String(gatewayPort); @@ -32,16 +31,7 @@ beforeAll(async () => { afterAll(async () => { await server.close(); - if (prevGatewayPort === undefined) { - delete process.env.OPENCLAW_GATEWAY_PORT; - } else { - process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort; - } - if (prevGatewayToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevGatewayToken; - } + envSnapshot.restore(); }); describe("sessions_send gateway loopback", () => { diff --git a/src/gateway/server.skills-status.e2e.test.ts b/src/gateway/server.skills-status.e2e.test.ts index c7446e04615..f4ff52faa96 100644 --- a/src/gateway/server.skills-status.e2e.test.ts +++ b/src/gateway/server.skills-status.e2e.test.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { connectOk, installGatewayTestHooks, @@ -12,23 +13,19 @@ installGatewayTestHooks({ scope: "suite" }); async function withServer( run: (ws: Awaited>["ws"]) => Promise, ) { - const { server, ws, prevToken } = await startServerWithClient("secret"); + const { server, ws, envSnapshot } = await startServerWithClient("secret"); try { return await run(ws); } finally { ws.close(); await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } + envSnapshot.restore(); } } describe("gateway skills.status", () => { it("does not expose raw config values to operator.read clients", async () => { - const prevBundledSkillsDir = process.env.OPENCLAW_BUNDLED_SKILLS_DIR; + const envSnapshot = captureEnv(["OPENCLAW_BUNDLED_SKILLS_DIR"]); process.env.OPENCLAW_BUNDLED_SKILLS_DIR = path.join(process.cwd(), "skills"); const secret = "discord-token-secret-abc"; const { writeConfigFile } = await import("../config/config.js"); @@ -62,11 +59,7 @@ describe("gateway skills.status", () => { expect(check && "value" in check).toBe(false); }); } finally { - if (prevBundledSkillsDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_SKILLS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_SKILLS_DIR = prevBundledSkillsDir; - } + envSnapshot.restore(); } }); }); diff --git a/src/gateway/server.talk-config.e2e.test.ts b/src/gateway/server.talk-config.e2e.test.ts index 4cbea64747a..e6023d9e54d 100644 --- a/src/gateway/server.talk-config.e2e.test.ts +++ b/src/gateway/server.talk-config.e2e.test.ts @@ -11,17 +11,13 @@ installGatewayTestHooks({ scope: "suite" }); async function withServer( run: (ws: Awaited>["ws"]) => Promise, ) { - const { server, ws, prevToken } = await startServerWithClient("secret"); + const { server, ws, envSnapshot } = await startServerWithClient("secret"); try { return await run(ws); } finally { ws.close(); await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } + envSnapshot.restore(); } } diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 64c7ffd3302..509c30773f4 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -16,6 +16,7 @@ import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key.js"; +import { captureEnv } from "../test-utils/env.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; @@ -374,6 +375,7 @@ export async function startServerWithClient( ) { const { wsHeaders, ...gatewayOpts } = opts ?? {}; let port = await getFreePort(); + const envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); const prev = process.env.OPENCLAW_GATEWAY_TOKEN; if (typeof token === "string") { testState.gatewayAuth = { mode: "token", token }; @@ -421,7 +423,7 @@ export async function startServerWithClient( ws.once("error", onError); ws.once("close", onClose); }); - return { server, ws, port, prevToken: prev }; + return { server, ws, port, prevToken: prev, envSnapshot }; } type ConnectResponse = { From a68ed3f64c5715c168e1411aecc7bdc2083fde4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:22:58 +0000 Subject: [PATCH 066/178] refactor(test): reuse env snapshots in gateway call tests --- src/gateway/call.test.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index a4aff647ac0..f6c5afb607a 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; const loadConfig = vi.fn(); const resolveGatewayPort = vi.fn(); @@ -331,7 +332,10 @@ describe("callGateway error details", () => { }); describe("callGateway url override auth requirements", () => { + let envSnapshot: ReturnType; + beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -345,8 +349,7 @@ describe("callGateway url override auth requirements", () => { }); afterEach(() => { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; + envSnapshot.restore(); }); it("throws when url override is set without explicit credentials", async () => { @@ -366,9 +369,10 @@ describe("callGateway url override auth requirements", () => { }); describe("callGateway password resolution", () => { - const originalEnvPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; + let envSnapshot: ReturnType; beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PASSWORD"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -383,11 +387,7 @@ describe("callGateway password resolution", () => { }); afterEach(() => { - if (originalEnvPassword == null) { - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } else { - process.env.OPENCLAW_GATEWAY_PASSWORD = originalEnvPassword; - } + envSnapshot.restore(); }); it("uses local config password when env is unset", async () => { @@ -468,9 +468,10 @@ describe("callGateway password resolution", () => { }); describe("callGateway token resolution", () => { - const originalEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN; + let envSnapshot: ReturnType; beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); @@ -485,11 +486,7 @@ describe("callGateway token resolution", () => { }); afterEach(() => { - if (originalEnvToken == null) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = originalEnvToken; - } + envSnapshot.restore(); }); it("uses explicit token when url override is set", async () => { From e3445f59c986b4a4c587369e0ae79e3670aad72e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 16:00:09 +0100 Subject: [PATCH 067/178] docs(changelog): note inter-session provenance security fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbfe70894b..caadbd0ef9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,7 @@ Docs: https://docs.openclaw.ai - Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh. - Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable. - Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories. +- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker. - Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale. - Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj. - BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y. From d8d9d3724f9a8cf3292949ea69ae5383a775e263 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:31:40 +0100 Subject: [PATCH 068/178] docs(agents): add GHSA patch/publish notes --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8a48c040243..3cca4e68c38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,19 @@ - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them. +## GHSA (Repo Advisory) Patch/Publish + +- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` +- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` +- Private fork PRs must be closed: + `fork=$(gh api /repos/openclaw/openclaw/security-advisories/ | jq -r .private_fork.full_name)` + `gh pr list -R "$fork" --state open` (must be empty) +- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) +- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` +- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) +- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs +- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing + ## Troubleshooting - Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`). From 35ab521e07d946fa0960ac7575b7f175aa214d00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:29:00 +0000 Subject: [PATCH 069/178] refactor(test): simplify voicewake env cleanup --- .../server.models-voicewake-misc.e2e.test.ts | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/gateway/server.models-voicewake-misc.e2e.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts index 9b9bc657b4b..a89e9250270 100644 --- a/src/gateway/server.models-voicewake-misc.e2e.test.ts +++ b/src/gateway/server.models-voicewake-misc.e2e.test.ts @@ -10,6 +10,7 @@ import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js"; +import { captureEnv } from "../test-utils/env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { @@ -301,31 +302,24 @@ describe("gateway server models + voicewake", () => { describe("gateway server misc", () => { test("hello-ok advertises the gateway port for canvas host", async () => { - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - const prevCanvasPort = process.env.OPENCLAW_CANVAS_HOST_PORT; - process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - testTailnetIPv4.value = "100.64.0.1"; - testState.gatewayBind = "lan"; - const canvasPort = await getFreePort(); - testState.canvasHostPort = canvasPort; - process.env.OPENCLAW_CANVAS_HOST_PORT = String(canvasPort); + const envSnapshot = captureEnv(["OPENCLAW_CANVAS_HOST_PORT", "OPENCLAW_GATEWAY_TOKEN"]); + try { + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + testTailnetIPv4.value = "100.64.0.1"; + testState.gatewayBind = "lan"; + const canvasPort = await getFreePort(); + testState.canvasHostPort = canvasPort; + process.env.OPENCLAW_CANVAS_HOST_PORT = String(canvasPort); - const testPort = await getFreePort(); - const canvasHostUrl = resolveCanvasHostUrl({ - canvasPort, - requestHost: `100.64.0.1:${testPort}`, - localAddress: "127.0.0.1", - }); - expect(canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - if (prevCanvasPort === undefined) { - delete process.env.OPENCLAW_CANVAS_HOST_PORT; - } else { - process.env.OPENCLAW_CANVAS_HOST_PORT = prevCanvasPort; + const testPort = await getFreePort(); + const canvasHostUrl = resolveCanvasHostUrl({ + canvasPort, + requestHost: `100.64.0.1:${testPort}`, + localAddress: "127.0.0.1", + }); + expect(canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`); + } finally { + envSnapshot.restore(); } }); From f0e373b82ee344ae0bb6b1f4b70adb8a141db86a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:29:56 +0000 Subject: [PATCH 070/178] refactor(test): simplify state dir env restore --- src/media/store.redirect.test.ts | 11 +++++------ src/web/media.test.ts | 11 ++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/media/store.redirect.test.ts b/src/media/store.redirect.test.ts index 40ba39815da..3d87f5bf9bf 100644 --- a/src/media/store.redirect.test.ts +++ b/src/media/store.redirect.test.ts @@ -4,14 +4,17 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { saveMediaSource, setMediaStoreNetworkDepsForTest } from "./store.js"; const HOME = path.join(os.tmpdir(), "openclaw-home-redirect"); -const previousStateDir = process.env.OPENCLAW_STATE_DIR; const mockRequest = vi.fn(); describe("media store redirects", () => { + let envSnapshot: ReturnType; + beforeAll(async () => { + envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); await fs.rm(HOME, { recursive: true, force: true }); process.env.OPENCLAW_STATE_DIR = HOME; }); @@ -29,11 +32,7 @@ describe("media store redirects", () => { afterAll(async () => { await fs.rm(HOME, { recursive: true, force: true }); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + envSnapshot.restore(); setMediaStoreNetworkDepsForTest(); vi.clearAllMocks(); }); diff --git a/src/web/media.test.ts b/src/web/media.test.ts index c76fb55d814..fadcea5cceb 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -5,6 +5,7 @@ import sharp from "sharp"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import * as ssrf from "../infra/net/ssrf.js"; import { optimizeImageToPng } from "../media/image-ops.js"; +import { captureEnv } from "../test-utils/env.js"; import { loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } from "./media.js"; let fixtureRoot = ""; @@ -19,7 +20,7 @@ let alphaPngFile = ""; let fallbackPngBuffer: Buffer; let fallbackPngFile = ""; let fallbackPngCap = 0; -let previousStateDir: string | undefined; +let stateDirSnapshot: ReturnType; async function writeTempFile(buffer: Buffer, ext: string): Promise { const file = path.join(fixtureRoot, `media-${fixtureFileCount++}${ext}`); @@ -100,7 +101,7 @@ describe("web media loading", () => { beforeAll(() => { // Ensure state dir is stable and not influenced by other tests that stub OPENCLAW_STATE_DIR. // Also keep it outside os.tmpdir() so tmpdir localRoots doesn't accidentally make all state readable. - previousStateDir = process.env.OPENCLAW_STATE_DIR; + stateDirSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); process.env.OPENCLAW_STATE_DIR = path.join( path.parse(os.tmpdir()).root, "var", @@ -110,11 +111,7 @@ describe("web media loading", () => { }); afterAll(() => { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + stateDirSnapshot.restore(); }); beforeAll(() => { From abd009b092664ab78429476180e6db68fcd2633c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:34:52 +0000 Subject: [PATCH 071/178] refactor(test): dedupe openresponses server setup --- src/gateway/openresponses-http.e2e.test.ts | 24 +++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/gateway/openresponses-http.e2e.test.ts b/src/gateway/openresponses-http.e2e.test.ts index 0c484a56366..9747280d693 100644 --- a/src/gateway/openresponses-http.e2e.test.ts +++ b/src/gateway/openresponses-http.e2e.test.ts @@ -13,30 +13,26 @@ let enabledPort: number; beforeAll(async () => { enabledPort = await getFreePort(); - enabledServer = await startServer(enabledPort); + enabledServer = await startServer(enabledPort, { openResponsesEnabled: true }); }); afterAll(async () => { await enabledServer.close({ reason: "openresponses enabled suite done" }); }); -async function startServerWithDefaultConfig(port: number) { - const { startGatewayServer } = await import("./server.js"); - return await startGatewayServer(port, { - host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, - controlUiEnabled: false, - }); -} - async function startServer(port: number, opts?: { openResponsesEnabled?: boolean }) { const { startGatewayServer } = await import("./server.js"); - return await startGatewayServer(port, { + const serverOpts = { host: "127.0.0.1", auth: { mode: "token", token: "secret" }, controlUiEnabled: false, - openResponsesEnabled: opts?.openResponsesEnabled ?? true, - }); + } as const; + return await startGatewayServer( + port, + opts?.openResponsesEnabled === undefined + ? serverOpts + : { ...serverOpts, openResponsesEnabled: opts.openResponsesEnabled }, + ); } async function writeGatewayConfig(config: Record) { @@ -96,7 +92,7 @@ async function ensureResponseConsumed(res: Response) { describe("OpenResponses HTTP API (e2e)", () => { it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => { const port = await getFreePort(); - const _server = await startServerWithDefaultConfig(port); + const _server = await startServer(port); try { const res = await postResponses(port, { model: "openclaw", From d27a763eec2b18d67765043bf280cfc08f1e6f36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:42:20 +0000 Subject: [PATCH 072/178] refactor(test): reuse env helper in temp home harness --- src/config/home-env.test-harness.ts | 44 ++++++----------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/src/config/home-env.test-harness.ts b/src/config/home-env.test-harness.ts index 02808461b0f..78abde370dc 100644 --- a/src/config/home-env.test-harness.ts +++ b/src/config/home-env.test-harness.ts @@ -1,39 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; - -type HomeEnvSnapshot = { - home: string | undefined; - userProfile: string | undefined; - homeDrive: string | undefined; - homePath: string | undefined; - stateDir: string | undefined; -}; - -function snapshotHomeEnv(): HomeEnvSnapshot { - return { - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - homeDrive: process.env.HOMEDRIVE, - homePath: process.env.HOMEPATH, - stateDir: process.env.OPENCLAW_STATE_DIR, - }; -} - -function restoreHomeEnv(snapshot: HomeEnvSnapshot) { - const restoreKey = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreKey("HOME", snapshot.home); - restoreKey("USERPROFILE", snapshot.userProfile); - restoreKey("HOMEDRIVE", snapshot.homeDrive); - restoreKey("HOMEPATH", snapshot.homePath); - restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); -} +import { captureEnv } from "../test-utils/env.js"; export async function withTempHome( prefix: string, @@ -42,7 +10,13 @@ export async function withTempHome( const home = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - const snapshot = snapshotHomeEnv(); + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); process.env.HOME = home; process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); @@ -58,7 +32,7 @@ export async function withTempHome( try { return await fn(home); } finally { - restoreHomeEnv(snapshot); + snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); } } From f809ff5e55a63a40b1928962900b1bc352db27ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:51:24 +0000 Subject: [PATCH 073/178] refactor(test): reuse env snapshot helper --- src/agents/auth-profiles.chutes.e2e.test.ts | 34 ++++++------------- .../subagent-registry.persistence.e2e.test.ts | 9 ++--- .../usage.sessions-usage.test.ts | 9 ++--- 3 files changed, 16 insertions(+), 36 deletions(-) diff --git a/src/agents/auth-profiles.chutes.e2e.test.ts b/src/agents/auth-profiles.chutes.e2e.test.ts index 317ce9c771a..c21f37ed1ca 100644 --- a/src/agents/auth-profiles.chutes.e2e.test.ts +++ b/src/agents/auth-profiles.chutes.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -10,10 +11,7 @@ import { import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js"; describe("auth-profiles (chutes)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + let envSnapshot: ReturnType | undefined; let tempDir: string | null = null; afterEach(async () => { @@ -22,29 +20,17 @@ describe("auth-profiles (chutes)", () => { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot?.restore(); + envSnapshot = undefined; }); it("refreshes expired Chutes OAuth credentials", async () => { + envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "CHUTES_CLIENT_ID", + ]); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-")); process.env.OPENCLAW_STATE_DIR = tempDir; process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); diff --git a/src/agents/subagent-registry.persistence.e2e.test.ts b/src/agents/subagent-registry.persistence.e2e.test.ts index 9b3f5348c42..4a6620c4e57 100644 --- a/src/agents/subagent-registry.persistence.e2e.test.ts +++ b/src/agents/subagent-registry.persistence.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { initSubagentRegistry, registerSubagentRun, @@ -29,7 +30,7 @@ vi.mock("./subagent-announce.js", () => ({ })); describe("subagent registry persistence", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -39,11 +40,7 @@ describe("subagent registry persistence", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + envSnapshot.restore(); }); it("persists runs to disk and resumes after restart", async () => { diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index efdeb9a1647..3731bdd4e08 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../../test-utils/env.js"; vi.mock("../../config/config.js", () => { return { @@ -118,7 +119,7 @@ describe("sessions.usage", () => { it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => { const storeKey = "agent:opus:slack:dm:u123"; const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-")); - const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); process.env.OPENCLAW_STATE_DIR = stateDir; try { @@ -163,11 +164,7 @@ describe("sessions.usage", () => { vi.mocked(loadSessionCostSummary).mock.calls.some((call) => call[0]?.agentId === "opus"), ).toBe(true); } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } + envSnapshot.restore(); fs.rmSync(stateDir, { recursive: true, force: true }); } }); From 961ca61b0eb987c7bd80fcd4d0d1a0b22c8d716c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:53:55 +0000 Subject: [PATCH 074/178] refactor(test): dedupe onboard auth env cleanup --- src/commands/auth-choice.moonshot.e2e.test.ts | 32 +++--------- src/commands/onboard-auth.e2e.test.ts | 51 +++++-------------- 2 files changed, 22 insertions(+), 61 deletions(-) diff --git a/src/commands/auth-choice.moonshot.e2e.test.ts b/src/commands/auth-choice.moonshot.e2e.test.ts index 2e467ae7f53..d215125f357 100644 --- a/src/commands/auth-choice.moonshot.e2e.test.ts +++ b/src/commands/auth-choice.moonshot.e2e.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice } from "./auth-choice.js"; const noopAsync = async () => {}; @@ -42,10 +43,12 @@ function createPrompter(overrides: Partial): WizardPrompter { } describe("applyAuthChoice (moonshot)", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousMoonshotKey = process.env.MOONSHOT_API_KEY; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "MOONSHOT_API_KEY", + ]); let tempStateDir: string | null = null; async function setupTempState() { @@ -61,26 +64,7 @@ describe("applyAuthChoice (moonshot)", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousMoonshotKey === undefined) { - delete process.env.MOONSHOT_API_KEY; - } else { - process.env.MOONSHOT_API_KEY = previousMoonshotKey; - } + envSnapshot.restore(); }); it("keeps the .cn baseUrl when setDefaultModel is false", async () => { diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index eb6858f87d5..a26c544e133 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthProfileConfig, applyLitellmProviderConfig, @@ -40,9 +41,12 @@ const requireAgentDir = () => { }; describe("writeOAuthCredentials", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "OPENCLAW_OAUTH_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -50,22 +54,7 @@ describe("writeOAuthCredentials", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - delete process.env.OPENCLAW_OAUTH_DIR; + envSnapshot.restore(); }); it("writes auth-profiles.json under OPENCLAW_AGENT_DIR when set", async () => { @@ -100,9 +89,11 @@ describe("writeOAuthCredentials", () => { }); describe("setMinimaxApiKey", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -110,21 +101,7 @@ describe("setMinimaxApiKey", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); }); it("writes to OPENCLAW_AGENT_DIR when set", async () => { From e9c8540e210918f7a5c12ee12401112acdfde2f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:55:11 +0000 Subject: [PATCH 075/178] refactor(test): simplify model auth env restore --- src/agents/model-auth.e2e.test.ts | 56 ++++++++----------------------- 1 file changed, 14 insertions(+), 42 deletions(-) diff --git a/src/agents/model-auth.e2e.test.ts b/src/agents/model-auth.e2e.test.ts index 7385f18ee3c..f3439c6feb9 100644 --- a/src/agents/model-auth.e2e.test.ts +++ b/src/agents/model-auth.e2e.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; @@ -15,9 +16,11 @@ const oauthFixture = { describe("getApiKeyForModel", () => { it("migrates legacy oauth.json into auth-profiles.json", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-")); try { @@ -73,30 +76,18 @@ describe("getApiKeyForModel", () => { }, }); } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("suggests openai-codex when only Codex OAuth is configured", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousOpenAiKey = process.env.OPENAI_API_KEY; + const envSnapshot = captureEnv([ + "OPENAI_API_KEY", + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); try { @@ -137,26 +128,7 @@ describe("getApiKeyForModel", () => { } expect(String(error)).toContain("openai-codex/gpt-5.3-codex"); } finally { - if (previousOpenAiKey === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = previousOpenAiKey; - } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); From 94e84e6f75294bdeccae63e321f2ec8a28ee0771 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:56:06 +0000 Subject: [PATCH 076/178] refactor(test): clean up gateway tool env restore --- src/agents/openclaw-gateway-tool.e2e.test.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index 716d7ee0ad2..66dfb9483e9 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.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, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import "./test-helpers/fast-core-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; @@ -18,8 +19,7 @@ describe("gateway tool", () => { it("schedules SIGUSR1 restart", async () => { vi.useFakeTimers(); const kill = vi.spyOn(process, "kill").mockImplementation(() => true); - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousProfile = process.env.OPENCLAW_PROFILE; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR", "OPENCLAW_PROFILE"]); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); process.env.OPENCLAW_STATE_DIR = stateDir; process.env.OPENCLAW_PROFILE = "isolated"; @@ -60,16 +60,8 @@ describe("gateway tool", () => { } finally { kill.mockRestore(); vi.useRealTimers(); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); + await fs.rm(stateDir, { recursive: true, force: true }); } }); From a90e007d50e14c37e3666d7eca6842b45229a35c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:56:57 +0000 Subject: [PATCH 077/178] refactor(test): reuse env snapshot in gateway ws harness --- src/gateway/server.e2e-ws-harness.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/gateway/server.e2e-ws-harness.ts b/src/gateway/server.e2e-ws-harness.ts index c3775e53ce6..ab585d56f41 100644 --- a/src/gateway/server.e2e-ws-harness.ts +++ b/src/gateway/server.e2e-ws-harness.ts @@ -1,4 +1,5 @@ import { WebSocket } from "ws"; +import { captureEnv } from "../test-utils/env.js"; import { connectOk, getFreePort, startGatewayServer } from "./test-helpers.js"; export type GatewayWsClient = { @@ -14,7 +15,7 @@ export type GatewayServerHarness = { }; export async function startGatewayServerHarness(): Promise { - const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + const envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); delete process.env.OPENCLAW_GATEWAY_TOKEN; const port = await getFreePort(); const server = await startGatewayServer(port); @@ -28,11 +29,7 @@ export async function startGatewayServerHarness(): Promise const close = async () => { await server.close(); - if (previousToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; - } + envSnapshot.restore(); }; return { port, server, openClient, close }; From 7bb0b7d1fc72a86e6aaf56debfeddb5d1d8d4cec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:58:06 +0000 Subject: [PATCH 078/178] refactor(test): simplify config io env snapshot --- src/config/io.write-config.test.ts | 42 +++++++----------------------- 1 file changed, 9 insertions(+), 33 deletions(-) diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index b8353e54b62..04f5e34a77f 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; describe("config io write", () => { @@ -10,37 +11,6 @@ describe("config io write", () => { error: () => {}, }; - type HomeEnvSnapshot = { - home: string | undefined; - userProfile: string | undefined; - homeDrive: string | undefined; - homePath: string | undefined; - stateDir: string | undefined; - }; - - const snapshotHomeEnv = (): HomeEnvSnapshot => ({ - home: process.env.HOME, - userProfile: process.env.USERPROFILE, - homeDrive: process.env.HOMEDRIVE, - homePath: process.env.HOMEPATH, - stateDir: process.env.OPENCLAW_STATE_DIR, - }); - - const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => { - const restoreKey = (key: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - }; - restoreKey("HOME", snapshot.home); - restoreKey("USERPROFILE", snapshot.userProfile); - restoreKey("HOMEDRIVE", snapshot.homeDrive); - restoreKey("HOMEPATH", snapshot.homePath); - restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir); - }; - let fixtureRoot = ""; let caseId = 0; @@ -49,7 +19,13 @@ describe("config io write", () => { const home = path.join(fixtureRoot, `${safePrefix}${caseId++}`); await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - const snapshot = snapshotHomeEnv(); + const snapshot = captureEnv([ + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "OPENCLAW_STATE_DIR", + ]); process.env.HOME = home; process.env.USERPROFILE = home; process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw"); @@ -65,7 +41,7 @@ describe("config io write", () => { try { await fn(home); } finally { - restoreHomeEnv(snapshot); + snapshot.restore(); } } From 07dea4c6cc2dccd0fc598237e397067fc85d9c57 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 23:59:28 +0000 Subject: [PATCH 079/178] refactor(test): dedupe auth choice env cleanup --- src/commands/auth-choice.e2e.test.ts | 88 +++++----------------------- 1 file changed, 16 insertions(+), 72 deletions(-) diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index c58494792b5..2977b750e50 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice } from "./onboard-types.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { MINIMAX_CN_API_BASE_URL, @@ -38,18 +39,20 @@ const requireAgentDir = () => { }; describe("applyAuthChoice", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; - const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; - const previousHfToken = process.env.HF_TOKEN; - const previousHfHubToken = process.env.HUGGINGFACE_HUB_TOKEN; - const previousLitellmKey = process.env.LITELLM_API_KEY; - const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY; - const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - const previousSshTty = process.env.SSH_TTY; - const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "HF_TOKEN", + "HUGGINGFACE_HUB_TOKEN", + "LITELLM_API_KEY", + "AI_GATEWAY_API_KEY", + "CLOUDFLARE_AI_GATEWAY_API_KEY", + "SSH_TTY", + "CHUTES_CLIENT_ID", + ]); let tempStateDir: string | null = null; afterEach(async () => { @@ -61,66 +64,7 @@ describe("applyAuthChoice", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - if (previousAnthropicKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = previousAnthropicKey; - } - if (previousOpenrouterKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousOpenrouterKey; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHfHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHfHubToken; - } - if (previousLitellmKey === undefined) { - delete process.env.LITELLM_API_KEY; - } else { - process.env.LITELLM_API_KEY = previousLitellmKey; - } - if (previousAiGatewayKey === undefined) { - delete process.env.AI_GATEWAY_API_KEY; - } else { - process.env.AI_GATEWAY_API_KEY = previousAiGatewayKey; - } - if (previousCloudflareGatewayKey === undefined) { - delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; - } else { - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = previousCloudflareGatewayKey; - } - if (previousSshTty === undefined) { - delete process.env.SSH_TTY; - } else { - process.env.SSH_TTY = previousSshTty; - } - if (previousChutesClientId === undefined) { - delete process.env.CHUTES_CLIENT_ID; - } else { - process.env.CHUTES_CLIENT_ID = previousChutesClientId; - } + envSnapshot.restore(); }); it("does not throw when openai-codex oauth fails", async () => { From ee2fa5f4114cf12e8173c1eae18c0847a355653e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:02:32 +0000 Subject: [PATCH 080/178] refactor(test): reuse env snapshots in unit suites --- .../auth-choice.apply.huggingface.test.ts | 21 +++---------------- src/pairing/pairing-messages.test.ts | 11 ++++------ src/pairing/pairing-store.test.ts | 9 +++----- src/process/exec.test.ts | 9 +++----- 4 files changed, 13 insertions(+), 37 deletions(-) diff --git a/src/commands/auth-choice.apply.huggingface.test.ts b/src/commands/auth-choice.apply.huggingface.test.ts index 3f6d995a908..aa0e2115235 100644 --- a/src/commands/auth-choice.apply.huggingface.test.ts +++ b/src/commands/auth-choice.apply.huggingface.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { captureEnv } from "../test-utils/env.js"; import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js"; const noopAsync = async () => {}; @@ -11,9 +12,7 @@ const noop = () => {}; const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json"); describe("applyAuthChoiceHuggingface", () => { - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousHfToken = process.env.HF_TOKEN; - const previousHubToken = process.env.HUGGINGFACE_HUB_TOKEN; + const envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN"]); let tempStateDir: string | null = null; afterEach(async () => { @@ -21,21 +20,7 @@ describe("applyAuthChoiceHuggingface", () => { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousHfToken === undefined) { - delete process.env.HF_TOKEN; - } else { - process.env.HF_TOKEN = previousHfToken; - } - if (previousHubToken === undefined) { - delete process.env.HUGGINGFACE_HUB_TOKEN; - } else { - process.env.HUGGINGFACE_HUB_TOKEN = previousHubToken; - } + envSnapshot.restore(); }); it("returns null when authChoice is not huggingface-api-key", async () => { diff --git a/src/pairing/pairing-messages.test.ts b/src/pairing/pairing-messages.test.ts index e63083560a1..5480d333c51 100644 --- a/src/pairing/pairing-messages.test.ts +++ b/src/pairing/pairing-messages.test.ts @@ -1,20 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { buildPairingReply } from "./pairing-messages.js"; describe("buildPairingReply", () => { - let previousProfile: string | undefined; + let envSnapshot: ReturnType; beforeEach(() => { - previousProfile = process.env.OPENCLAW_PROFILE; + envSnapshot = captureEnv(["OPENCLAW_PROFILE"]); process.env.OPENCLAW_PROFILE = "isolated"; }); afterEach(() => { - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - return; - } - process.env.OPENCLAW_PROFILE = previousProfile; + envSnapshot.restore(); }); const cases = [ diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index ab667443818..c0fb933f9e9 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { resolveOAuthDir } from "../config/paths.js"; +import { captureEnv } from "../test-utils/env.js"; import { listChannelPairingRequests, upsertChannelPairingRequest } from "./pairing-store.js"; let fixtureRoot = ""; @@ -20,18 +21,14 @@ afterAll(async () => { }); async function withTempStateDir(fn: (stateDir: string) => Promise) { - const previous = process.env.OPENCLAW_STATE_DIR; + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); const dir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(dir, { recursive: true }); process.env.OPENCLAW_STATE_DIR = dir; try { return await fn(dir); } finally { - if (previous === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previous; - } + envSnapshot.restore(); } } diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 7e1dd1f0232..0af0979558b 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; describe("runCommandWithTimeout", () => { @@ -25,7 +26,7 @@ describe("runCommandWithTimeout", () => { }); it("merges custom env with process.env", async () => { - const previous = process.env.OPENCLAW_BASE_ENV; + const envSnapshot = captureEnv(["OPENCLAW_BASE_ENV"]); process.env.OPENCLAW_BASE_ENV = "base"; try { const result = await runCommandWithTimeout( @@ -43,11 +44,7 @@ describe("runCommandWithTimeout", () => { expect(result.code).toBe(0); expect(result.stdout).toBe("base|ok"); } finally { - if (previous === undefined) { - delete process.env.OPENCLAW_BASE_ENV; - } else { - process.env.OPENCLAW_BASE_ENV = previous; - } + envSnapshot.restore(); } }); }); From fe73878dfc71081288c1cfe4c04a4b362a9f7972 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 15 Feb 2026 23:57:49 +0000 Subject: [PATCH 081/178] fix(gateway): preserve session mapping across gateway restarts --- src/gateway/boot.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ src/gateway/boot.ts | 38 +++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 9e6ead765b9..492c60f0b9b 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -9,6 +9,9 @@ vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); const { resolveMainSessionKey } = await import("../config/sessions/main-session.js"); +const { saveSessionStore } = await import("../config/sessions/store.js"); +const { resolveStorePath } = await import("../config/sessions/paths.js"); +const { resolveAgentIdFromSessionKey } = await import("../config/sessions/main-session.js"); describe("runBootOnce", () => { beforeEach(() => { @@ -69,4 +72,94 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + + it("generates new session ID when no existing session exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + agentCommand.mockResolvedValue(undefined); + const cfg = {}; + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify a boot-style session ID was generated (format: boot-YYYY-MM-DD_HH-MM-SS-xxx-xxxxxxxx) + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("reuses existing session ID when session mapping exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + // Create a session store with an existing session + const cfg = {}; + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(undefined, { agentId }); + const existingSessionId = "existing-session-abc123"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now(), + }, + }); + + agentCommand.mockResolvedValue(undefined); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify the existing session ID was reused + expect(call?.sessionId).toBe(existingSessionId); + expect(call?.sessionKey).toBe(sessionKey); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("appends boot message to existing session transcript", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + const content = "Check if the system is healthy."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + // Create a session store with an existing session + const cfg = {}; + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(undefined, { agentId }); + const existingSessionId = "test-session-xyz789"; + + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: Date.now() - 60_000, // 1 minute ago + }, + }); + + agentCommand.mockResolvedValue(undefined); + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const call = agentCommand.mock.calls[0]?.[0]; + + // Verify boot message uses the existing session + expect(call?.sessionId).toBe(existingSessionId); + expect(call?.sessionKey).toBe(sessionKey); + + // The agent command should append to the existing session's JSONL file + // (actual file append is handled by agentCommand, we just verify the IDs match) + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); }); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index bc95c2ab6c5..7017811a0b6 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -6,6 +6,9 @@ import type { OpenClawConfig } from "../config/config.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { agentCommand } from "../commands/agent.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveAgentIdFromSessionKey } from "../config/sessions/main-session.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; @@ -16,6 +19,39 @@ function generateBootSessionId(): string { return `boot-${ts}-${suffix}`; } +/** + * Resolve the session ID for the boot message. + * If there's an existing session mapped to the main session key, reuse it to avoid orphaning. + * Otherwise, generate a new ephemeral boot session ID. + */ +function resolveBootSessionId(cfg: OpenClawConfig): string { + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + + try { + const sessionStore = loadSessionStore(storePath); + const existingEntry = sessionStore[sessionKey]; + + if (existingEntry?.sessionId) { + log.info("reusing existing session for boot message", { + sessionKey, + sessionId: existingEntry.sessionId, + }); + return existingEntry.sessionId; + } + } catch (err) { + // If we can't load the session store (e.g., first boot), fall through to generate new ID + log.debug("could not load session store for boot; generating new session ID", { + error: String(err), + }); + } + + const newSessionId = generateBootSessionId(); + log.info("generating new boot session", { sessionKey, sessionId: newSessionId }); + return newSessionId; +} + const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -83,7 +119,7 @@ export async function runBootOnce(params: { const sessionKey = resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); - const sessionId = generateBootSessionId(); + const sessionId = resolveBootSessionId(params.cfg); try { await agentCommand( From b562aa6625779ae3d02eb6a23e7f6e0059f0aa7f Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 15 Feb 2026 23:59:41 +0000 Subject: [PATCH 082/178] fix(gateway): keep boot sessions ephemeral without remapping main --- src/gateway/boot.test.ts | 80 ++++++++++++++++------- src/gateway/boot.ts | 137 +++++++++++++++++++++++++++------------ 2 files changed, 152 insertions(+), 65 deletions(-) diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 492c60f0b9b..4c8790319f2 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -8,14 +8,23 @@ const agentCommand = vi.fn(); vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); -const { resolveMainSessionKey } = await import("../config/sessions/main-session.js"); -const { saveSessionStore } = await import("../config/sessions/store.js"); +const { resolveAgentIdFromSessionKey, resolveMainSessionKey } = + await import("../config/sessions/main-session.js"); const { resolveStorePath } = await import("../config/sessions/paths.js"); -const { resolveAgentIdFromSessionKey } = await import("../config/sessions/main-session.js"); +const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js"); describe("runBootOnce", () => { - beforeEach(() => { + const resolveMainStore = (cfg: { session?: { store?: string } } = {}) => { + const sessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + return { sessionKey, storePath }; + }; + + beforeEach(async () => { vi.clearAllMocks(); + const { storePath } = resolveMainStore(); + await fs.rm(storePath, { force: true }); }); const makeDeps = () => ({ @@ -93,17 +102,14 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); - it("reuses existing session ID when session mapping exists", async () => { + it("uses a fresh boot session ID even when main session mapping already exists", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); const content = "Say hello when you wake up."; await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); - // Create a session store with an existing session const cfg = {}; - const sessionKey = resolveMainSessionKey(cfg); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const storePath = resolveStorePath(undefined, { agentId }); - const existingSessionId = "existing-session-abc123"; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-abc123"; await saveSessionStore(storePath, { [sessionKey]: { @@ -120,24 +126,21 @@ describe("runBootOnce", () => { expect(agentCommand).toHaveBeenCalledTimes(1); const call = agentCommand.mock.calls[0]?.[0]; - // Verify the existing session ID was reused - expect(call?.sessionId).toBe(existingSessionId); + expect(call?.sessionId).not.toBe(existingSessionId); + expect(call?.sessionId).toMatch(/^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/); expect(call?.sessionKey).toBe(sessionKey); await fs.rm(workspaceDir, { recursive: true, force: true }); }); - it("appends boot message to existing session transcript", async () => { + it("restores the original main session mapping after the boot run", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); const content = "Check if the system is healthy."; await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); - // Create a session store with an existing session const cfg = {}; - const sessionKey = resolveMainSessionKey(cfg); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const storePath = resolveStorePath(undefined, { agentId }); - const existingSessionId = "test-session-xyz789"; + const { sessionKey, storePath } = resolveMainStore(cfg); + const existingSessionId = "main-session-xyz789"; await saveSessionStore(storePath, { [sessionKey]: { @@ -146,19 +149,46 @@ describe("runBootOnce", () => { }, }); - agentCommand.mockResolvedValue(undefined); + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ status: "ran", }); - const call = agentCommand.mock.calls[0]?.[0]; + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]?.sessionId).toBe(existingSessionId); - // Verify boot message uses the existing session - expect(call?.sessionId).toBe(existingSessionId); - expect(call?.sessionKey).toBe(sessionKey); + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); - // The agent command should append to the existing session's JSONL file - // (actual file append is handled by agentCommand, we just verify the IDs match) + it("removes a boot-created main-session mapping when none existed before", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), "health check", "utf-8"); + + const cfg = {}; + const { sessionKey, storePath } = resolveMainStore(cfg); + + agentCommand.mockImplementation(async (opts: { sessionId?: string }) => { + const current = loadSessionStore(storePath, { skipCache: true }); + current[sessionKey] = { + sessionId: String(opts.sessionId), + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, current); + }); + + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({ + status: "ran", + }); + + const restored = loadSessionStore(storePath, { skipCache: true }); + expect(restored[sessionKey]).toBeUndefined(); await fs.rm(workspaceDir, { recursive: true, force: true }); }); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index 7017811a0b6..e9486eac32a 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -3,12 +3,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { agentCommand } from "../commands/agent.js"; -import { resolveMainSessionKey } from "../config/sessions/main-session.js"; -import { resolveAgentIdFromSessionKey } from "../config/sessions/main-session.js"; +import { + resolveAgentIdFromSessionKey, + resolveMainSessionKey, +} from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; -import { loadSessionStore } from "../config/sessions/store.js"; +import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; @@ -19,38 +22,13 @@ function generateBootSessionId(): string { return `boot-${ts}-${suffix}`; } -/** - * Resolve the session ID for the boot message. - * If there's an existing session mapped to the main session key, reuse it to avoid orphaning. - * Otherwise, generate a new ephemeral boot session ID. - */ -function resolveBootSessionId(cfg: OpenClawConfig): string { - const sessionKey = resolveMainSessionKey(cfg); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const storePath = resolveStorePath(cfg.session?.store, { agentId }); - - try { - const sessionStore = loadSessionStore(storePath); - const existingEntry = sessionStore[sessionKey]; - - if (existingEntry?.sessionId) { - log.info("reusing existing session for boot message", { - sessionKey, - sessionId: existingEntry.sessionId, - }); - return existingEntry.sessionId; - } - } catch (err) { - // If we can't load the session store (e.g., first boot), fall through to generate new ID - log.debug("could not load session store for boot; generating new session ID", { - error: String(err), - }); - } - - const newSessionId = generateBootSessionId(); - log.info("generating new boot session", { sessionKey, sessionId: newSessionId }); - return newSessionId; -} +type SessionMappingSnapshot = { + storePath: string; + sessionKey: string; + canRestore: boolean; + hadEntry: boolean; + entry?: SessionEntry; +}; const log = createSubsystemLogger("gateway/boot"); const BOOT_FILENAME = "BOOT.md"; @@ -94,6 +72,68 @@ async function loadBootFile( } } +function snapshotMainSessionMapping(params: { + cfg: OpenClawConfig; + sessionKey: string; +}): SessionMappingSnapshot { + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); + try { + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[params.sessionKey]; + if (!entry) { + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: false, + }; + } + return { + storePath, + sessionKey: params.sessionKey, + canRestore: true, + hadEntry: true, + entry: structuredClone(entry), + }; + } catch (err) { + log.debug("boot: could not snapshot main session mapping", { + sessionKey: params.sessionKey, + error: String(err), + }); + return { + storePath, + sessionKey: params.sessionKey, + canRestore: false, + hadEntry: false, + }; + } +} + +async function restoreMainSessionMapping( + snapshot: SessionMappingSnapshot, +): Promise { + if (!snapshot.canRestore) { + return undefined; + } + try { + await updateSessionStore( + snapshot.storePath, + (store) => { + if (snapshot.hadEntry && snapshot.entry) { + store[snapshot.sessionKey] = snapshot.entry; + return; + } + delete store[snapshot.sessionKey]; + }, + { activeSessionKey: snapshot.sessionKey }, + ); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -119,8 +159,13 @@ export async function runBootOnce(params: { const sessionKey = resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); - const sessionId = resolveBootSessionId(params.cfg); + const sessionId = generateBootSessionId(); + const mappingSnapshot = snapshotMainSessionMapping({ + cfg: params.cfg, + sessionKey, + }); + let agentFailure: string | undefined; try { await agentCommand( { @@ -132,10 +177,22 @@ export async function runBootOnce(params: { bootRuntime, params.deps, ); - return { status: "ran" }; } catch (err) { - const messageText = err instanceof Error ? err.message : String(err); - log.error(`boot: agent run failed: ${messageText}`); - return { status: "failed", reason: messageText }; + agentFailure = err instanceof Error ? err.message : String(err); + log.error(`boot: agent run failed: ${agentFailure}`); } + + const mappingRestoreFailure = await restoreMainSessionMapping(mappingSnapshot); + if (mappingRestoreFailure) { + log.error(`boot: failed to restore main session mapping: ${mappingRestoreFailure}`); + } + + if (!agentFailure && !mappingRestoreFailure) { + return { status: "ran" }; + } + const reasonParts = [ + agentFailure ? `agent run failed: ${agentFailure}` : undefined, + mappingRestoreFailure ? `mapping restore failed: ${mappingRestoreFailure}` : undefined, + ].filter((part): part is string => Boolean(part)); + return { status: "failed", reason: reasonParts.join("; ") }; } From c07036e8134f93dd97ef33d3dafed71635dbcccb Mon Sep 17 00:00:00 2001 From: cpojer Date: Mon, 16 Feb 2026 09:02:56 +0900 Subject: [PATCH 083/178] chore: Update deps. --- package.json | 4 +- pnpm-lock.yaml | 148 ++++++++++++++++++++++++------------------------- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/package.json b/package.json index c85cc08b2ba..6444e9787a1 100644 --- a/package.json +++ b/package.json @@ -177,13 +177,13 @@ "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260214.1", + "@typescript/native-preview": "7.0.0-dev.20260215.1", "@vitest/coverage-v8": "^4.0.18", "lit": "^3.3.2", "ollama": "^0.6.3", "oxfmt": "0.32.0", "oxlint": "^1.47.0", - "oxlint-tsgolint": "^0.12.2", + "oxlint-tsgolint": "^0.13.0", "rolldown": "1.0.0-rc.4", "tsdown": "^0.20.3", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d8eb48939c..4f53e6e729e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,8 +207,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260214.1 - version: 7.0.0-dev.20260214.1 + specifier: 7.0.0-dev.20260215.1 + version: 7.0.0-dev.20260215.1 '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -223,16 +223,16 @@ importers: version: 0.32.0 oxlint: specifier: ^1.47.0 - version: 1.47.0(oxlint-tsgolint@0.12.2) + version: 1.47.0(oxlint-tsgolint@0.13.0) oxlint-tsgolint: - specifier: ^0.12.2 - version: 0.12.2 + specifier: ^0.13.0 + version: 0.13.0 rolldown: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 tsdown: specifier: ^0.20.3 - version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3) + version: 0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -783,8 +783,8 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.0-rc.1': - resolution: {integrity: sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw==} + '@babel/helper-string-parser@8.0.0-rc.2': + resolution: {integrity: sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==} engines: {node: ^20.19.0 || >=22.12.0} '@babel/helper-validator-identifier@7.28.5': @@ -2059,33 +2059,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.12.2': - resolution: {integrity: sha512-XIfavTqkJPGYi/98z7ZCkZvXq2AccMAAB0iwvKDRTQqiweMXVUyeUdx46phCHHH1PgmIVJtVfysThkHq2xCyrw==} + '@oxlint-tsgolint/darwin-arm64@0.13.0': + resolution: {integrity: sha512-OWQ3U+oDjjupmX0WU9oYyKF2iUOKDMLW/+zan0cd0vYIGId80xTRHHA8oXnREmK8dsMMP3nV3VXME3NH/hS0lw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.12.2': - resolution: {integrity: sha512-tytsvP6zmNShRNDo4GgQartOXmd4GPd+TylCUMdO/iWl9PZVOgRyswWbYVTNgn85Cib/aY2q3Uu+jOw+QlbxvQ==} + '@oxlint-tsgolint/darwin-x64@0.13.0': + resolution: {integrity: sha512-wZvgj+eVqNkCUjSq2ExlMdbGDpZfaw6J+YctQV1pkGFdn7Y9cySWdfwu5v/AW2JPsJbFMXJ8GAr+WoZbRapz2A==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.12.2': - resolution: {integrity: sha512-3W38yJuF7taEquhEuD6mYQyCeWNAlc1pNPjFkspkhLKZVgbrhDA4V6fCxLDDRvrTHde0bXPmFvuPlUq5pSePgA==} + '@oxlint-tsgolint/linux-arm64@0.13.0': + resolution: {integrity: sha512-nwtf5BgHbAWSVwyIF00l6QpfyFcpDMp6D+3cpe6NTgBYMSSSC0Ip1gswUwzVccOPoQK48t+J6vHyURQ96M1KDg==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.12.2': - resolution: {integrity: sha512-EjcEspeeV0NmaopEp4wcN5ntQP9VCJJDrTvzOjMP4W6ajz18M+pni9vkKvmcPIpRa/UmWobeFgKoVd/KGueeuQ==} + '@oxlint-tsgolint/linux-x64@0.13.0': + resolution: {integrity: sha512-Rkzgj38eVoGSBuGDaCrALS4FM19+m1Qlv0hjB4MWvXUej014XkB5ze+svYE3HX+AAm1ey9QYj/CQzfz203FPIg==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.12.2': - resolution: {integrity: sha512-a9L7iA5K/Ht/i8d9+7RTp6hbPa4cyXP0MdySVXAO6vczpL/4ildfY9Hr2m2wqL12uK6xe/uVABpVTrqay/wV+g==} + '@oxlint-tsgolint/win32-arm64@0.13.0': + resolution: {integrity: sha512-Y+0hFqLT5M7UIvGvTR3QFK27l17FqXk6UwwpBFOcyBGJ5bLd1RaAPWjqTmcgPvdolA6FCMeW1pxZuNtKDlYd7A==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.12.2': - resolution: {integrity: sha512-Cvt40UbTf5ib12DjGN+mMGOnjWa4Bc6Y7KEaXXp9qzckvs3HpNk2wSwMV3gnuR8Ipx4hkzkzrgzD0BAUsySAfA==} + '@oxlint-tsgolint/win32-x64@0.13.0': + resolution: {integrity: sha512-mXjTttzyyfl8d/XvxggmZFBq0pbQmRvHbjQEv70YECNaLEHG8j8WYUwLa641uudAnV1VoBI34pc7bmgJM7qhOA==} cpu: [x64] os: [win32] @@ -2995,43 +2995,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-Jb2WcLGpTOC6x58e8QPYC/14xmDbnbFIuKqUvYoI77hVtojVyxZi8L5Y4CgYqXYx8vRWmIFk35c1OGdtPip6Sg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-icVO/hEMXjWlKhmpjIpqDyCzPvtHqfrPB+2rkd6M3rz84Bmw+o8Xgd7JvRxryZhR+D0y55me/bKh9xgvsgzuhA==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-O9l2gVuQFZsb8NIQtu0HN5Tn/Hw2fwylPOPS/0Y4oW+FUMhkqtvetUkb3zZ0qj7capilZ4YnmyGYg3TDqkP4Nw==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-Wz73wf1o9+4KwCLg8wnnIZZDAvv2KRZlDyP4X8GfBNzajfIAwYvI0ANWuIDznUUGeDAcqhBJXNe0Bkf4H9y4mg==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-Hl4e3yxJqzIGgFI8aH/rLGW+a7kSLHJCpAd5JOLG7hHKnamZF4SjlunnoHLV4IcMri+G6UE3W/84i0QvQP5wLA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-AYyXRxVwLZzfkEYN8FGdV4vqXwbTmv93nAZ6gMLvpDG4ItOybAE1R2obFjlFc+Or/rfQmVvfdkTym3c4bRJ3XQ==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-TaFrVnx3iXtl/oH1hzwvFyqWj9tzkjW8Ufl2m0Vx2/7GXnzZadm2KA6tFpGbzzWbZJznmXxKHL4O3AZRQYyZqQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-6WVXFVSp3LBBiBgBMtAHQgTDN72mDhgjrmXH7GoABTxR9asK8oPfmy5cwTp1sPD46pYhqjnSHMrARyg2FaNSeA==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-a/JypIXTc/tdodhYdQm24WH6aTfnJJjDbwxce4BS2g6IzYSc2GFcZBvlq1CJYS2FAVLpiSxj0OFAZmgjpCDAKg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-Ui6qbTO+nE7fwh5OGTGfL4ndaT+SpiUiv0F1m3+nMaiAKysY5GbgXUfzWzkSrOODsT8F/4jZ4wCzEzJordt8sQ==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-MJGPEDvdXj8olcWH0P+cWYcaN4r/0J4aSbcaISlen3MZ/2hrrgNl46PV4eGJKKCDniY2pH2fJzrMyJWZOcdb0w==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-dBFyAH9h3bMUaIp/84c3gKwyQ6jQmtzVoIBamSrYNw0xinJ56A/Ln5igdNOYrH8+/Aofmeh7pAWaa8U456XMjw==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-BtF48TRUyiCKznlOcQ7r7EXhonGSanm9X2eu7d8Yq1vaWO5SDgB0e+ISQXSoIfs3a1S3d5S5QV/vTE4+vocPxA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-bEMSwX71OGGvfsfHEa/aX7ZUWbPSI2oKEmeWcDQVY8vH1VK1ZwcFzMhKfgVJPt5pKH2bK3EO3xYnAyKkDO/Ung==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260214.1': - resolution: {integrity: sha512-BDM0ZLf2v6ilR0tDi8OMEr4X08lFCToPk3/p1SSE4GhagzmlU/5b+9slR0kKtaKMrds01FhvaKx6U9+NmAWgbQ==} + '@typescript/native-preview@7.0.0-dev.20260215.1': + resolution: {integrity: sha512-grs0BbJyPR7VLNerBVteEToPku1InMKVKVKBUTJi19LfK+LU3+pkU6/fsTfZhH3xmIzIxD/sNRQHLt4x/Yb9yg==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -4728,8 +4728,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.12.2: - resolution: {integrity: sha512-IFiOhYZfSgiHbBznTZOhFpEHpsZFSP0j7fVRake03HEkgH0YljnTFDNoRkGWsTrnrHr7nRIomSsF4TnCI/O+kQ==} + oxlint-tsgolint@0.13.0: + resolution: {integrity: sha512-VUOWP5T9R9RwuPLKvNgvhsjdPFVhr2k8no8ea84+KhDtYPmk9L/3StNP3WClyPOKJOT8bFlO3eyhTKxXK9+Oog==} hasBin: true oxlint@1.47.0: @@ -6285,7 +6285,7 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@8.0.0-rc.1': {} + '@babel/helper-string-parser@8.0.0-rc.2': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -6308,7 +6308,7 @@ snapshots: '@babel/types@8.0.0-rc.1': dependencies: - '@babel/helper-string-parser': 8.0.0-rc.1 + '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.1 '@bcoe/v8-coverage@1.0.2': {} @@ -7546,22 +7546,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.32.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.12.2': + '@oxlint-tsgolint/darwin-arm64@0.13.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.12.2': + '@oxlint-tsgolint/darwin-x64@0.13.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.12.2': + '@oxlint-tsgolint/linux-arm64@0.13.0': optional: true - '@oxlint-tsgolint/linux-x64@0.12.2': + '@oxlint-tsgolint/linux-x64@0.13.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.12.2': + '@oxlint-tsgolint/win32-arm64@0.13.0': optional: true - '@oxlint-tsgolint/win32-x64@0.12.2': + '@oxlint-tsgolint/win32-x64@0.13.0': optional: true '@oxlint/binding-android-arm-eabi@1.47.0': @@ -8473,36 +8473,36 @@ snapshots: dependencies: '@types/node': 25.2.3 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260215.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260214.1': + '@typescript/native-preview@7.0.0-dev.20260215.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260214.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260214.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260215.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260215.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -10385,16 +10385,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.32.0 '@oxfmt/binding-win32-x64-msvc': 0.32.0 - oxlint-tsgolint@0.12.2: + oxlint-tsgolint@0.13.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.12.2 - '@oxlint-tsgolint/darwin-x64': 0.12.2 - '@oxlint-tsgolint/linux-arm64': 0.12.2 - '@oxlint-tsgolint/linux-x64': 0.12.2 - '@oxlint-tsgolint/win32-arm64': 0.12.2 - '@oxlint-tsgolint/win32-x64': 0.12.2 + '@oxlint-tsgolint/darwin-arm64': 0.13.0 + '@oxlint-tsgolint/darwin-x64': 0.13.0 + '@oxlint-tsgolint/linux-arm64': 0.13.0 + '@oxlint-tsgolint/linux-x64': 0.13.0 + '@oxlint-tsgolint/win32-arm64': 0.13.0 + '@oxlint-tsgolint/win32-x64': 0.13.0 - oxlint@1.47.0(oxlint-tsgolint@0.12.2): + oxlint@1.47.0(oxlint-tsgolint@0.13.0): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.47.0 '@oxlint/binding-android-arm64': 1.47.0 @@ -10415,7 +10415,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.47.0 '@oxlint/binding-win32-ia32-msvc': 1.47.0 '@oxlint/binding-win32-x64-msvc': 1.47.0 - oxlint-tsgolint: 0.12.2 + oxlint-tsgolint: 0.13.0 p-finally@1.0.0: {} @@ -10791,7 +10791,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -10804,7 +10804,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260214.1 + '@typescript/native-preview': 7.0.0-dev.20260215.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -11269,7 +11269,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260214.1)(typescript@5.9.3): + tsdown@0.20.3(@typescript/native-preview@7.0.0-dev.20260215.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -11280,7 +11280,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260214.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(@typescript/native-preview@7.0.0-dev.20260215.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 From e075a33ca3d408b06ae86654aa4c34f820d91bb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:03:45 +0000 Subject: [PATCH 084/178] refactor(test): simplify oauth/profile env restore --- src/commands/status.e2e.test.ts | 11 ++++------- src/web/accounts.whatsapp-auth.test.ts | 11 ++++------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/commands/status.e2e.test.ts b/src/commands/status.e2e.test.ts index f3957a41c07..ae866bbd2ac 100644 --- a/src/commands/status.e2e.test.ts +++ b/src/commands/status.e2e.test.ts @@ -1,18 +1,15 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; -let previousProfile: string | undefined; +let envSnapshot: ReturnType; beforeAll(() => { - previousProfile = process.env.OPENCLAW_PROFILE; + envSnapshot = captureEnv(["OPENCLAW_PROFILE"]); process.env.OPENCLAW_PROFILE = "isolated"; }); afterAll(() => { - if (previousProfile === undefined) { - delete process.env.OPENCLAW_PROFILE; - } else { - process.env.OPENCLAW_PROFILE = previousProfile; - } + envSnapshot.restore(); }); const mocks = vi.hoisted(() => ({ diff --git a/src/web/accounts.whatsapp-auth.test.ts b/src/web/accounts.whatsapp-auth.test.ts index c63ae12e56a..89dac3977cc 100644 --- a/src/web/accounts.whatsapp-auth.test.ts +++ b/src/web/accounts.whatsapp-auth.test.ts @@ -2,10 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; describe("hasAnyWhatsAppAuth", () => { - let previousOauthDir: string | undefined; + let envSnapshot: ReturnType; let tempOauthDir: string | undefined; const writeCreds = (dir: string) => { @@ -14,17 +15,13 @@ describe("hasAnyWhatsAppAuth", () => { }; beforeEach(() => { - previousOauthDir = process.env.OPENCLAW_OAUTH_DIR; + envSnapshot = captureEnv(["OPENCLAW_OAUTH_DIR"]); tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-")); process.env.OPENCLAW_OAUTH_DIR = tempOauthDir; }); afterEach(() => { - if (previousOauthDir === undefined) { - delete process.env.OPENCLAW_OAUTH_DIR; - } else { - process.env.OPENCLAW_OAUTH_DIR = previousOauthDir; - } + envSnapshot.restore(); if (tempOauthDir) { fs.rmSync(tempOauthDir, { recursive: true, force: true }); tempOauthDir = undefined; From 997b9ad232e1f5b5e50ec5aba610e90c5e432cf1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:05:02 +0000 Subject: [PATCH 085/178] refactor(test): dedupe provider api key env restore --- .../models-config.providers.minimax.test.ts | 9 +++------ .../models-config.providers.nvidia.test.ts | 17 +++++------------ 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts index 7832e483bce..94b7994a6cd 100644 --- a/src/agents/models-config.providers.minimax.test.ts +++ b/src/agents/models-config.providers.minimax.test.ts @@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; describe("MiniMax implicit provider (#15275)", () => { it("should use anthropic-messages API for API-key provider", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.MINIMAX_API_KEY; + const envSnapshot = captureEnv(["MINIMAX_API_KEY"]); process.env.MINIMAX_API_KEY = "test-key"; try { @@ -16,11 +17,7 @@ describe("MiniMax implicit provider (#15275)", () => { expect(providers?.minimax?.api).toBe("anthropic-messages"); expect(providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic"); } finally { - if (previous === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = previous; - } + envSnapshot.restore(); } }); }); diff --git a/src/agents/models-config.providers.nvidia.test.ts b/src/agents/models-config.providers.nvidia.test.ts index 42a46ebe4a1..a9920a3cba2 100644 --- a/src/agents/models-config.providers.nvidia.test.ts +++ b/src/agents/models-config.providers.nvidia.test.ts @@ -2,13 +2,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveApiKeyForProvider } from "./model-auth.js"; import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js"; describe("NVIDIA provider", () => { it("should include nvidia when NVIDIA_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "test-key"; try { @@ -16,17 +17,13 @@ describe("NVIDIA provider", () => { expect(providers?.nvidia).toBeDefined(); expect(providers?.nvidia?.models?.length).toBeGreaterThan(0); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); it("resolves the nvidia api key value from env", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.NVIDIA_API_KEY; + const envSnapshot = captureEnv(["NVIDIA_API_KEY"]); process.env.NVIDIA_API_KEY = "nvidia-test-api-key"; try { @@ -39,11 +36,7 @@ describe("NVIDIA provider", () => { expect(auth.mode).toBe("api-key"); expect(auth.source).toContain("NVIDIA_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.NVIDIA_API_KEY; - } else { - process.env.NVIDIA_API_KEY = previous; - } + envSnapshot.restore(); } }); From 4bdb857ecadce2b4bd1f7858b116f561e6d5fcac Mon Sep 17 00:00:00 2001 From: cpojer Date: Mon, 16 Feb 2026 09:06:51 +0900 Subject: [PATCH 086/178] chore: Use proper pnpm caching in one CI step. --- .github/workflows/install-smoke.yml | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index e6c0914f018..61861a84be9 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -33,19 +33,17 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup pnpm (corepack retry) - run: | - set -euo pipefail - corepack enable - for attempt in 1 2 3; do - if corepack prepare pnpm@10.23.0 --activate; then - pnpm -v - exit 0 - fi - echo "corepack prepare failed (attempt $attempt/3). Retrying..." - sleep $((attempt * 10)) - done - exit 1 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + check-latest: true + + - name: Setup pnpm + cache store + uses: ./.github/actions/setup-pnpm-store-cache + with: + pnpm-version: "10.23.0" + cache-key-suffix: "node22" - name: Install pnpm deps (minimal) run: pnpm install --ignore-scripts --frozen-lockfile From cedd520f257298acfbdfceb3d44a458a65f0cddc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:07:45 +0000 Subject: [PATCH 087/178] refactor(test): simplify state dir env helpers --- .../oauth.fallback-to-main-agent.e2e.test.ts | 26 +++++-------------- src/agents/pi-tools.safe-bins.e2e.test.ts | 19 +++++--------- src/test-helpers/state-dir-env.ts | 25 ++++-------------- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts index 9379d387913..ea15d462f01 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.e2e.test.ts @@ -3,13 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "./types.js"; +import { captureEnv } from "../../test-utils/env.js"; import { resolveApiKeyForProfile } from "./oauth.js"; import { ensureAuthProfileStore } from "./store.js"; describe("resolveApiKeyForProfile fallback to main agent", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const envSnapshot = captureEnv([ + "OPENCLAW_STATE_DIR", + "OPENCLAW_AGENT_DIR", + "PI_CODING_AGENT_DIR", + ]); let tmpDir: string; let mainAgentDir: string; let secondaryAgentDir: string; @@ -30,22 +33,7 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { afterEach(async () => { vi.unstubAllGlobals(); - // Restore original environment - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - if (previousAgentDir === undefined) { - delete process.env.OPENCLAW_AGENT_DIR; - } else { - process.env.OPENCLAW_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } + envSnapshot.restore(); await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/src/agents/pi-tools.safe-bins.e2e.test.ts b/src/agents/pi-tools.safe-bins.e2e.test.ts index 665059035d2..d9c5c2bc64b 100644 --- a/src/agents/pi-tools.safe-bins.e2e.test.ts +++ b/src/agents/pi-tools.safe-bins.e2e.test.ts @@ -4,8 +4,9 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { captureEnv } from "../test-utils/env.js"; -const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]); beforeAll(() => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join( @@ -15,11 +16,7 @@ beforeAll(() => { }); afterAll(() => { - if (previousBundledPluginsDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; - } + bundledPluginsDirSnapshot.restore(); }); vi.mock("../infra/shell-env.js", async (importOriginal) => { @@ -109,20 +106,16 @@ describe("createOpenClawCodingTools safeBins", () => { expect(execTool).toBeDefined(); const marker = `safe-bins-${Date.now()}`; - const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; + const envSnapshot = captureEnv(["OPENCLAW_SHELL_ENV_TIMEOUT_MS"]); const result = await (async () => { try { + process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; return await execTool!.execute("call1", { command: `echo ${marker}`, workdir: tmpDir, }); } finally { - if (prevShellEnvTimeoutMs === undefined) { - delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; - } else { - process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs; - } + envSnapshot.restore(); } })(); const text = result.content.find((content) => content.type === "text")?.text ?? ""; diff --git a/src/test-helpers/state-dir-env.ts b/src/test-helpers/state-dir-env.ts index 3561c27af77..235cbca1c4e 100644 --- a/src/test-helpers/state-dir-env.ts +++ b/src/test-helpers/state-dir-env.ts @@ -1,26 +1,11 @@ -type StateDirEnvSnapshot = { - openclawStateDir: string | undefined; - clawdbotStateDir: string | undefined; -}; +import { captureEnv } from "../test-utils/env.js"; -export function snapshotStateDirEnv(): StateDirEnvSnapshot { - return { - openclawStateDir: process.env.OPENCLAW_STATE_DIR, - clawdbotStateDir: process.env.CLAWDBOT_STATE_DIR, - }; +export function snapshotStateDirEnv() { + return captureEnv(["OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"]); } -export function restoreStateDirEnv(snapshot: StateDirEnvSnapshot): void { - if (snapshot.openclawStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = snapshot.openclawStateDir; - } - if (snapshot.clawdbotStateDir === undefined) { - delete process.env.CLAWDBOT_STATE_DIR; - } else { - process.env.CLAWDBOT_STATE_DIR = snapshot.clawdbotStateDir; - } +export function restoreStateDirEnv(snapshot: ReturnType): void { + snapshot.restore(); } export function setStateDirEnv(stateDir: string): void { From 7857096d297ec8b792f8bf0e3f7d713d9f15439b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:08:35 +0000 Subject: [PATCH 088/178] refactor(test): reuse env snapshot in model scan --- src/agents/model-scan.e2e.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/agents/model-scan.e2e.test.ts b/src/agents/model-scan.e2e.test.ts index 574ad51224a..59f50861ad6 100644 --- a/src/agents/model-scan.e2e.test.ts +++ b/src/agents/model-scan.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { scanOpenRouterModels } from "./model-scan.js"; function createFetchFixture(payload: unknown): typeof fetch { @@ -66,7 +67,7 @@ describe("scanOpenRouterModels", () => { it("requires an API key when probing", async () => { const fetchImpl = createFetchFixture({ data: [] }); - const previousKey = process.env.OPENROUTER_API_KEY; + const envSnapshot = captureEnv(["OPENROUTER_API_KEY"]); try { delete process.env.OPENROUTER_API_KEY; await expect( @@ -77,11 +78,7 @@ describe("scanOpenRouterModels", () => { }), ).rejects.toThrow(/Missing OpenRouter API key/); } finally { - if (previousKey === undefined) { - delete process.env.OPENROUTER_API_KEY; - } else { - process.env.OPENROUTER_API_KEY = previousKey; - } + envSnapshot.restore(); } }); }); From e3a93d6705988c0fb7a8cccfca4310f87cdc5cd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:12:23 +0000 Subject: [PATCH 089/178] refactor(test): dedupe safe-bins mocks --- src/agents/pi-tools.safe-bins.e2e.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/agents/pi-tools.safe-bins.e2e.test.ts b/src/agents/pi-tools.safe-bins.e2e.test.ts index d9c5c2bc64b..f022a84abc1 100644 --- a/src/agents/pi-tools.safe-bins.e2e.test.ts +++ b/src/agents/pi-tools.safe-bins.e2e.test.ts @@ -23,21 +23,11 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - getShellPathFromLoginShell: vi.fn(() => "/usr/bin:/bin"), + getShellPathFromLoginShell: vi.fn(() => null), resolveShellEnvFallbackTimeoutMs: vi.fn(() => 500), }; }); -vi.mock("../plugins/tools.js", () => ({ - getPluginToolMeta: () => undefined, - resolvePluginTools: () => [], -})); - -vi.mock("../infra/shell-env.js", async (importOriginal) => { - const mod = await importOriginal(); - return { ...mod, getShellPathFromLoginShell: () => null }; -}); - vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: () => [], getPluginToolMeta: () => undefined, From ab000bc4110980c32e5f26045872bcacc4ef33d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 00:13:01 +0000 Subject: [PATCH 090/178] refactor(test): dedupe qianfan env restore --- src/agents/models-config.providers.qianfan.e2e.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/agents/models-config.providers.qianfan.e2e.test.ts b/src/agents/models-config.providers.qianfan.e2e.test.ts index 17527262897..06f47787464 100644 --- a/src/agents/models-config.providers.qianfan.e2e.test.ts +++ b/src/agents/models-config.providers.qianfan.e2e.test.ts @@ -2,12 +2,13 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; describe("Qianfan provider", () => { it("should include qianfan when QIANFAN_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const previous = process.env.QIANFAN_API_KEY; + const envSnapshot = captureEnv(["QIANFAN_API_KEY"]); process.env.QIANFAN_API_KEY = "test-key"; try { @@ -15,11 +16,7 @@ describe("Qianfan provider", () => { expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); } finally { - if (previous === undefined) { - delete process.env.QIANFAN_API_KEY; - } else { - process.env.QIANFAN_API_KEY = previous; - } + envSnapshot.restore(); } }); }); From 115cfb4430d0526ea64f7a9f5f77c3d81da75e3e Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Sun, 15 Feb 2026 16:14:17 -0800 Subject: [PATCH 091/178] gateway: add cron finished-run webhook (#14535) * gateway: add cron finished webhook delivery * config: allow cron webhook in runtime schema * cron: require notify flag for webhook posts * ui/docs: add cron notify toggle and webhook docs * fix: harden cron webhook auth and fill notify coverage (#14535) (thanks @advaitpaliwal) --------- Co-authored-by: Tyler Yust --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 8 + .../OpenClawProtocol/GatewayModels.swift | 8 + docs/automation/cron-jobs.md | 12 +- docs/gateway/configuration-reference.md | 4 + docs/web/control-ui.md | 3 + src/agents/tools/cron-tool.ts | 5 +- src/config/config.cron-webhook-schema.test.ts | 26 +++ src/config/types.cron.ts | 2 + src/config/zod-schema.ts | 10 ++ src/cron/service.get-job.test.ts | 87 ++++++++++ src/cron/service.jobs.test.ts | 20 +++ src/cron/service.ts | 6 +- src/cron/service/jobs.ts | 4 + src/cron/types.ts | 1 + src/gateway/protocol/schema/cron.ts | 3 + src/gateway/server-cron.ts | 45 ++++++ src/gateway/server.cron.e2e.test.ts | 149 +++++++++++++++++- ui/src/ui/app-defaults.ts | 1 + ui/src/ui/controllers/cron.test.ts | 63 ++++++++ ui/src/ui/controllers/cron.ts | 1 + ui/src/ui/types.ts | 1 + ui/src/ui/ui-types.ts | 1 + ui/src/ui/views/cron.test.ts | 46 ++++++ ui/src/ui/views/cron.ts | 16 ++ 25 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 src/config/config.cron-webhook-schema.test.ts create mode 100644 src/cron/service.get-job.test.ts create mode 100644 ui/src/ui/controllers/cron.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index caadbd0ef9b..4179961a1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. - Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..31763115ae0 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool + public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? + public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 29a4059b334..31763115ae0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2087,6 +2087,7 @@ public struct CronJob: Codable, Sendable { public let name: String public let description: String? public let enabled: Bool + public let notify: Bool? public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int @@ -2103,6 +2104,7 @@ public struct CronJob: Codable, Sendable { name: String, description: String?, enabled: Bool, + notify: Bool?, deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, @@ -2118,6 +2120,7 @@ public struct CronJob: Codable, Sendable { self.name = name self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms @@ -2134,6 +2137,7 @@ public struct CronJob: Codable, Sendable { case name case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -2167,6 +2171,7 @@ public struct CronAddParams: Codable, Sendable { public let agentid: AnyCodable? public let description: String? public let enabled: Bool? + public let notify: Bool? public let deleteafterrun: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable @@ -2179,6 +2184,7 @@ public struct CronAddParams: Codable, Sendable { agentid: AnyCodable?, description: String?, enabled: Bool?, + notify: Bool?, deleteafterrun: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -2190,6 +2196,7 @@ public struct CronAddParams: Codable, Sendable { self.agentid = agentid self.description = description self.enabled = enabled + self.notify = notify self.deleteafterrun = deleteafterrun self.schedule = schedule self.sessiontarget = sessiontarget @@ -2202,6 +2209,7 @@ public struct CronAddParams: Codable, Sendable { case agentid = "agentId" case description case enabled + case notify case deleteafterrun = "deleteAfterRun" case schedule case sessiontarget = "sessionTarget" diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b1e5ef9a10c..82d66c23e7c 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -27,6 +27,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - **Main session**: enqueue a system event, then run on the next heartbeat. - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. +- Webhook posting is opt-in per job: set `notify: true` and configure `cron.webhook`. ## Quick start (actionable) @@ -288,7 +289,7 @@ Notes: - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. - `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. -- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), +- Optional fields: `agentId`, `description`, `enabled`, `notify`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. @@ -333,10 +334,19 @@ Notes: enabled: true, // default true store: "~/.openclaw/cron/jobs.json", maxConcurrentRuns: 1, // default 1 + webhook: "https://example.invalid/cron-finished", // optional finished-run webhook endpoint + webhookToken: "replace-with-dedicated-webhook-token", // optional, do not reuse gateway auth token }, } ``` +Webhook behavior: + +- The Gateway posts finished run events to `cron.webhook` only when the job has `notify: true`. +- Payload is the cron finished event JSON. +- If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. +- If `cron.webhookToken` is not set, no `Authorization` header is sent. + Disable cron entirely: - `cron.enabled: false` (config) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index eeb1eaea7b5..d94551ca81f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2295,12 +2295,16 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway cron: { enabled: true, maxConcurrentRuns: 2, + webhook: "https://example.invalid/cron-finished", // optional, must be http:// or https:// + webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth sessionRetention: "24h", // duration string or false }, } ``` - `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`. +- `webhook`: finished-run webhook endpoint, only used when the job has `notify: true`. +- `webhookToken`: dedicated bearer token for webhook auth, if omitted no auth header is sent. See [Cron Jobs](/automation/cron-jobs). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 233a67c48b0..1c6e5ea57c5 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -83,6 +83,9 @@ Cron jobs panel notes: - For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs. - Channel/target fields appear when announce is selected. +- New job form includes a **Notify webhook** toggle (`notify` on the job). +- Gateway webhook posting requires both `notify: true` on the job and `cron.webhook` in config. +- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. ## Chat behavior diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index d69bf949796..6bc57e386d1 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -219,7 +219,8 @@ JOB SCHEMA (for add action): "payload": { ... }, // Required: what to execute "delivery": { ... }, // Optional: announce summary (isolated only) "sessionTarget": "main" | "isolated", // Required - "enabled": true | false // Optional, default true + "enabled": true | false, // Optional, default true + "notify": true | false // Optional webhook opt-in; set true for user-facing reminders } SCHEDULE TYPES (schedule.kind): @@ -246,6 +247,7 @@ DELIVERY (isolated-only, top-level): CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" +- For reminders users should be notified about, set notify=true. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): @@ -292,6 +294,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con "payload", "delivery", "enabled", + "notify", "description", "deleteAfterRun", "agentId", diff --git a/src/config/config.cron-webhook-schema.test.ts b/src/config/config.cron-webhook-schema.test.ts new file mode 100644 index 00000000000..e6f64bf5890 --- /dev/null +++ b/src/config/config.cron-webhook-schema.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("cron webhook schema", () => { + it("accepts cron.webhook and cron.webhookToken", () => { + const res = OpenClawSchema.safeParse({ + cron: { + enabled: true, + webhook: "https://example.invalid/cron", + webhookToken: "secret-token", + }, + }); + + expect(res.success).toBe(true); + }); + + it("rejects non-http(s) cron.webhook URLs", () => { + const res = OpenClawSchema.safeParse({ + cron: { + webhook: "ftp://example.invalid/cron", + }, + }); + + expect(res.success).toBe(false); + }); +}); diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 62a9c1da139..d1704b30b12 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -2,6 +2,8 @@ export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; + webhook?: string; + webhookToken?: string; /** * How long to retain completed cron run sessions before automatic pruning. * Accepts a duration string (e.g. "24h", "7d", "1h30m") or `false` to disable pruning. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 7f43b4b1a08..3d718f2f1a5 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -93,6 +93,14 @@ const MemorySchema = z .strict() .optional(); +const HttpUrlSchema = z + .string() + .url() + .refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + }, "Expected http:// or https:// URL"); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -295,6 +303,8 @@ export const OpenClawSchema = z enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), + webhook: HttpUrlSchema.optional(), + webhookToken: z.string().optional().register(sensitive), sessionRetention: z.union([z.string(), z.literal(false)]).optional(), }) .strict() diff --git a/src/cron/service.get-job.test.ts b/src/cron/service.get-job.test.ts new file mode 100644 index 00000000000..6d07189765f --- /dev/null +++ b/src/cron/service.get-job.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { CronService } from "./service.js"; +import { + createCronStoreHarness, + createNoopLogger, + installCronTestHooks, +} from "./service.test-harness.js"; + +const logger = createNoopLogger(); +const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" }); +installCronTestHooks({ logger }); + +function createCronService(storePath: string) { + return new CronService({ + storePath, + cronEnabled: true, + log: logger, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); +} + +describe("CronService.getJob", () => { + it("returns added jobs and undefined for missing ids", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const added = await cron.add({ + name: "lookup-test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(added.id)?.id).toBe(added.id); + expect(cron.getJob("missing-job-id")).toBeUndefined(); + } finally { + cron.stop(); + } + }); + + it("preserves notify on create for true, false, and omitted", async () => { + const { storePath } = await makeStorePath(); + const cron = createCronService(storePath); + await cron.start(); + + try { + const notifyTrue = await cron.add({ + name: "notify-true", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + const notifyFalse = await cron.add({ + name: "notify-false", + enabled: true, + notify: false, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + const notifyOmitted = await cron.add({ + name: "notify-omitted", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }); + + expect(cron.getJob(notifyTrue.id)?.notify).toBe(true); + expect(cron.getJob(notifyFalse.id)?.notify).toBe(false); + expect(cron.getJob(notifyOmitted.id)?.notify).toBeUndefined(); + } finally { + cron.stop(); + } + }); +}); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index b11ca9854b1..edb95f0792a 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -100,4 +100,24 @@ describe("applyJobPatch", () => { bestEffort: undefined, }); }); + + it("updates notify via patch", () => { + const now = Date.now(); + const job: CronJob = { + id: "job-4", + name: "job-4", + enabled: true, + notify: false, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it" }, + state: {}, + }; + + expect(() => applyJobPatch(job, { notify: true })).not.toThrow(); + expect(job.notify).toBe(true); + }); }); diff --git a/src/cron/service.ts b/src/cron/service.ts index 8f82a2e6947..8891ee9915b 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "./types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js"; import * as ops from "./service/ops.js"; import { type CronServiceDeps, createCronServiceState } from "./service/state.js"; @@ -42,6 +42,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + getJob(id: string): CronJob | undefined { + return this.state.store?.jobs.find((job) => job.id === id); + } + wake(opts: { mode: "now" | "next-heartbeat"; text: string }) { return ops.wakeNow(this.state, opts); } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 71a11af7bca..3db5c3ebe58 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -256,6 +256,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo name: normalizeRequiredName(input.name), description: normalizeOptionalText(input.description), enabled, + notify: typeof input.notify === "boolean" ? input.notify : undefined, deleteAfterRun, createdAtMs: now, updatedAtMs: now, @@ -284,6 +285,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) { if (typeof patch.enabled === "boolean") { job.enabled = patch.enabled; } + if (typeof patch.notify === "boolean") { + job.notify = patch.notify; + } if (typeof patch.deleteAfterRun === "boolean") { job.deleteAfterRun = patch.deleteAfterRun; } diff --git a/src/cron/types.ts b/src/cron/types.ts index c3168346fb4..22363851357 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -71,6 +71,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 345690c8327..8772c9195e5 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -119,6 +119,7 @@ export const CronJobSchema = Type.Object( name: NonEmptyString, description: Type.Optional(Type.String()), enabled: Type.Boolean(), + notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), createdAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }), @@ -147,6 +148,7 @@ export const CronAddParamsSchema = Type.Object( agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), + notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), schedule: CronScheduleSchema, sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), @@ -163,6 +165,7 @@ export const CronJobPatchSchema = Type.Object( agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), + notify: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()), schedule: Type.Optional(CronScheduleSchema), sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])), diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 51865d1d0a4..9a5bb40412b 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -20,6 +20,17 @@ export type GatewayCronState = { cronEnabled: boolean; }; +const CRON_WEBHOOK_TIMEOUT_MS = 10_000; + +function redactWebhookUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return ""; + } +} + export function buildGatewayCronService(params: { cfg: ReturnType; deps: CliDeps; @@ -93,6 +104,40 @@ export function buildGatewayCronService(params: { onEvent: (evt) => { params.broadcast("cron", evt, { dropIfSlow: true }); if (evt.action === "finished") { + const webhookUrl = params.cfg.cron?.webhook?.trim(); + const webhookToken = params.cfg.cron?.webhookToken?.trim(); + const job = cron.getJob(evt.jobId); + if (webhookUrl && evt.summary && job?.notify === true) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (webhookToken) { + headers.Authorization = `Bearer ${webhookToken}`; + } + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, CRON_WEBHOOK_TIMEOUT_MS); + void fetch(webhookUrl, { + method: "POST", + headers, + body: JSON.stringify(evt), + signal: abortController.signal, + }) + .catch((err) => { + cronLogger.warn( + { + err: String(err), + jobId: evt.jobId, + webhookUrl: redactWebhookUrl(webhookUrl), + }, + "cron: webhook delivery failed", + ); + }) + .finally(() => { + clearTimeout(timeout); + }); + } const logPath = resolveCronRunLogPath({ storePath, jobId: evt.jobId, diff --git a/src/gateway/server.cron.e2e.test.ts b/src/gateway/server.cron.e2e.test.ts index 94e52d99b4d..682720487d0 100644 --- a/src/gateway/server.cron.e2e.test.ts +++ b/src/gateway/server.cron.e2e.test.ts @@ -1,9 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { connectOk, + cronIsolatedRun, installGatewayTestHooks, rpcReq, startServerWithClient, @@ -50,6 +51,20 @@ async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) { } } +async function waitForCondition(check: () => boolean, timeoutMs = 2000) { + const startedAt = process.hrtime.bigint(); + for (;;) { + if (check()) { + return; + } + const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6; + if (elapsedMs >= timeoutMs) { + throw new Error("timeout waiting for condition"); + } + await yieldToEventLoop(); + } +} + describe("gateway server cron", () => { test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => { const prevSkipCron = process.env.OPENCLAW_SKIP_CRON; @@ -68,6 +83,7 @@ describe("gateway server cron", () => { const addRes = await rpcReq(ws, "cron.add", { name: "daily", enabled: true, + notify: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "next-heartbeat", @@ -84,6 +100,9 @@ describe("gateway server cron", () => { expect(Array.isArray(jobs)).toBe(true); expect((jobs as unknown[]).length).toBe(1); expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily"); + expect( + ((jobs as Array<{ notify?: unknown }>)[0]?.notify as boolean | undefined) ?? false, + ).toBe(true); const routeAtMs = Date.now() - 1; const routeRes = await rpcReq(ws, "cron.add", { @@ -403,4 +422,132 @@ describe("gateway server cron", () => { } } }, 45_000); + + test("posts webhooks only when notify is true and summary exists", async () => { + const prevSkipCron = process.env.OPENCLAW_SKIP_CRON; + process.env.OPENCLAW_SKIP_CRON = "0"; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-webhook-")); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + testState.cronEnabled = false; + await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); + await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] })); + + const configPath = process.env.OPENCLAW_CONFIG_PATH; + expect(typeof configPath).toBe("string"); + await fs.mkdir(path.dirname(configPath as string), { recursive: true }); + await fs.writeFile( + configPath as string, + JSON.stringify( + { + cron: { + webhook: "https://example.invalid/cron-finished", + webhookToken: "cron-webhook-token", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const notifyRes = await rpcReq(ws, "cron.add", { + name: "notify true", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "send webhook" }, + }); + expect(notifyRes.ok).toBe(true); + const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id; + const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : ""; + expect(notifyJobId.length > 0).toBe(true); + + const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000); + expect(notifyRunRes.ok).toBe(true); + + await waitForCondition(() => fetchMock.mock.calls.length === 1, 5000); + const [notifyUrl, notifyInit] = fetchMock.mock.calls[0] as [ + string, + { + method?: string; + headers?: Record; + body?: string; + }, + ]; + expect(notifyUrl).toBe("https://example.invalid/cron-finished"); + expect(notifyInit.method).toBe("POST"); + expect(notifyInit.headers?.Authorization).toBe("Bearer cron-webhook-token"); + expect(notifyInit.headers?.["Content-Type"]).toBe("application/json"); + const notifyBody = JSON.parse(notifyInit.body ?? "{}"); + expect(notifyBody.action).toBe("finished"); + expect(notifyBody.jobId).toBe(notifyJobId); + + const silentRes = await rpcReq(ws, "cron.add", { + name: "notify false", + enabled: true, + notify: false, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "do not send" }, + }); + expect(silentRes.ok).toBe(true); + const silentJobIdValue = (silentRes.payload as { id?: unknown } | null)?.id; + const silentJobId = typeof silentJobIdValue === "string" ? silentJobIdValue : ""; + expect(silentJobId.length > 0).toBe(true); + + const silentRunRes = await rpcReq(ws, "cron.run", { id: silentJobId, mode: "force" }, 20_000); + expect(silentRunRes.ok).toBe(true); + await yieldToEventLoop(); + await yieldToEventLoop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + cronIsolatedRun.mockResolvedValueOnce({ status: "ok" }); + const noSummaryRes = await rpcReq(ws, "cron.add", { + name: "notify no summary", + enabled: true, + notify: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "test" }, + }); + expect(noSummaryRes.ok).toBe(true); + const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id; + const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : ""; + expect(noSummaryJobId.length > 0).toBe(true); + + const noSummaryRunRes = await rpcReq( + ws, + "cron.run", + { id: noSummaryJobId, mode: "force" }, + 20_000, + ); + expect(noSummaryRunRes.ok).toBe(true); + await yieldToEventLoop(); + await yieldToEventLoop(); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + ws.close(); + await server.close(); + await rmTempDir(dir); + vi.unstubAllGlobals(); + testState.cronStorePath = undefined; + testState.cronEnabled = undefined; + if (prevSkipCron === undefined) { + delete process.env.OPENCLAW_SKIP_CRON; + } else { + process.env.OPENCLAW_SKIP_CRON = prevSkipCron; + } + } + }, 60_000); }); diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 89bdaf11d1b..ee394802b09 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -15,6 +15,7 @@ export const DEFAULT_CRON_FORM: CronFormState = { description: "", agentId: "", enabled: true, + notify: false, scheduleKind: "every", scheduleAt: "", everyAmount: "30", diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts new file mode 100644 index 00000000000..aef1a219032 --- /dev/null +++ b/ui/src/ui/controllers/cron.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; +import { addCronJob, type CronState } from "./cron.ts"; + +function createState(overrides: Partial = {}): CronState { + return { + client: null, + connected: true, + cronLoading: false, + cronJobs: [], + cronStatus: null, + cronError: null, + cronForm: { ...DEFAULT_CRON_FORM }, + cronRunsJobId: null, + cronRuns: [], + cronBusy: false, + ...overrides, + }; +} + +describe("cron controller", () => { + it("forwards notify in cron.add payload", async () => { + const request = vi.fn(async (method: string) => { + if (method === "cron.add") { + return { id: "job-1" }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 0, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronForm: { + ...DEFAULT_CRON_FORM, + name: "notify job", + notify: true, + scheduleKind: "every", + everyAmount: "1", + everyUnit: "minutes", + sessionTarget: "main", + wakeMode: "next-heartbeat", + payloadKind: "systemEvent", + payloadText: "ping", + }, + }); + + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + expect(addCall).toBeDefined(); + expect(addCall?.[1]).toMatchObject({ + notify: true, + name: "notify job", + }); + }); +}); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 190311bca6c..29330c6d8eb 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -122,6 +122,7 @@ export async function addCronJob(state: CronState) { description: state.cronForm.description.trim() || undefined, agentId: agentId || undefined, enabled: state.cronForm.enabled, + notify: state.cronForm.notify, schedule, sessionTarget: state.cronForm.sessionTarget, wakeMode: state.cronForm.wakeMode, diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 2d53a9ccbb5..6763fe3a6b8 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -473,6 +473,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + notify?: boolean; deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 7ce3c73998e..5a583fcedcc 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -19,6 +19,7 @@ export type CronFormState = { description: string; agentId: string; enabled: boolean; + notify: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index ea74093afec..91c724b1d5a 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -158,4 +158,50 @@ describe("cron view", () => { expect(summaries[0]).toBe("newer run"); expect(summaries[1]).toBe("older run"); }); + + it("forwards notify checkbox updates from the form", () => { + const container = document.createElement("div"); + const onFormChange = vi.fn(); + render( + renderCron( + createProps({ + onFormChange, + }), + ), + container, + ); + + const notifyLabel = Array.from(container.querySelectorAll("label.field.checkbox")).find( + (label) => label.querySelector("span")?.textContent?.trim() === "Notify webhook", + ); + const notifyInput = + notifyLabel?.querySelector('input[type="checkbox"]') ?? null; + expect(notifyInput).not.toBeNull(); + + if (!notifyInput) { + return; + } + notifyInput.checked = true; + notifyInput.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onFormChange).toHaveBeenCalledWith({ notify: true }); + }); + + it("shows notify chip for webhook-enabled jobs", () => { + const container = document.createElement("div"); + const job = { ...createJob("job-2"), notify: true }; + render( + renderCron( + createProps({ + jobs: [job], + }), + ), + container, + ); + + const chips = Array.from(container.querySelectorAll(".chip")).map((el) => + (el.textContent ?? "").trim(), + ); + expect(chips).toContain("notify"); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index a51cbfbbd3b..790c6d42988 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -127,6 +127,15 @@ export function renderCron(props: CronProps) { props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })} /> +