diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index f722451d0..73b5c8bee 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -2,6 +2,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; +function createDefaultSpawnConfig(): OpenClawConfig { + return { + acp: { + enabled: true, + backend: "acpx", + allowedAgents: ["codex"], + }, + session: { + mainKey: "main", + scope: "per-sender", + }, + channels: { + discord: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + }; +} + const hoisted = vi.hoisted(() => { const callGatewayMock = vi.fn(); const sessionBindingCapabilitiesMock = vi.fn(); @@ -12,25 +34,7 @@ const hoisted = vi.hoisted(() => { const closeSessionMock = vi.fn(); const initializeSessionMock = vi.fn(); const state = { - cfg: { - acp: { - enabled: true, - backend: "acpx", - allowedAgents: ["codex"], - }, - session: { - mainKey: "main", - scope: "per-sender", - }, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnAcpSessions: true, - }, - }, - }, - } as OpenClawConfig, + cfg: createDefaultSpawnConfig(), }; return { callGatewayMock, @@ -45,6 +49,27 @@ const hoisted = vi.hoisted(() => { }; }); +function buildSessionBindingServiceMock() { + return { + touch: vi.fn(), + bind(input: unknown) { + return hoisted.sessionBindingBindMock(input); + }, + unbind(input: unknown) { + return hoisted.sessionBindingUnbindMock(input); + }, + getCapabilities(params: unknown) { + return hoisted.sessionBindingCapabilitiesMock(params); + }, + resolveByConversation(ref: unknown) { + return hoisted.sessionBindingResolveByConversationMock(ref); + }, + listBySession(targetSessionKey: string) { + return hoisted.sessionBindingListBySessionMock(targetSessionKey); + }, + }; +} + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -71,20 +96,21 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) = await importOriginal(); return { ...actual, - getSessionBindingService: () => ({ - bind: (input: unknown) => hoisted.sessionBindingBindMock(input), - getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), - listBySession: (targetSessionKey: string) => - hoisted.sessionBindingListBySessionMock(targetSessionKey), - resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), - touch: vi.fn(), - unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), - }), + getSessionBindingService: () => buildSessionBindingServiceMock(), }; }); const { spawnAcpDirect } = await import("./acp-spawn.js"); +function createSessionBindingCapabilities() { + return { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"] as const, + }; +} + function createSessionBinding(overrides?: Partial): SessionBindingRecord { return { bindingId: "default:child-thread", @@ -106,27 +132,21 @@ function createSessionBinding(overrides?: Partial): Sessio }; } +function expectResolvedIntroTextInBindMetadata(): void { + const callWithMetadata = hoisted.sessionBindingBindMock.mock.calls.find( + (call: unknown[]) => + typeof (call[0] as { metadata?: { introText?: unknown } } | undefined)?.metadata + ?.introText === "string", + ); + const introText = + (callWithMetadata?.[0] as { metadata?: { introText?: string } } | undefined)?.metadata + ?.introText ?? ""; + expect(introText.includes("session ids: pending (available after the first reply)")).toBe(false); +} + describe("spawnAcpDirect", () => { beforeEach(() => { - hoisted.state.cfg = { - acp: { - enabled: true, - backend: "acpx", - allowedAgents: ["codex"], - }, - session: { - mainKey: "main", - scope: "per-sender", - }, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnAcpSessions: true, - }, - }, - }, - } satisfies OpenClawConfig; + hoisted.state.cfg = createDefaultSpawnConfig(); hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { method?: string }; @@ -186,12 +206,9 @@ describe("spawnAcpDirect", () => { }; }); - hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }); + hoisted.sessionBindingCapabilitiesMock + .mockReset() + .mockReturnValue(createSessionBindingCapabilities()); hoisted.sessionBindingBindMock .mockReset() .mockImplementation( @@ -248,15 +265,7 @@ describe("spawnAcpDirect", () => { placement: "child", }), ); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - introText: expect.not.stringContaining( - "session ids: pending (available after the first reply)", - ), - }), - }), - ); + expectResolvedIntroTextInBindMetadata(); const agentCall = hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) diff --git a/src/agents/compaction.identifier-policy.test.ts b/src/agents/compaction.identifier-policy.test.ts index ddc6f5ecb..23c199236 100644 --- a/src/agents/compaction.identifier-policy.test.ts +++ b/src/agents/compaction.identifier-policy.test.ts @@ -1,89 +1,28 @@ -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import * as piCodingAgent from "@mariozechner/pi-coding-agent"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js"; - -vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateSummary: vi.fn(), - }; -}); - -const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); - -function makeMessage(index: number, size = 1200): AgentMessage { - return { - role: "user", - content: `m${index}-${"x".repeat(size)}`, - timestamp: index, - }; -} +import { describe, expect, it } from "vitest"; +import { buildCompactionSummarizationInstructions } from "./compaction.js"; describe("compaction identifier policy", () => { - const testModel = { - provider: "anthropic", - model: "claude-3-opus", - contextWindow: 200_000, - } as unknown as NonNullable; - - beforeEach(() => { - mockGenerateSummary.mockReset(); - mockGenerateSummary.mockResolvedValue("summary"); + it("defaults to strict identifier preservation", () => { + const built = buildCompactionSummarizationInstructions(); + expect(built).toContain("Preserve all opaque identifiers exactly as written"); + expect(built).toContain("UUIDs"); }); - it("defaults to strict identifier preservation", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 8000, - contextWindow: 200_000, + it("can disable identifier preservation with off policy", () => { + const built = buildCompactionSummarizationInstructions(undefined, { + identifierPolicy: "off", }); - - const firstCall = mockGenerateSummary.mock.calls[0]; - expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written"); - expect(firstCall?.[5]).toContain("UUIDs"); + expect(built).toBeUndefined(); }); - it("can disable identifier preservation with off policy", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 8000, - contextWindow: 200_000, - summarizationInstructions: { identifierPolicy: "off" }, + it("supports custom identifier instructions", () => { + const built = buildCompactionSummarizationInstructions(undefined, { + identifierPolicy: "custom", + identifierInstructions: "Keep ticket IDs unchanged.", }); - const firstCall = mockGenerateSummary.mock.calls[0]; - expect(firstCall?.[5]).toBeUndefined(); - }); - - it("supports custom identifier instructions", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 8000, - contextWindow: 200_000, - summarizationInstructions: { - identifierPolicy: "custom", - identifierInstructions: "Keep ticket IDs unchanged.", - }, - }); - - const firstCall = mockGenerateSummary.mock.calls[0]; - expect(firstCall?.[5]).toContain("Keep ticket IDs unchanged."); - expect(firstCall?.[5]).not.toContain("Preserve all opaque identifiers exactly as written"); + expect(built).toContain("Keep ticket IDs unchanged."); + expect(built).not.toContain("Preserve all opaque identifiers exactly as written"); }); it("falls back to strict text when custom policy is missing instructions", () => { @@ -94,24 +33,10 @@ describe("compaction identifier policy", () => { expect(built).toContain("Preserve all opaque identifiers exactly as written"); }); - it("avoids duplicate additional-focus headers in split+merge path", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 1000, - contextWindow: 200_000, - parts: 2, - minMessagesForSplit: 4, - customInstructions: "Prioritize customer-visible regressions.", + it("keeps custom focus text when identifier policy is off", () => { + const built = buildCompactionSummarizationInstructions("Track release blockers.", { + identifierPolicy: "off", }); - - const mergedCall = mockGenerateSummary.mock.calls.at(-1); - const instructions = mergedCall?.[5] ?? ""; - expect(instructions).toContain("Merge these partial summaries into a single cohesive summary."); - expect(instructions).toContain("Prioritize customer-visible regressions."); - expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1); + expect(built).toBe("Additional focus:\nTrack release blockers."); }); }); diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index 810b6307d..cdf742e14 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -13,6 +13,7 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { }); const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); +type SummarizeInStagesInput = Parameters[0]; function makeMessage(index: number, size = 1200): AgentMessage { return { @@ -28,58 +29,63 @@ describe("compaction identifier-preservation instructions", () => { model: "claude-3-opus", contextWindow: 200_000, } as unknown as NonNullable; + const summarizeBase: Omit = { + model: testModel, + apiKey: "test-key", + reserveTokens: 4000, + maxChunkTokens: 8000, + contextWindow: 200_000, + signal: new AbortController().signal, + }; beforeEach(() => { mockGenerateSummary.mockReset(); mockGenerateSummary.mockResolvedValue("summary"); }); - it("injects identifier-preservation guidance even without custom instructions", async () => { + async function runSummary( + messageCount: number, + overrides: Partial> = {}, + ) { await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2)], - model: testModel, - apiKey: "test-key", + ...summarizeBase, + ...overrides, signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 8000, - contextWindow: 200_000, + messages: Array.from({ length: messageCount }, (_unused, index) => makeMessage(index + 1)), }); + } + + function firstSummaryInstructions() { + return mockGenerateSummary.mock.calls[0]?.[5]; + } + + it("injects identifier-preservation guidance even without custom instructions", async () => { + await runSummary(2); expect(mockGenerateSummary).toHaveBeenCalled(); - const firstCall = mockGenerateSummary.mock.calls[0]; - expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written"); - expect(firstCall?.[5]).toContain("UUIDs"); - expect(firstCall?.[5]).toContain("IPs"); - expect(firstCall?.[5]).toContain("ports"); + expect(firstSummaryInstructions()).toContain( + "Preserve all opaque identifiers exactly as written", + ); + expect(firstSummaryInstructions()).toContain("UUIDs"); + expect(firstSummaryInstructions()).toContain("IPs"); + expect(firstSummaryInstructions()).toContain("ports"); }); it("keeps identifier-preservation guidance when custom instructions are provided", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, - maxChunkTokens: 8000, - contextWindow: 200_000, + await runSummary(2, { customInstructions: "Focus on release-impacting bugs.", }); - const firstCall = mockGenerateSummary.mock.calls[0]; - expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written"); - expect(firstCall?.[5]).toContain("Additional focus:"); - expect(firstCall?.[5]).toContain("Focus on release-impacting bugs."); + expect(firstSummaryInstructions()).toContain( + "Preserve all opaque identifiers exactly as written", + ); + expect(firstSummaryInstructions()).toContain("Additional focus:"); + expect(firstSummaryInstructions()).toContain("Focus on release-impacting bugs."); }); it("applies identifier-preservation guidance on staged split + merge summarization", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, + await runSummary(4, { maxChunkTokens: 1000, - contextWindow: 200_000, parts: 2, minMessagesForSplit: 4, }); @@ -91,14 +97,8 @@ describe("compaction identifier-preservation instructions", () => { }); it("avoids duplicate additional-focus headers in split+merge path", async () => { - await summarizeInStages({ - messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)], - model: testModel, - apiKey: "test-key", - signal: new AbortController().signal, - reserveTokens: 4000, + await runSummary(4, { maxChunkTokens: 1000, - contextWindow: 200_000, parts: 2, minMessagesForSplit: 4, customInstructions: "Prioritize customer-visible regressions.", diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 8641b8b6c..b7a725853 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -8,6 +8,25 @@ import { type PiSdkModule, } from "./model-catalog.test-harness.js"; +function mockPiDiscoveryModels(models: unknown[]) { + __setModelCatalogImportForTest( + async () => + ({ + discoverAuthStorage: () => ({}), + AuthStorage: class {}, + ModelRegistry: class { + getAll() { + return models; + } + }, + }) as unknown as PiSdkModule, + ); +} + +function mockSingleOpenAiCatalogModel() { + mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]); +} + describe("loadModelCatalog", () => { installModelCatalogTestHooks(); @@ -67,32 +86,21 @@ describe("loadModelCatalog", () => { }); it("adds openai-codex/gpt-5.3-codex-spark when base gpt-5.3-codex exists", async () => { - __setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [ - { - id: "gpt-5.3-codex", - provider: "openai-codex", - name: "GPT-5.3 Codex", - reasoning: true, - contextWindow: 200000, - input: ["text"], - }, - { - id: "gpt-5.2-codex", - provider: "openai-codex", - name: "GPT-5.2 Codex", - }, - ]; - } - }, - }) as unknown as PiSdkModule, - ); + mockPiDiscoveryModels([ + { + id: "gpt-5.3-codex", + provider: "openai-codex", + name: "GPT-5.3 Codex", + reasoning: true, + contextWindow: 200000, + input: ["text"], + }, + { + id: "gpt-5.2-codex", + provider: "openai-codex", + name: "GPT-5.2 Codex", + }, + ]); const result = await loadModelCatalog({ config: {} as OpenClawConfig }); expect(result).toContainEqual( @@ -107,18 +115,7 @@ describe("loadModelCatalog", () => { }); it("merges configured models for opted-in non-pi-native providers", async () => { - __setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]; - } - }, - }) as unknown as PiSdkModule, - ); + mockSingleOpenAiCatalogModel(); const result = await loadModelCatalog({ config: { @@ -154,18 +151,7 @@ describe("loadModelCatalog", () => { }); it("does not merge configured models for providers that are not opted in", async () => { - __setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]; - } - }, - }) as unknown as PiSdkModule, - ); + mockSingleOpenAiCatalogModel(); const result = await loadModelCatalog({ config: { @@ -197,24 +183,13 @@ describe("loadModelCatalog", () => { }); it("does not duplicate opted-in configured models already present in ModelRegistry", async () => { - __setModelCatalogImportForTest( - async () => - ({ - discoverAuthStorage: () => ({}), - AuthStorage: class {}, - ModelRegistry: class { - getAll() { - return [ - { - id: "anthropic/claude-opus-4.6", - provider: "kilocode", - name: "Claude Opus 4.6", - }, - ]; - } - }, - }) as unknown as PiSdkModule, - ); + mockPiDiscoveryModels([ + { + id: "anthropic/claude-opus-4.6", + provider: "kilocode", + name: "Claude Opus 4.6", + }, + ]); const result = await loadModelCatalog({ config: { diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 9f10e451b..c28954bd9 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -15,6 +15,40 @@ import { resolveModelRefFromString, } from "./model-selection.js"; +const EXPLICIT_ALLOWLIST_CONFIG = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, +} as OpenClawConfig; + +const BUNDLED_ALLOWLIST_CATALOG = [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, +]; + +const ANTHROPIC_OPUS_CATALOG = [ + { + provider: "anthropic", + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + }, +]; + +function resolveAnthropicOpusThinking(cfg: OpenClawConfig) { + return resolveThinkingDefault({ + cfg, + provider: "anthropic", + model: "claude-opus-4-6", + catalog: ANTHROPIC_OPUS_CATALOG, + }); +} + describe("model-selection", () => { describe("normalizeProviderId", () => { it("should normalize provider names", () => { @@ -245,25 +279,9 @@ describe("model-selection", () => { describe("buildAllowedModelSet", () => { it("keeps explicitly allowlisted models even when missing from bundled catalog", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.2" }, - models: { - "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, - }, - }, - }, - } as OpenClawConfig; - - const catalog = [ - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, - ]; - const result = buildAllowedModelSet({ - cfg, - catalog, + cfg: EXPLICIT_ALLOWLIST_CONFIG, + catalog: BUNDLED_ALLOWLIST_CATALOG, defaultProvider: "anthropic", }); @@ -277,25 +295,9 @@ describe("model-selection", () => { describe("resolveAllowedModelRef", () => { it("accepts explicit allowlist refs absent from bundled catalog", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - model: { primary: "openai/gpt-5.2" }, - models: { - "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, - }, - }, - }, - } as OpenClawConfig; - - const catalog = [ - { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, - { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, - ]; - const result = resolveAllowedModelRef({ - cfg, - catalog, + cfg: EXPLICIT_ALLOWLIST_CONFIG, + catalog: BUNDLED_ALLOWLIST_CATALOG, raw: "anthropic/claude-sonnet-4-6", defaultProvider: "openai", defaultModel: "gpt-5.2", @@ -487,21 +489,7 @@ describe("model-selection", () => { }, } as OpenClawConfig; - expect( - resolveThinkingDefault({ - cfg, - provider: "anthropic", - model: "claude-opus-4-6", - catalog: [ - { - provider: "anthropic", - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - reasoning: true, - }, - ], - }), - ).toBe("high"); + expect(resolveAnthropicOpusThinking(cfg)).toBe("high"); }); it("accepts per-model params.thinking=adaptive", () => { @@ -517,41 +505,13 @@ describe("model-selection", () => { }, } as OpenClawConfig; - expect( - resolveThinkingDefault({ - cfg, - provider: "anthropic", - model: "claude-opus-4-6", - catalog: [ - { - provider: "anthropic", - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - reasoning: true, - }, - ], - }), - ).toBe("adaptive"); + expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive"); }); it("defaults Anthropic Claude 4.6 models to adaptive", () => { const cfg = {} as OpenClawConfig; - expect( - resolveThinkingDefault({ - cfg, - provider: "anthropic", - model: "claude-opus-4-6", - catalog: [ - { - provider: "anthropic", - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - reasoning: true, - }, - ], - }), - ).toBe("adaptive"); + expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive"); expect( resolveThinkingDefault({ diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index e7ddd2f58..e87024618 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -14,6 +14,98 @@ import { readGeneratedModelsJson } from "./models-config.test-utils.js"; installModelsConfigTestHooks(); +const MODELS_JSON_NAME = "models.json"; + +async function withEnvVar(name: string, value: string, run: () => Promise) { + const previous = process.env[name]; + process.env[name] = value; + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } + } +} + +async function writeAgentModelsJson(content: unknown): Promise { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, MODELS_JSON_NAME), + JSON.stringify(content, null, 2), + "utf8", + ); +} + +function createMergeConfigProvider() { + return { + baseUrl: "https://config.example/v1", + apiKey: "CONFIG_KEY", + api: "openai-responses", + models: [ + { + id: "config-model", + name: "Config model", + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + } as const; +} + +async function runCustomProviderMergeTest(seedProvider: { + baseUrl: string; + apiKey: string; + api: string; + models: Array<{ id: string; name: string; input: string[] }>; +}) { + await writeAgentModelsJson({ providers: { custom: seedProvider } }); + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: createMergeConfigProvider(), + }, + }, + }); + return readGeneratedModelsJson<{ + providers: Record; + }>(); +} + +function createMoonshotConfig(overrides: { + contextWindow: number; + maxTokens: number; +}): OpenClawConfig { + return { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + api: "openai-completions", + models: [ + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 }, + contextWindow: overrides.contextWindow, + maxTokens: overrides.maxTokens, + }, + ], + }, + }, + }, + }; +} + describe("models-config", () => { it("keeps anthropic api defaults when model entries omit api", async () => { await withTempHome(async () => { @@ -46,9 +138,7 @@ describe("models-config", () => { it("fills missing provider.apiKey from env var name when models exist", async () => { await withTempHome(async () => { - const prevKey = process.env.MINIMAX_API_KEY; - process.env.MINIMAX_API_KEY = "sk-minimax-test"; - try { + await withEnvVar("MINIMAX_API_KEY", "sk-minimax-test", async () => { const cfg: OpenClawConfig = { models: { providers: { @@ -79,55 +169,38 @@ describe("models-config", () => { expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-VL-01"); - } finally { - if (prevKey === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = prevKey; - } - } + }); }); }); it("merges providers by default", async () => { await withTempHome(async () => { - const agentDir = resolveOpenClawAgentDir(); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "models.json"), - JSON.stringify( - { - providers: { - existing: { - baseUrl: "http://localhost:1234/v1", - apiKey: "EXISTING_KEY", + await writeAgentModelsJson({ + providers: { + existing: { + baseUrl: "http://localhost:1234/v1", + apiKey: "EXISTING_KEY", + api: "openai-completions", + models: [ + { + id: "existing-model", + name: "Existing", api: "openai-completions", - models: [ - { - id: "existing-model", - name: "Existing", - api: "openai-completions", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 2048, - }, - ], + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, }, - }, + ], }, - null, - 2, - ), - "utf8", - ); + }, + }); await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); - const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); - const parsed = JSON.parse(raw) as { + const parsed = await readGeneratedModelsJson<{ providers: Record; - }; + }>(); expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1"); expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1"); @@ -136,54 +209,12 @@ describe("models-config", () => { it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => { await withTempHome(async () => { - const agentDir = resolveOpenClawAgentDir(); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "models.json"), - JSON.stringify( - { - providers: { - custom: { - baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - await ensureOpenClawModelsJson({ - models: { - mode: "merge", - providers: { - custom: { - baseUrl: "https://config.example/v1", - apiKey: "CONFIG_KEY", - api: "openai-responses", - models: [ - { - id: "config-model", - name: "Config model", - input: ["text"], - reasoning: false, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 2048, - }, - ], - }, - }, - }, + const parsed = await runCustomProviderMergeTest({ + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], }); - - const parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); }); @@ -191,54 +222,12 @@ describe("models-config", () => { it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { - const agentDir = resolveOpenClawAgentDir(); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "models.json"), - JSON.stringify( - { - providers: { - custom: { - baseUrl: "", - apiKey: "", - api: "openai-responses", - models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], - }, - }, - }, - null, - 2, - ), - "utf8", - ); - - await ensureOpenClawModelsJson({ - models: { - mode: "merge", - providers: { - custom: { - baseUrl: "https://config.example/v1", - apiKey: "CONFIG_KEY", - api: "openai-responses", - models: [ - { - id: "config-model", - name: "Config model", - input: ["text"], - reasoning: false, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 2048, - }, - ], - }, - }, - }, + const parsed = await runCustomProviderMergeTest({ + baseUrl: "", + apiKey: "", + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], }); - - const parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY"); expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); }); @@ -246,36 +235,12 @@ describe("models-config", () => { it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => { await withTempHome(async () => { - const prevKey = process.env.MOONSHOT_API_KEY; - process.env.MOONSHOT_API_KEY = "sk-moonshot-test"; - try { - const cfg: OpenClawConfig = { - models: { - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - api: "openai-completions", - models: [ - { - id: "kimi-k2.5", - name: "Kimi K2.5", - reasoning: false, - input: ["text"], - cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1024, - maxTokens: 256, - }, - ], - }, - }, - }, - }; + await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => { + const cfg = createMoonshotConfig({ contextWindow: 1024, maxTokens: 256 }); await ensureOpenClawModelsJson(cfg); - const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); - const raw = await fs.readFile(modelPath, "utf8"); - const parsed = JSON.parse(raw) as { + const parsed = await readGeneratedModelsJson<{ providers: Record< string, { @@ -289,7 +254,7 @@ describe("models-config", () => { }>; } >; - }; + }>(); const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5"); expect(kimi?.input).toEqual(["text", "image"]); expect(kimi?.reasoning).toBe(false); @@ -298,42 +263,14 @@ describe("models-config", () => { // Preserve explicit user pricing overrides when refreshing capabilities. expect(kimi?.cost?.input).toBe(123); expect(kimi?.cost?.output).toBe(456); - } finally { - if (prevKey === undefined) { - delete process.env.MOONSHOT_API_KEY; - } else { - process.env.MOONSHOT_API_KEY = prevKey; - } - } + }); }); }); it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => { await withTempHome(async () => { - const prevKey = process.env.MOONSHOT_API_KEY; - process.env.MOONSHOT_API_KEY = "sk-moonshot-test"; - try { - const cfg: OpenClawConfig = { - models: { - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - api: "openai-completions", - models: [ - { - id: "kimi-k2.5", - name: "Kimi K2.5", - reasoning: false, - input: ["text"], - cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 350000, - maxTokens: 16384, - }, - ], - }, - }, - }, - }; + await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => { + const cfg = createMoonshotConfig({ contextWindow: 350000, maxTokens: 16384 }); await ensureOpenClawModelsJson(cfg); const parsed = await readGeneratedModelsJson<{ @@ -351,13 +288,7 @@ describe("models-config", () => { const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5"); expect(kimi?.contextWindow).toBe(350000); expect(kimi?.maxTokens).toBe(16384); - } finally { - if (prevKey === undefined) { - delete process.env.MOONSHOT_API_KEY; - } else { - process.env.MOONSHOT_API_KEY = prevKey; - } - } + }); }); }); }); diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts index 6a3601aa8..b1dd8ca49 100644 --- a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -1,13 +1,11 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, withModelsTempHome as withTempHome, } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; installModelsConfigTestHooks(); @@ -22,23 +20,49 @@ type ModelsJson = { providers: Record; }; +const MINIMAX_ENV_KEY = "MINIMAX_API_KEY"; +const MINIMAX_MODEL_ID = "MiniMax-M2.5"; +const MINIMAX_TEST_KEY = "sk-minimax-test"; + +const baseMinimaxProvider = { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", +} as const; + +async function withMinimaxApiKey(run: () => Promise) { + const prev = process.env[MINIMAX_ENV_KEY]; + process.env[MINIMAX_ENV_KEY] = MINIMAX_TEST_KEY; + try { + await run(); + } finally { + if (prev === undefined) { + delete process.env[MINIMAX_ENV_KEY]; + } else { + process.env[MINIMAX_ENV_KEY] = prev; + } + } +} + +async function generateAndReadMinimaxModel(cfg: OpenClawConfig): Promise { + await ensureOpenClawModelsJson(cfg); + const parsed = await readGeneratedModelsJson(); + return parsed.providers.minimax?.models?.find((model) => model.id === MINIMAX_MODEL_ID); +} + describe("models-config: explicit reasoning override", () => { it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { // MiniMax-M2.5 has reasoning:true in the built-in catalog. // User explicitly sets reasoning:false to avoid message-ordering conflicts. await withTempHome(async () => { - const prevKey = process.env.MINIMAX_API_KEY; - process.env.MINIMAX_API_KEY = "sk-minimax-test"; - try { + await withMinimaxApiKey(async () => { const cfg: OpenClawConfig = { models: { providers: { minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", + ...baseMinimaxProvider, models: [ { - id: "MiniMax-M2.5", + id: MINIMAX_MODEL_ID, name: "MiniMax M2.5", reasoning: false, // explicit override: user wants to disable reasoning input: ["text"], @@ -52,21 +76,11 @@ describe("models-config: explicit reasoning override", () => { }, }; - await ensureOpenClawModelsJson(cfg); - - const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); - const parsed = JSON.parse(raw) as ModelsJson; - const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + const m25 = await generateAndReadMinimaxModel(cfg); expect(m25).toBeDefined(); // Must honour the explicit false — built-in true must NOT win. expect(m25?.reasoning).toBe(false); - } finally { - if (prevKey === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = prevKey; - } - } + }); }); }); @@ -74,12 +88,10 @@ describe("models-config: explicit reasoning override", () => { // When the user does not set reasoning at all, the built-in catalog value // (true for MiniMax-M2.5) should be used so the model works out of the box. await withTempHome(async () => { - const prevKey = process.env.MINIMAX_API_KEY; - process.env.MINIMAX_API_KEY = "sk-minimax-test"; - try { + await withMinimaxApiKey(async () => { // Omit 'reasoning' to simulate a user config that doesn't set it. const modelWithoutReasoning = { - id: "MiniMax-M2.5", + id: MINIMAX_MODEL_ID, name: "MiniMax M2.5", input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -90,8 +102,7 @@ describe("models-config: explicit reasoning override", () => { models: { providers: { minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", + ...baseMinimaxProvider, // @ts-expect-error Intentional: emulate user config omitting reasoning. models: [modelWithoutReasoning], }, @@ -99,21 +110,11 @@ describe("models-config: explicit reasoning override", () => { }, }; - await ensureOpenClawModelsJson(cfg); - - const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8"); - const parsed = JSON.parse(raw) as ModelsJson; - const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5"); + const m25 = await generateAndReadMinimaxModel(cfg); expect(m25).toBeDefined(); // Built-in catalog has reasoning:true — should be applied as default. expect(m25?.reasoning).toBe(true); - } finally { - if (prevKey === undefined) { - delete process.env.MINIMAX_API_KEY; - } else { - process.env.MINIMAX_API_KEY = prevKey; - } - } + }); }); }); }); diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 7e3132b31..c44b5aa2c 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -15,6 +15,14 @@ import { createOpenClawTools } from "./openclaw-tools.js"; const NODE_ID = "mac-1"; const BASE_RUN_INPUT = { action: "run", node: NODE_ID, command: ["echo", "hi"] } as const; +const JPG_PAYLOAD = { + format: "jpg", + base64: "aGVsbG8=", + width: 1, + height: 1, +} as const; + +type GatewayCall = { method: string; params?: unknown }; function unexpectedGatewayMethod(method: unknown): never { throw new Error(`unexpected method: ${String(method)}`); @@ -32,24 +40,99 @@ async function executeNodes(input: Record) { return getNodesTool().execute("call1", input as never); } +type NodesToolResult = Awaited>; +type GatewayMockResult = Record | null | undefined; + function mockNodeList(commands?: string[]) { return { nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }], }; } +function expectSingleImage(result: NodesToolResult, params?: { mimeType?: string }) { + const images = (result.content ?? []).filter((block) => block.type === "image"); + expect(images).toHaveLength(1); + if (params?.mimeType) { + expect(images[0]?.mimeType).toBe(params.mimeType); + } +} + +function expectFirstTextContains(result: NodesToolResult, expectedText: string) { + expect(result.content?.[0]).toMatchObject({ + type: "text", + text: expect.stringContaining(expectedText), + }); +} + +function setupNodeInvokeMock(params: { + commands?: string[]; + onInvoke?: (invokeParams: unknown) => GatewayMockResult | Promise; + invokePayload?: unknown; +}) { + callGateway.mockImplementation(async ({ method, params: invokeParams }: GatewayCall) => { + if (method === "node.list") { + return mockNodeList(params.commands); + } + if (method === "node.invoke") { + if (params.onInvoke) { + return await params.onInvoke(invokeParams); + } + if (params.invokePayload !== undefined) { + return { payload: params.invokePayload }; + } + return { payload: {} }; + } + return unexpectedGatewayMethod(method); + }); +} + +function createSystemRunPreparePayload(cwd: string | null) { + return { + payload: { + cmdText: "echo hi", + plan: { + argv: ["echo", "hi"], + cwd, + rawCommand: "echo hi", + agentId: null, + sessionKey: null, + }, + }, + }; +} + +function setupSystemRunGateway(params: { + onRunInvoke: (invokeParams: unknown) => GatewayMockResult | Promise; + onApprovalRequest?: (approvalParams: unknown) => GatewayMockResult | Promise; + prepareCwd?: string | null; +}) { + callGateway.mockImplementation(async ({ method, params: gatewayParams }: GatewayCall) => { + if (method === "node.list") { + return mockNodeList(["system.run"]); + } + if (method === "node.invoke") { + const command = (gatewayParams as { command?: string } | undefined)?.command; + if (command === "system.run.prepare") { + return createSystemRunPreparePayload(params.prepareCwd ?? null); + } + return await params.onRunInvoke(gatewayParams); + } + if (method === "exec.approval.request" && params.onApprovalRequest) { + return await params.onApprovalRequest(gatewayParams); + } + return unexpectedGatewayMethod(method); + }); +} + beforeEach(() => { callGateway.mockClear(); }); describe("nodes camera_snap", () => { it("uses front/high-quality defaults when params are omitted", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ command: "camera.snap", params: { facing: "front", @@ -57,16 +140,8 @@ describe("nodes camera_snap", () => { quality: 0.95, }, }); - return { - payload: { - format: "jpg", - base64: "aGVsbG8=", - width: 1, - height: 1, - }, - }; - } - return unexpectedGatewayMethod(method); + return { payload: JPG_PAYLOAD }; + }, }); const result = await executeNodes({ @@ -74,26 +149,12 @@ describe("nodes camera_snap", () => { node: NODE_ID, }); - const images = (result.content ?? []).filter((block) => block.type === "image"); - expect(images).toHaveLength(1); + expectSingleImage(result); }); it("maps jpg payloads to image/jpeg", async () => { - callGateway.mockImplementation(async ({ method }) => { - if (method === "node.list") { - return mockNodeList(); - } - if (method === "node.invoke") { - return { - payload: { - format: "jpg", - base64: "aGVsbG8=", - width: 1, - height: 1, - }, - }; - } - return unexpectedGatewayMethod(method); + setupNodeInvokeMock({ + invokePayload: JPG_PAYLOAD, }); const result = await executeNodes({ @@ -102,31 +163,18 @@ describe("nodes camera_snap", () => { facing: "front", }); - const images = (result.content ?? []).filter((block) => block.type === "image"); - expect(images).toHaveLength(1); - expect(images[0]?.mimeType).toBe("image/jpeg"); + expectSingleImage(result, { mimeType: "image/jpeg" }); }); it("passes deviceId when provided", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ command: "camera.snap", params: { deviceId: "cam-123" }, }); - return { - payload: { - format: "jpg", - base64: "aGVsbG8=", - width: 1, - height: 1, - }, - }; - } - return unexpectedGatewayMethod(method); + return { payload: JPG_PAYLOAD }; + }, }); await executeNodes({ @@ -151,12 +199,10 @@ describe("nodes camera_snap", () => { describe("nodes notifications_list", () => { it("invokes notifications.list and returns payload", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["notifications.list"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["notifications.list"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "notifications.list", params: {}, @@ -169,8 +215,7 @@ describe("nodes notifications_list", () => { notifications: [{ key: "n1", packageName: "com.example.app" }], }, }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -178,21 +223,16 @@ describe("nodes notifications_list", () => { node: NODE_ID, }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"notifications"'), - }); + expectFirstTextContains(result, '"notifications"'); }); }); describe("nodes notifications_action", () => { it("invokes notifications.actions dismiss", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["notifications.actions"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["notifications.actions"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "notifications.actions", params: { @@ -201,8 +241,7 @@ describe("nodes notifications_action", () => { }, }); return { payload: { ok: true, key: "n1", action: "dismiss" } }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -212,21 +251,16 @@ describe("nodes notifications_action", () => { notificationAction: "dismiss", }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"dismiss"'), - }); + expectFirstTextContains(result, '"dismiss"'); }); }); describe("nodes device_status and device_info", () => { it("invokes device.status and returns payload", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["device.status", "device.info"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["device.status", "device.info"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "device.status", params: {}, @@ -236,8 +270,7 @@ describe("nodes device_status and device_info", () => { battery: { state: "charging", lowPowerModeEnabled: false }, }, }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -245,19 +278,14 @@ describe("nodes device_status and device_info", () => { node: NODE_ID, }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"battery"'), - }); + expectFirstTextContains(result, '"battery"'); }); it("invokes device.info and returns payload", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["device.status", "device.info"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["device.status", "device.info"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "device.info", params: {}, @@ -268,8 +296,7 @@ describe("nodes device_status and device_info", () => { appVersion: "1.0.0", }, }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -277,19 +304,14 @@ describe("nodes device_status and device_info", () => { node: NODE_ID, }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"systemName"'), - }); + expectFirstTextContains(result, '"systemName"'); }); it("invokes device.permissions and returns payload", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["device.permissions"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["device.permissions"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "device.permissions", params: {}, @@ -301,8 +323,7 @@ describe("nodes device_status and device_info", () => { }, }, }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -310,19 +331,14 @@ describe("nodes device_status and device_info", () => { node: NODE_ID, }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"permissions"'), - }); + expectFirstTextContains(result, '"permissions"'); }); it("invokes device.health and returns payload", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["device.health"]); - } - if (method === "node.invoke") { - expect(params).toMatchObject({ + setupNodeInvokeMock({ + commands: ["device.health"], + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "device.health", params: {}, @@ -333,8 +349,7 @@ describe("nodes device_status and device_info", () => { battery: { chargingType: "usb" }, }, }; - } - return unexpectedGatewayMethod(method); + }, }); const result = await executeNodes({ @@ -342,36 +357,16 @@ describe("nodes device_status and device_info", () => { node: NODE_ID, }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringContaining('"memory"'), - }); + expectFirstTextContains(result, '"memory"'); }); }); describe("nodes run", () => { it("passes invoke and command timeouts", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["system.run"]); - } - if (method === "node.invoke") { - const command = (params as { command?: string } | undefined)?.command; - if (command === "system.run.prepare") { - return { - payload: { - cmdText: "echo hi", - plan: { - argv: ["echo", "hi"], - cwd: "/tmp", - rawCommand: "echo hi", - agentId: null, - sessionKey: null, - }, - }, - }; - } - expect(params).toMatchObject({ + setupSystemRunGateway({ + prepareCwd: "/tmp", + onRunInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "system.run", timeoutMs: 45_000, @@ -385,8 +380,7 @@ describe("nodes run", () => { return { payload: { stdout: "", stderr: "", exitCode: 0, success: true }, }; - } - return unexpectedGatewayMethod(method); + }, }); await executeNodes({ @@ -401,31 +395,13 @@ describe("nodes run", () => { it("requests approval and retries with allow-once decision", async () => { let invokeCalls = 0; let approvalId: string | null = null; - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["system.run"]); - } - if (method === "node.invoke") { - const command = (params as { command?: string } | undefined)?.command; - if (command === "system.run.prepare") { - return { - payload: { - cmdText: "echo hi", - plan: { - argv: ["echo", "hi"], - cwd: null, - rawCommand: "echo hi", - agentId: null, - sessionKey: null, - }, - }, - }; - } + setupSystemRunGateway({ + onRunInvoke: (invokeParams) => { invokeCalls += 1; if (invokeCalls === 1) { throw new Error("SYSTEM_RUN_DENIED: approval required"); } - expect(params).toMatchObject({ + expect(invokeParams).toMatchObject({ nodeId: NODE_ID, command: "system.run", params: { @@ -436,9 +412,9 @@ describe("nodes run", () => { }, }); return { payload: { stdout: "", stderr: "", exitCode: 0, success: true } }; - } - if (method === "exec.approval.request") { - expect(params).toMatchObject({ + }, + onApprovalRequest: (approvalParams) => { + expect(approvalParams).toMatchObject({ id: expect.any(String), command: "echo hi", commandArgv: ["echo", "hi"], @@ -450,12 +426,11 @@ describe("nodes run", () => { timeoutMs: 120_000, }); approvalId = - typeof (params as { id?: unknown } | undefined)?.id === "string" - ? ((params as { id: string }).id ?? null) + typeof (approvalParams as { id?: unknown } | undefined)?.id === "string" + ? ((approvalParams as { id: string }).id ?? null) : null; return { decision: "allow-once" }; - } - return unexpectedGatewayMethod(method); + }, }); await executeNodes(BASE_RUN_INPUT); @@ -463,93 +438,36 @@ describe("nodes run", () => { }); it("fails with user denied when approval decision is deny", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["system.run"]); - } - if (method === "node.invoke") { - const command = (params as { command?: string } | undefined)?.command; - if (command === "system.run.prepare") { - return { - payload: { - cmdText: "echo hi", - plan: { - argv: ["echo", "hi"], - cwd: null, - rawCommand: "echo hi", - agentId: null, - sessionKey: null, - }, - }, - }; - } + setupSystemRunGateway({ + onRunInvoke: () => { throw new Error("SYSTEM_RUN_DENIED: approval required"); - } - if (method === "exec.approval.request") { + }, + onApprovalRequest: () => { return { decision: "deny" }; - } - return unexpectedGatewayMethod(method); + }, }); await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: user denied"); }); it("fails closed for timeout and invalid approval decisions", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["system.run"]); - } - if (method === "node.invoke") { - const command = (params as { command?: string } | undefined)?.command; - if (command === "system.run.prepare") { - return { - payload: { - cmdText: "echo hi", - plan: { - argv: ["echo", "hi"], - cwd: null, - rawCommand: "echo hi", - agentId: null, - sessionKey: null, - }, - }, - }; - } + setupSystemRunGateway({ + onRunInvoke: () => { throw new Error("SYSTEM_RUN_DENIED: approval required"); - } - if (method === "exec.approval.request") { + }, + onApprovalRequest: () => { return {}; - } - return unexpectedGatewayMethod(method); + }, }); await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out"); - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return mockNodeList(["system.run"]); - } - if (method === "node.invoke") { - const command = (params as { command?: string } | undefined)?.command; - if (command === "system.run.prepare") { - return { - payload: { - cmdText: "echo hi", - plan: { - argv: ["echo", "hi"], - cwd: null, - rawCommand: "echo hi", - agentId: null, - sessionKey: null, - }, - }, - }; - } + setupSystemRunGateway({ + onRunInvoke: () => { throw new Error("SYSTEM_RUN_DENIED: approval required"); - } - if (method === "exec.approval.request") { + }, + onApprovalRequest: () => { return { decision: "allow-never" }; - } - return unexpectedGatewayMethod(method); + }, }); await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow( "exec denied: invalid approval decision", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts index 279566a0e..a01e8d461 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts @@ -1,83 +1,78 @@ -import { describe, expect, it, vi } from "vitest"; -import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import * as harness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; +import { resetSubagentRegistryForTests } from "./subagent-registry.js"; -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => ({ - agents: { - defaults: { - subagents: { - thinking: "high", - }, - }, - }, - routing: { - sessions: { - mainKey: "agent:test:main", - }, - }, - }), - }; -}); +const MAIN_SESSION_KEY = "agent:test:main"; -vi.mock("../gateway/call.js", () => { - return { - callGateway: vi.fn(async ({ method }: { method: string }) => { - if (method === "agent") { - return { runId: "run-123" }; - } - return {}; - }), - }; -}); +type ThinkingLevel = "high" | "medium" | "low"; -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 applyThinkingDefault(thinking: ThinkingLevel) { + harness.setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { defaults: { subagents: { thinking } } }, + routing: { sessions: { mainKey: MAIN_SESSION_KEY } }, + }); } -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; +function findSubagentThinking( + calls: Array<{ method?: string; params?: unknown }>, +): string | undefined { + for (const call of calls) { + if (call.method !== "agent") { + continue; + } + const params = call.params as { lane?: string; thinking?: string } | undefined; + if (params?.lane === "subagent") { + return params.thinking; } } return undefined; } -async function expectThinkingPropagation(params: { +function findPatchedThinking( + calls: Array<{ method?: string; params?: unknown }>, +): string | undefined { + for (let index = calls.length - 1; index >= 0; index -= 1) { + const entry = calls[index]; + if (!entry || entry.method !== "sessions.patch") { + continue; + } + const params = entry.params as { thinkingLevel?: string } | undefined; + if (params?.thinkingLevel) { + return params.thinkingLevel; + } + } + return undefined; +} + +async function expectThinkingPropagation(input: { callId: string; payload: Record; - expectedThinking: string; + expected: ThinkingLevel; }) { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); - const result = await tool.execute(params.callId, params.payload); + const gateway = harness.setupSessionsSpawnGatewayMock({}); + const tool = await harness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY }); + const result = await tool.execute(input.callId, input.payload); expect(result.details).toMatchObject({ status: "accepted" }); - 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(params.expectedThinking); - expect(thinkingPatch?.params?.thinkingLevel).toBe(params.expectedThinking); + expect(findSubagentThinking(gateway.calls)).toBe(input.expected); + expect(findPatchedThinking(gateway.calls)).toBe(input.expected); } describe("sessions_spawn thinking defaults", () => { + beforeEach(() => { + harness.resetSessionsSpawnConfigOverride(); + resetSubagentRegistryForTests(); + harness.getCallGatewayMock().mockClear(); + applyThinkingDefault("high"); + }); + it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => { await expectThinkingPropagation({ callId: "call-1", payload: { task: "hello" }, - expectedThinking: "high", + expected: "high", }); }); @@ -85,7 +80,7 @@ describe("sessions_spawn thinking defaults", () => { await expectThinkingPropagation({ callId: "call-2", payload: { task: "hello", thinking: "low" }, - expectedThinking: "low", + expected: "low", }); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts index 947c83333..bf23d3d68 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts @@ -1,69 +1,50 @@ -import { describe, expect, it, vi } from "vitest"; -import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import { + getCallGatewayMock, + getSessionsSpawnTool, + resetSessionsSpawnConfigOverride, + setSessionsSpawnConfigOverride, + setupSessionsSpawnGatewayMock, +} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; +import { resetSubagentRegistryForTests } from "./subagent-registry.js"; -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => ({ - agents: { - defaults: { - subagents: { - maxConcurrent: 8, - }, - }, - }, - routing: { - sessions: { - mainKey: "agent:test:main", - }, - }, - }), - }; -}); +const MAIN_SESSION_KEY = "agent:test:main"; -vi.mock("../gateway/call.js", () => { - return { - callGateway: vi.fn(async ({ method }: { method: string }) => { - if (method === "agent") { - return { runId: "run-456" }; - } - return {}; - }), - }; -}); - -vi.mock("../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => null, -})); - -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 configureDefaultsWithoutTimeout() { + setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { defaults: { subagents: { maxConcurrent: 8 } } }, + routing: { sessions: { mainKey: MAIN_SESSION_KEY } }, + }); } -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; +function readSpawnTimeout(calls: Array<{ method?: string; params?: unknown }>): number | undefined { + const spawn = calls.find((entry) => { + if (entry.method !== "agent") { + return false; } - } - return undefined; + const params = entry.params as { lane?: string } | undefined; + return params?.lane === "subagent"; + }); + const params = spawn?.params as { timeout?: number } | undefined; + return params?.timeout; } describe("sessions_spawn default runTimeoutSeconds (config absent)", () => { + beforeEach(() => { + resetSessionsSpawnConfigOverride(); + resetSubagentRegistryForTests(); + getCallGatewayMock().mockClear(); + }); + it("falls back to 0 (no timeout) when config key is absent", async () => { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + configureDefaultsWithoutTimeout(); + const gateway = setupSessionsSpawnGatewayMock({}); + const tool = await getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY }); + const result = await tool.execute("call-1", { task: "hello" }); expect(result.details).toMatchObject({ status: "accepted" }); - - const calls = await getGatewayCalls(); - const agentCall = findLastCall(calls, (call) => call.method === "agent"); - expect(agentCall?.params?.timeout).toBe(0); + expect(readSpawnTimeout(gateway.calls)).toBe(0); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts index 8186b8bde..cd64fc55f 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts @@ -1,79 +1,61 @@ -import { describe, expect, it, vi } from "vitest"; -import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import "./test-helpers/fast-core-tools.js"; +import * as sessionsHarness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; +import { resetSubagentRegistryForTests } from "./subagent-registry.js"; -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => ({ - agents: { - defaults: { - subagents: { - runTimeoutSeconds: 900, - }, - }, - }, - routing: { - sessions: { - mainKey: "agent:test:main", - }, - }, - }), - }; -}); +const MAIN_SESSION_KEY = "agent:test:main"; -vi.mock("../gateway/call.js", () => { - return { - callGateway: vi.fn(async ({ method }: { method: string }) => { - if (method === "agent") { - return { runId: "run-123" }; - } - return {}; - }), - }; -}); - -vi.mock("../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => null, -})); - -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 applySubagentTimeoutDefault(seconds: number) { + sessionsHarness.setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { defaults: { subagents: { runTimeoutSeconds: seconds } } }, + routing: { sessions: { mainKey: MAIN_SESSION_KEY } }, + }); } -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; +function getSubagentTimeout( + calls: Array<{ method?: string; params?: unknown }>, +): number | undefined { + for (const call of calls) { + if (call.method !== "agent") { + continue; + } + const params = call.params as { lane?: string; timeout?: number } | undefined; + if (params?.lane === "subagent") { + return params.timeout; } } return undefined; } -describe("sessions_spawn default runTimeoutSeconds", () => { - it("uses config default when agent omits runTimeoutSeconds", async () => { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); - const result = await tool.execute("call-1", { task: "hello" }); - expect(result.details).toMatchObject({ status: "accepted" }); +async function spawnSubagent(callId: string, payload: Record) { + const tool = await sessionsHarness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY }); + const result = await tool.execute(callId, payload); + expect(result.details).toMatchObject({ status: "accepted" }); +} - const calls = await getGatewayCalls(); - const agentCall = findLastCall(calls, (call) => call.method === "agent"); - expect(agentCall?.params?.timeout).toBe(900); +describe("sessions_spawn default runTimeoutSeconds", () => { + beforeEach(() => { + sessionsHarness.resetSessionsSpawnConfigOverride(); + resetSubagentRegistryForTests(); + sessionsHarness.getCallGatewayMock().mockClear(); + }); + + it("uses config default when agent omits runTimeoutSeconds", async () => { + applySubagentTimeoutDefault(900); + const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({}); + + await spawnSubagent("call-1", { task: "hello" }); + + expect(getSubagentTimeout(gateway.calls)).toBe(900); }); it("explicit runTimeoutSeconds wins over config default", async () => { - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); - const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 }); - expect(result.details).toMatchObject({ status: "accepted" }); + applySubagentTimeoutDefault(900); + const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({}); - const calls = await getGatewayCalls(); - const agentCall = findLastCall(calls, (call) => call.method === "agent"); - expect(agentCall?.params?.timeout).toBe(300); + await spawnSubagent("call-2", { task: "hello", runTimeoutSeconds: 300 }); + + expect(getSubagentTimeout(gateway.calls)).toBe(300); }); }); diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index e3061518f..33c85b832 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -320,54 +320,55 @@ describe("downgradeOpenAIReasoningBlocks", () => { }); describe("downgradeOpenAIFunctionCallReasoningPairs", () => { + const callIdWithReasoning = "call_123|fc_123"; + const callIdWithoutReasoning = "call_123"; + const readArgs = {} as Record; + + const makeToolCall = (id: string) => ({ + type: "toolCall", + id, + name: "read", + arguments: readArgs, + }); + const makeToolResult = (toolCallId: string, text: string) => ({ + role: "toolResult", + toolCallId, + toolName: "read", + content: [{ type: "text", text }], + }); + const makeReasoningAssistantTurn = (id: string) => ({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + makeToolCall(id), + ], + }); + const makePlainAssistantTurn = (id: string) => ({ + role: "assistant", + content: [makeToolCall(id)], + }); + it("strips fc ids when reasoning cannot be replayed", () => { const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "read", - content: [{ type: "text", text: "ok" }], - }, + makePlainAssistantTurn(callIdWithReasoning), + makeToolResult(callIdWithReasoning, "ok"), ]; // oxlint-disable-next-line typescript/no-explicit-any expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_123", - toolName: "read", - content: [{ type: "text", text: "ok" }], - }, + makePlainAssistantTurn(callIdWithoutReasoning), + makeToolResult(callIdWithoutReasoning, "ok"), ]); }); it("keeps fc ids when replayable reasoning is present", () => { const input = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "read", - content: [{ type: "text", text: "ok" }], - }, + makeReasoningAssistantTurn(callIdWithReasoning), + makeToolResult(callIdWithReasoning, "ok"), ]; // oxlint-disable-next-line typescript/no-explicit-any @@ -376,64 +377,18 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => { it("only rewrites tool results paired to the downgraded assistant turn", () => { const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "read", - content: [{ type: "text", text: "turn1" }], - }, - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "read", - content: [{ type: "text", text: "turn2" }], - }, + makePlainAssistantTurn(callIdWithReasoning), + makeToolResult(callIdWithReasoning, "turn1"), + makeReasoningAssistantTurn(callIdWithReasoning), + makeToolResult(callIdWithReasoning, "turn2"), ]; // oxlint-disable-next-line typescript/no-explicit-any expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_123", - toolName: "read", - content: [{ type: "text", text: "turn1" }], - }, - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }, - ], - }, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "read", - content: [{ type: "text", text: "turn2" }], - }, + makePlainAssistantTurn(callIdWithoutReasoning), + makeToolResult(callIdWithoutReasoning, "turn1"), + makeReasoningAssistantTurn(callIdWithReasoning), + makeToolResult(callIdWithReasoning, "turn2"), ]); }); }); diff --git a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts index ee7149030..d0d4b7c36 100644 --- a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts +++ b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts @@ -7,92 +7,66 @@ import { import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; describe("sanitizeSessionHistory openai tool id preservation", () => { - it("strips fc ids when replayable reasoning metadata is missing", async () => { - const sessionEntries = [ + const makeSessionManager = () => + makeInMemorySessionManager([ makeModelSnapshotEntry({ provider: "openai", modelApi: "openai-responses", modelId: "gpt-5.2-codex", }), - ]; - const sessionManager = makeInMemorySessionManager(sessionEntries); + ]); - const messages: AgentMessage[] = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }], - } as unknown as AgentMessage, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "noop", - content: [{ type: "text", text: "ok" }], - isError: false, - } as unknown as AgentMessage, - ]; + const makeMessages = (withReasoning: boolean): AgentMessage[] => [ + { + role: "assistant", + content: [ + ...(withReasoning + ? [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + ] + : []), + { type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }, + ], + } as unknown as AgentMessage, + { + role: "toolResult", + toolCallId: "call_123|fc_123", + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + } as unknown as AgentMessage, + ]; + it.each([ + { + name: "strips fc ids when replayable reasoning metadata is missing", + withReasoning: false, + expectedToolId: "call_123", + }, + { + name: "keeps canonical call_id|fc_id pairings when replayable reasoning is present", + withReasoning: true, + expectedToolId: "call_123|fc_123", + }, + ])("$name", async ({ withReasoning, expectedToolId }) => { const result = await sanitizeSessionHistory({ - messages, + messages: makeMessages(withReasoning), modelApi: "openai-responses", provider: "openai", modelId: "gpt-5.2-codex", - sessionManager, + sessionManager: makeSessionManager(), sessionId: "test-session", }); const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> }; const toolCall = assistant.content?.find((block) => block.type === "toolCall"); - expect(toolCall?.id).toBe("call_123"); + expect(toolCall?.id).toBe(expectedToolId); const toolResult = result[1] as { toolCallId?: string }; - expect(toolResult.toolCallId).toBe("call_123"); - }); - - it("keeps canonical call_id|fc_id pairings when replayable reasoning is present", async () => { - const sessionEntries = [ - makeModelSnapshotEntry({ - provider: "openai", - modelApi: "openai-responses", - modelId: "gpt-5.2-codex", - }), - ]; - const sessionManager = makeInMemorySessionManager(sessionEntries); - - const messages: AgentMessage[] = [ - { - role: "assistant", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), - }, - { type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }, - ], - } as unknown as AgentMessage, - { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "noop", - content: [{ type: "text", text: "ok" }], - isError: false, - } as unknown as AgentMessage, - ]; - - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - modelId: "gpt-5.2-codex", - sessionManager, - sessionId: "test-session", - }); - - const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> }; - const toolCall = assistant.content?.find((block) => block.type === "toolCall"); - expect(toolCall?.id).toBe("call_123|fc_123"); - - const toolResult = result[1] as { toolCallId?: string }; - expect(toolResult.toolCallId).toBe("call_123|fc_123"); + expect(toolResult.toolCallId).toBe(expectedToolId); }); }); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index fc1a2cec8..6b65bc9d3 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -74,6 +74,54 @@ describe("sanitizeSessionHistory", () => { }, ] as unknown as AgentMessage[]; + const makeUsage = (input: number, output: number, totalTokens: number) => ({ + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); + + const makeAssistantUsageMessage = (params: { + text: string; + usage: ReturnType; + timestamp?: number; + }) => + ({ + role: "assistant", + content: [{ type: "text", text: params.text }], + stopReason: "stop", + ...(typeof params.timestamp === "number" ? { timestamp: params.timestamp } : {}), + usage: params.usage, + }) as unknown as AgentMessage; + + const makeCompactionSummaryMessage = (tokensBefore: number, timestamp: string) => + ({ + role: "compactionSummary", + summary: "compressed", + tokensBefore, + timestamp, + }) as unknown as AgentMessage; + + const sanitizeOpenAIHistory = async ( + messages: AgentMessage[], + overrides: Partial[0]> = {}, + ) => + sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + ...overrides, + }); + + const getAssistantMessages = (messages: AgentMessage[]) => + messages.filter((message) => message.role === "assistant") as Array< + AgentMessage & { usage?: unknown; content?: unknown } + >; + beforeEach(async () => { sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); }); @@ -178,34 +226,14 @@ describe("sanitizeSessionHistory", () => { const messages = [ { role: "user", content: "old context" }, - { - role: "assistant", - content: [{ type: "text", text: "old answer" }], - stopReason: "stop", - usage: { - input: 191_919, - output: 2_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 193_919, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, - { - role: "compactionSummary", - summary: "compressed", - tokensBefore: 191_919, - timestamp: new Date().toISOString(), - }, + makeAssistantUsageMessage({ + text: "old answer", + usage: makeUsage(191_919, 2_000, 193_919), + }), + makeCompactionSummaryMessage(191_919, new Date().toISOString()), ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); const staleAssistant = result.find((message) => message.role === "assistant") as | (AgentMessage & { usage?: unknown }) @@ -218,52 +246,21 @@ describe("sanitizeSessionHistory", () => { vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); const messages = [ - { - role: "assistant", - content: [{ type: "text", text: "pre-compaction answer" }], - stopReason: "stop", - usage: { - input: 120_000, - output: 3_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 123_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, - { - role: "compactionSummary", - summary: "compressed", - tokensBefore: 123_000, - timestamp: new Date().toISOString(), - }, + makeAssistantUsageMessage({ + text: "pre-compaction answer", + usage: makeUsage(120_000, 3_000, 123_000), + }), + makeCompactionSummaryMessage(123_000, new Date().toISOString()), { role: "user", content: "new question" }, - { - role: "assistant", - content: [{ type: "text", text: "fresh answer" }], - stopReason: "stop", - usage: { - input: 1_000, - output: 250, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 1_250, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, + makeAssistantUsageMessage({ + text: "fresh answer", + usage: makeUsage(1_000, 250, 1_250), + }), ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); - const assistants = result.filter((message) => message.role === "assistant") as Array< - AgentMessage & { usage?: unknown } - >; + const assistants = getAssistantMessages(result); expect(assistants).toHaveLength(2); expect(assistants[0]?.usage).toEqual(makeZeroUsageSnapshot()); expect(assistants[1]?.usage).toBeDefined(); @@ -274,35 +271,15 @@ describe("sanitizeSessionHistory", () => { const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const messages = [ - { - role: "compactionSummary", - summary: "compressed", - tokensBefore: 191_919, - timestamp: new Date(compactionTs).toISOString(), - }, - { - role: "assistant", - content: [{ type: "text", text: "kept pre-compaction answer" }], - stopReason: "stop", + makeCompactionSummaryMessage(191_919, new Date(compactionTs).toISOString()), + makeAssistantUsageMessage({ + text: "kept pre-compaction answer", timestamp: compactionTs - 1_000, - usage: { - input: 191_919, - output: 2_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 193_919, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, + usage: makeUsage(191_919, 2_000, 193_919), + }), ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); const assistant = result.find((message) => message.role === "assistant") as | (AgentMessage & { usage?: unknown }) @@ -315,54 +292,23 @@ describe("sanitizeSessionHistory", () => { const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const messages = [ - { - role: "compactionSummary", - summary: "compressed", - tokensBefore: 123_000, - timestamp: new Date(compactionTs).toISOString(), - }, - { - role: "assistant", - content: [{ type: "text", text: "kept pre-compaction answer" }], - stopReason: "stop", + makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()), + makeAssistantUsageMessage({ + text: "kept pre-compaction answer", timestamp: compactionTs - 2_000, - usage: { - input: 120_000, - output: 3_000, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 123_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, + usage: makeUsage(120_000, 3_000, 123_000), + }), { role: "user", content: "new question", timestamp: compactionTs + 1_000 }, - { - role: "assistant", - content: [{ type: "text", text: "fresh answer" }], - stopReason: "stop", + makeAssistantUsageMessage({ + text: "fresh answer", timestamp: compactionTs + 2_000, - usage: { - input: 1_000, - output: 250, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 1_250, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }, + usage: makeUsage(1_000, 250, 1_250), + }), ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); - const assistants = result.filter((message) => message.role === "assistant") as Array< - AgentMessage & { usage?: unknown; content?: unknown } - >; + const assistants = getAssistantMessages(result); const keptAssistant = assistants.find((message) => JSON.stringify(message.content).includes("kept pre-compaction answer"), ); @@ -411,13 +357,7 @@ describe("sanitizeSessionHistory", () => { }, ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); // repairToolUseResultPairing now runs for all providers (including OpenAI) // to fix orphaned function_call_output items that OpenAI would reject. @@ -435,13 +375,7 @@ describe("sanitizeSessionHistory", () => { { role: "user", content: "hello" }, ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: "test-session", - }); + const result = await sanitizeOpenAIHistory(messages, { sessionId: "test-session" }); expect(result.map((msg) => msg.role)).toEqual(["user"]); }); @@ -463,13 +397,7 @@ describe("sanitizeSessionHistory", () => { { role: "user", content: "hello" }, ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, - }); + const result = await sanitizeOpenAIHistory(messages); expect(result.map((msg) => msg.role)).toEqual(["user"]); }); @@ -482,13 +410,8 @@ describe("sanitizeSessionHistory", () => { }, ] as unknown as AgentMessage[]; - const result = await sanitizeSessionHistory({ - messages, - modelApi: "openai-responses", - provider: "openai", + const result = await sanitizeOpenAIHistory(messages, { allowedToolNames: ["read"], - sessionManager: mockSessionManager, - sessionId: TEST_SESSION_ID, }); expect(result).toEqual([]); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 705025eaf..41750595b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -12,6 +12,21 @@ import { wrapStreamFnTrimToolCallNames, } from "./attempt.js"; +function createOllamaProviderConfig(injectNumCtxForOpenAICompat: boolean): OpenClawConfig { + return { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + api: "openai-completions", + injectNumCtxForOpenAICompat, + models: [], + }, + }, + }, + }; +} + describe("resolvePromptBuildHookResult", () => { function createLegacyOnlyHookRunner() { return { @@ -129,6 +144,25 @@ describe("wrapStreamFnTrimToolCallNames", () => { }; } + async function invokeWrappedStream( + baseFn: (...args: never[]) => unknown, + allowedToolNames?: Set, + ) { + const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, allowedToolNames); + return await wrappedFn({} as never, {} as never, {} as never); + } + + function createEventStream(params: { + event: unknown; + finalToolCall: { type: string; name: string }; + }) { + const finalMessage = { role: "assistant", content: [params.finalToolCall] }; + const baseFn = vi.fn(() => + createFakeStream({ events: [params.event], resultMessage: finalMessage }), + ); + return { baseFn, finalMessage }; + } + it("trims whitespace from live streamed tool call names and final result message", async () => { const partialToolCall = { type: "toolCall", name: " read " }; const messageToolCall = { type: "toolCall", name: " exec " }; @@ -138,13 +172,9 @@ describe("wrapStreamFnTrimToolCallNames", () => { partial: { role: "assistant", content: [partialToolCall] }, message: { role: "assistant", content: [messageToolCall] }, }; - const finalMessage = { role: "assistant", content: [finalToolCall] }; - const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage })); + const { baseFn, finalMessage } = createEventStream({ event, finalToolCall }); - const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never); - const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited< - ReturnType - >; + const stream = await invokeWrappedStream(baseFn); const seenEvents: unknown[] = []; for await (const item of stream) { @@ -170,8 +200,7 @@ describe("wrapStreamFnTrimToolCallNames", () => { }), ); - const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never); - const stream = await wrappedFn({} as never, {} as never, {} as never); + const stream = await invokeWrappedStream(baseFn); const result = await stream.result(); expect(finalToolCall.name).toBe("browser"); @@ -188,10 +217,7 @@ describe("wrapStreamFnTrimToolCallNames", () => { }), ); - const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"])); - const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited< - ReturnType - >; + const stream = await invokeWrappedStream(baseFn, new Set(["exec"])); const result = await stream.result(); expect(finalToolCall.name).toBe("exec"); @@ -205,13 +231,9 @@ describe("wrapStreamFnTrimToolCallNames", () => { type: "toolcall_delta", partial: { role: "assistant", content: [partialToolCall] }, }; - const finalMessage = { role: "assistant", content: [finalToolCall] }; - const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage })); + const { baseFn } = createEventStream({ event, finalToolCall }); - const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never); - const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited< - ReturnType - >; + const stream = await invokeWrappedStream(baseFn); for await (const _item of stream) { // drain @@ -346,18 +368,7 @@ describe("resolveOllamaCompatNumCtxEnabled", () => { it("returns false when provider flag is explicitly disabled", () => { expect( resolveOllamaCompatNumCtxEnabled({ - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - injectNumCtxForOpenAICompat: false, - models: [], - }, - }, - }, - }, + config: createOllamaProviderConfig(false), providerId: "ollama", }), ).toBe(false); @@ -385,18 +396,7 @@ describe("shouldInjectOllamaCompatNumCtx", () => { api: "openai-completions", baseUrl: "http://127.0.0.1:11434/v1", }, - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - api: "openai-completions", - injectNumCtxForOpenAICompat: false, - models: [], - }, - }, - }, - }, + config: createOllamaProviderConfig(false), providerId: "ollama", }), ).toBe(false); diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index bb673898a..8e9defdba 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -36,6 +36,14 @@ function findCallByScriptFragment(fragment: string) { return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment)); } +function dockerExecResult(stdout: string) { + return { + stdout: Buffer.from(stdout), + stderr: Buffer.alloc(0), + code: 0, + }; +} + function createSandbox(overrides?: Partial): SandboxContext { return createSandboxTestContext({ overrides: { @@ -58,38 +66,37 @@ async function withTempDir(prefix: string, run: (stateDir: string) => Promise } } +function installDockerReadMock(params?: { canonicalPath?: string }) { + const canonicalPath = params?.canonicalPath; + mockedExecDockerRaw.mockImplementation(async (args) => { + const script = getDockerScript(args); + if (script.includes('readlink -f -- "$cursor"')) { + return dockerExecResult(`${canonicalPath ?? getDockerArg(args, 1)}\n`); + } + if (script.includes('stat -c "%F|%s|%Y"')) { + return dockerExecResult("regular file|1|2"); + } + if (script.includes('cat -- "$1"')) { + return dockerExecResult("content"); + } + return dockerExecResult(""); + }); +} + +async function createHostEscapeFixture(stateDir: string) { + const workspaceDir = path.join(stateDir, "workspace"); + const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(outsideFile, "classified"); + return { workspaceDir, outsideFile }; +} + describe("sandbox fs bridge shell compatibility", () => { beforeEach(() => { mockedExecDockerRaw.mockClear(); - mockedExecDockerRaw.mockImplementation(async (args) => { - const script = getDockerScript(args); - if (script.includes('readlink -f -- "$cursor"')) { - return { - stdout: Buffer.from(`${getDockerArg(args, 1)}\n`), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (script.includes('stat -c "%F|%s|%Y"')) { - return { - stdout: Buffer.from("regular file|1|2"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (script.includes('cat -- "$1"')) { - return { - stdout: Buffer.from("content"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - code: 0, - }; - }); + installDockerReadMock(); }); it("uses POSIX-safe shell prologue in all bridge commands", async () => { @@ -227,12 +234,7 @@ describe("sandbox fs bridge shell compatibility", () => { it("rejects pre-existing host symlink escapes before docker exec", async () => { await withTempDir("openclaw-fs-bridge-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - const outsideDir = path.join(stateDir, "outside"); - const outsideFile = path.join(outsideDir, "secret.txt"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - await fs.writeFile(outsideFile, "classified"); + const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir); await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt")); const bridge = createSandboxFsBridge({ @@ -252,12 +254,7 @@ describe("sandbox fs bridge shell compatibility", () => { return; } await withTempDir("openclaw-fs-bridge-hardlink-", async (stateDir) => { - const workspaceDir = path.join(stateDir, "workspace"); - const outsideDir = path.join(stateDir, "outside"); - const outsideFile = path.join(outsideDir, "secret.txt"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.mkdir(outsideDir, { recursive: true }); - await fs.writeFile(outsideFile, "classified"); + const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir); const hardlinkPath = path.join(workspaceDir, "link.txt"); try { await fs.link(outsideFile, hardlinkPath); @@ -281,28 +278,7 @@ describe("sandbox fs bridge shell compatibility", () => { }); it("rejects container-canonicalized paths outside allowed mounts", async () => { - mockedExecDockerRaw.mockImplementation(async (args) => { - const script = getDockerScript(args); - if (script.includes('readlink -f -- "$cursor"')) { - return { - stdout: Buffer.from("/etc/passwd\n"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - if (script.includes('cat -- "$1"')) { - return { - stdout: Buffer.from("content"), - stderr: Buffer.alloc(0), - code: 0, - }; - } - return { - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - code: 0, - }; - }); + installDockerReadMock({ canonicalPath: "/etc/passwd" }); const bridge = createSandboxFsBridge({ sandbox: createSandbox() }); await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/escapes allowed mounts/i); diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index daadbca25..2c493fc0d 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -239,6 +239,28 @@ describe("sanitizeToolUseResultPairing", () => { }); describe("sanitizeToolCallInputs", () => { + function sanitizeAssistantContent( + content: unknown[], + options?: Parameters[1], + ) { + return sanitizeToolCallInputs( + [ + { + role: "assistant", + content, + }, + ] as unknown as AgentMessage[], + options, + ); + } + + function sanitizeAssistantToolCalls( + content: unknown[], + options?: Parameters[1], + ) { + return getAssistantToolCallBlocks(sanitizeAssistantContent(content, options)); + } + it("drops tool calls missing input or arguments", () => { const input = [ { @@ -252,71 +274,54 @@ describe("sanitizeToolCallInputs", () => { expect(out.map((m) => m.role)).toEqual(["user"]); }); - it("drops tool calls with missing or blank name/id", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, - { type: "toolCall", id: "call_empty_name", name: "", arguments: {} }, - { type: "toolUse", id: "call_blank_name", name: " ", input: {} }, - { type: "functionCall", id: "", name: "exec", arguments: {} }, - ], - }, - ] as unknown as AgentMessage[]; + it.each([ + { + name: "drops tool calls with missing or blank name/id", + content: [ + { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, + { type: "toolCall", id: "call_empty_name", name: "", arguments: {} }, + { type: "toolUse", id: "call_blank_name", name: " ", input: {} }, + { type: "functionCall", id: "", name: "exec", arguments: {} }, + ], + options: undefined, + expectedIds: ["call_ok"], + }, + { + name: "drops tool calls with malformed or overlong names", + content: [ + { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, + { + type: "toolCall", + id: "call_bad_chars", + name: 'toolu_01abc <|tool_call_argument_begin|> {"command"', + arguments: {}, + }, + { + type: "toolUse", + id: "call_too_long", + name: `read_${"x".repeat(80)}`, + input: {}, + }, + ], + options: undefined, + expectedIds: ["call_ok"], + }, + { + name: "drops unknown tool names when an allowlist is provided", + content: [ + { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, + { type: "toolCall", id: "call_unknown", name: "write", arguments: {} }, + ], + options: { allowedToolNames: ["read"] }, + expectedIds: ["call_ok"], + }, + ])("$name", ({ content, options, expectedIds }) => { + const toolCalls = sanitizeAssistantToolCalls(content, options); + const ids = toolCalls + .map((toolCall) => (toolCall as { id?: unknown }).id) + .filter((id): id is string => typeof id === "string"); - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok"); - }); - - it("drops tool calls with malformed or overlong names", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, - { - type: "toolCall", - id: "call_bad_chars", - name: 'toolu_01abc <|tool_call_argument_begin|> {"command"', - arguments: {}, - }, - { - type: "toolUse", - id: "call_too_long", - name: `read_${"x".repeat(80)}`, - input: {}, - }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); - }); - - it("drops unknown tool names when an allowlist is provided", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_ok", name: "read", arguments: {} }, - { type: "toolCall", id: "call_unknown", name: "write", arguments: {} }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] }); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + expect(ids).toEqual(expectedIds); }); it("keeps valid tool calls and preserves text blocks", () => { @@ -339,71 +344,43 @@ describe("sanitizeToolCallInputs", () => { expect(types).toEqual(["text", "toolUse"]); }); - it("trims leading whitespace from tool names", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); - }); - - it("trims trailing whitespace from tool names", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { name?: unknown }).name).toBe("exec"); - }); - - it("trims both leading and trailing whitespace from tool names", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, - { type: "toolUse", id: "call_2", name: " exec ", input: {} }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(2); - expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); - expect((toolCalls[1] as { name?: unknown }).name).toBe("exec"); - }); - - it("trims tool names and matches against allowlist", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, - { type: "toolCall", id: "call_2", name: " write ", arguments: {} }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] }); - const toolCalls = getAssistantToolCallBlocks(out); - - expect(toolCalls).toHaveLength(1); - expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); + it.each([ + { + name: "trims leading whitespace from tool names", + content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }], + options: undefined, + expectedNames: ["read"], + }, + { + name: "trims trailing whitespace from tool names", + content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }], + options: undefined, + expectedNames: ["exec"], + }, + { + name: "trims both leading and trailing whitespace from tool names", + content: [ + { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, + { type: "toolUse", id: "call_2", name: " exec ", input: {} }, + ], + options: undefined, + expectedNames: ["read", "exec"], + }, + { + name: "trims tool names and matches against allowlist", + content: [ + { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, + { type: "toolCall", id: "call_2", name: " write ", arguments: {} }, + ], + options: { allowedToolNames: ["read"] }, + expectedNames: ["read"], + }, + ])("$name", ({ content, options, expectedNames }) => { + const toolCalls = sanitizeAssistantToolCalls(content, options); + const names = toolCalls + .map((toolCall) => (toolCall as { name?: unknown }).name) + .filter((name): name is string => typeof name === "string"); + expect(names).toEqual(expectedNames); }); it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => { @@ -458,17 +435,9 @@ describe("sanitizeToolCallInputs", () => { expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__"); }); it("preserves other block properties when trimming tool names", () => { - const input = [ - { - role: "assistant", - content: [ - { type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } }, - ], - }, - ] as unknown as AgentMessage[]; - - const out = sanitizeToolCallInputs(input); - const toolCalls = getAssistantToolCallBlocks(out); + const toolCalls = sanitizeAssistantToolCalls([ + { type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } }, + ]); expect(toolCalls).toHaveLength(1); expect((toolCalls[0] as { name?: unknown }).name).toBe("read"); diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 86a490802..fd3abd6d0 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -47,85 +47,93 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin }; } +function createSinglePluginRegistry(params: { + pluginRoot: string; + skills: string[]; +}): PluginManifestRegistry { + return { + diagnostics: [], + plugins: [ + { + id: "helper", + name: "Helper", + channels: [], + providers: [], + skills: params.skills, + origin: "workspace", + rootDir: params.pluginRoot, + source: params.pluginRoot, + manifestPath: path.join(params.pluginRoot, "openclaw.plugin.json"), + }, + ], + }; +} + +async function setupAcpxAndHelperRegistry() { + const workspaceDir = await tempDirs.make("openclaw-"); + const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); + const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); + await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); + await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); + hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot })); + return { workspaceDir, acpxRoot, helperRoot }; +} + +async function setupPluginOutsideSkills() { + const workspaceDir = await tempDirs.make("openclaw-"); + const pluginRoot = await tempDirs.make("openclaw-plugin-"); + const outsideDir = await tempDirs.make("openclaw-outside-"); + const outsideSkills = path.join(outsideDir, "skills"); + return { workspaceDir, pluginRoot, outsideSkills }; +} + afterEach(async () => { hoisted.loadPluginManifestRegistry.mockReset(); await tempDirs.cleanup(); }); describe("resolvePluginSkillDirs", () => { - it("keeps acpx plugin skills when ACP is enabled", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); - const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); - await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); - await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); - - hoisted.loadPluginManifestRegistry.mockReturnValue( - buildRegistry({ - acpxRoot, - helperRoot, - }), - ); + it.each([ + { + name: "keeps acpx plugin skills when ACP is enabled", + acpEnabled: true, + expectedDirs: ({ acpxRoot, helperRoot }: { acpxRoot: string; helperRoot: string }) => [ + path.resolve(acpxRoot, "skills"), + path.resolve(helperRoot, "skills"), + ], + }, + { + name: "skips acpx plugin skills when ACP is disabled", + acpEnabled: false, + expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [ + path.resolve(helperRoot, "skills"), + ], + }, + ])("$name", async ({ acpEnabled, expectedDirs }) => { + const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); const dirs = resolvePluginSkillDirs({ workspaceDir, config: { - acp: { enabled: true }, + acp: { enabled: acpEnabled }, } as OpenClawConfig, }); - expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]); - }); - - it("skips acpx plugin skills when ACP is disabled", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); - const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); - await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); - await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); - - hoisted.loadPluginManifestRegistry.mockReturnValue( - buildRegistry({ - acpxRoot, - helperRoot, - }), - ); - - const dirs = resolvePluginSkillDirs({ - workspaceDir, - config: { - acp: { enabled: false }, - } as OpenClawConfig, - }); - - expect(dirs).toEqual([path.resolve(helperRoot, "skills")]); + expect(dirs).toEqual(expectedDirs({ acpxRoot, helperRoot })); }); it("rejects plugin skill paths that escape the plugin root", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const pluginRoot = await tempDirs.make("openclaw-plugin-"); - const outsideDir = await tempDirs.make("openclaw-outside-"); - const outsideSkills = path.join(outsideDir, "skills"); + const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); await fs.mkdir(outsideSkills, { recursive: true }); const escapePath = path.relative(pluginRoot, outsideSkills); - hoisted.loadPluginManifestRegistry.mockReturnValue({ - diagnostics: [], - plugins: [ - { - id: "helper", - name: "Helper", - channels: [], - providers: [], - skills: ["./skills", escapePath], - origin: "workspace", - rootDir: pluginRoot, - source: pluginRoot, - manifestPath: path.join(pluginRoot, "openclaw.plugin.json"), - }, - ], - } satisfies PluginManifestRegistry); + hoisted.loadPluginManifestRegistry.mockReturnValue( + createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills", escapePath], + }), + ); const dirs = resolvePluginSkillDirs({ workspaceDir, @@ -136,10 +144,7 @@ describe("resolvePluginSkillDirs", () => { }); it("rejects plugin skill symlinks that resolve outside plugin root", async () => { - const workspaceDir = await tempDirs.make("openclaw-"); - const pluginRoot = await tempDirs.make("openclaw-plugin-"); - const outsideDir = await tempDirs.make("openclaw-outside-"); - const outsideSkills = path.join(outsideDir, "skills"); + const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); const linkPath = path.join(pluginRoot, "skills-link"); await fs.mkdir(outsideSkills, { recursive: true }); await fs.symlink( @@ -148,22 +153,12 @@ describe("resolvePluginSkillDirs", () => { process.platform === "win32" ? ("junction" as const) : ("dir" as const), ); - hoisted.loadPluginManifestRegistry.mockReturnValue({ - diagnostics: [], - plugins: [ - { - id: "helper", - name: "Helper", - channels: [], - providers: [], - skills: ["./skills-link"], - origin: "workspace", - rootDir: pluginRoot, - source: pluginRoot, - manifestPath: path.join(pluginRoot, "openclaw.plugin.json"), - }, - ], - } satisfies PluginManifestRegistry); + hoisted.loadPluginManifestRegistry.mockReturnValue( + createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills-link"], + }), + ); const dirs = resolvePluginSkillDirs({ workspaceDir, diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 7f919c4fd..a74af80db 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -1,46 +1,54 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const noop = () => {}; +const MAIN_REQUESTER_SESSION_KEY = "agent:main:main"; +const MAIN_REQUESTER_DISPLAY_KEY = "main"; -let lifecycleHandler: - | ((evt: { - stream?: string; - runId: string; - data?: { - phase?: string; - startedAt?: number; - endedAt?: number; - aborted?: boolean; - error?: string; - }; - }) => void) - | undefined; +type LifecycleData = { + phase?: string; + startedAt?: number; + endedAt?: number; + aborted?: boolean; + error?: string; +}; +type LifecycleEvent = { + stream?: string; + runId: string; + data?: LifecycleData; +}; + +let lifecycleHandler: ((evt: LifecycleEvent) => void) | undefined; +const callGatewayMock = vi.fn(async (request: unknown) => { + const method = (request as { method?: string }).method; + if (method === "agent.wait") { + // Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised. + return { status: "pending" }; + } + return {}; +}); +const onAgentEventMock = vi.fn((handler: typeof lifecycleHandler) => { + lifecycleHandler = handler; + return noop; +}); +const loadConfigMock = vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, +})); +const loadRegistryMock = vi.fn(() => new Map()); +const saveRegistryMock = vi.fn(() => {}); +const announceSpy = vi.fn(async () => true); vi.mock("../gateway/call.js", () => ({ - callGateway: vi.fn(async (request: unknown) => { - const method = (request as { method?: string }).method; - if (method === "agent.wait") { - // Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised. - return { status: "pending" }; - } - return {}; - }), + callGateway: callGatewayMock, })); vi.mock("../infra/agent-events.js", () => ({ - onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => { - lifecycleHandler = handler; - return noop; - }), + onAgentEvent: onAgentEventMock, })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ - agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, - })), + loadConfig: loadConfigMock, })); -const announceSpy = vi.fn(async () => true); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: announceSpy, })); @@ -50,8 +58,8 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })); vi.mock("./subagent-registry.store.js", () => ({ - loadSubagentRegistryFromDisk: vi.fn(() => new Map()), - saveSubagentRegistryToDisk: vi.fn(() => {}), + loadSubagentRegistryFromDisk: loadRegistryMock, + saveSubagentRegistryToDisk: saveRegistryMock, })); describe("subagent registry lifecycle error grace", () => { @@ -77,21 +85,41 @@ describe("subagent registry lifecycle error grace", () => { await Promise.resolve(); }; - it("ignores transient lifecycle errors when run retries and then ends successfully", async () => { + function registerCompletionRun(runId: string, childSuffix: string, task: string) { mod.registerSubagentRun({ - runId: "run-transient-error", - childSessionKey: "agent:main:subagent:transient-error", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "transient error test", + runId, + childSessionKey: `agent:main:subagent:${childSuffix}`, + requesterSessionKey: MAIN_REQUESTER_SESSION_KEY, + requesterDisplayKey: MAIN_REQUESTER_DISPLAY_KEY, + task, cleanup: "keep", expectsCompletionMessage: true, }); + } + function emitLifecycleEvent(runId: string, data: LifecycleData) { lifecycleHandler?.({ stream: "lifecycle", - runId: "run-transient-error", - data: { phase: "error", error: "rate limit", endedAt: 1_000 }, + runId, + data, + }); + } + + function readFirstAnnounceOutcome() { + const announceCalls = announceSpy.mock.calls as unknown as Array>; + const first = (announceCalls[0]?.[0] ?? {}) as { + outcome?: { status?: string; error?: string }; + }; + return first.outcome; + } + + it("ignores transient lifecycle errors when run retries and then ends successfully", async () => { + registerCompletionRun("run-transient-error", "transient-error", "transient error test"); + + emitLifecycleEvent("run-transient-error", { + phase: "error", + error: "rate limit", + endedAt: 1_000, }); await flushAsync(); expect(announceSpy).not.toHaveBeenCalled(); @@ -99,46 +127,26 @@ describe("subagent registry lifecycle error grace", () => { await vi.advanceTimersByTimeAsync(14_999); expect(announceSpy).not.toHaveBeenCalled(); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-transient-error", - data: { phase: "start", startedAt: 1_050 }, - }); + emitLifecycleEvent("run-transient-error", { phase: "start", startedAt: 1_050 }); await flushAsync(); await vi.advanceTimersByTimeAsync(20_000); expect(announceSpy).not.toHaveBeenCalled(); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-transient-error", - data: { phase: "end", endedAt: 1_250 }, - }); + emitLifecycleEvent("run-transient-error", { phase: "end", endedAt: 1_250 }); await flushAsync(); expect(announceSpy).toHaveBeenCalledTimes(1); - const announceCalls = announceSpy.mock.calls as unknown as Array>; - const first = (announceCalls[0]?.[0] ?? {}) as { - outcome?: { status?: string; error?: string }; - }; - expect(first.outcome?.status).toBe("ok"); + expect(readFirstAnnounceOutcome()?.status).toBe("ok"); }); it("announces error when lifecycle error remains terminal after grace window", async () => { - mod.registerSubagentRun({ - runId: "run-terminal-error", - childSessionKey: "agent:main:subagent:terminal-error", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "terminal error test", - cleanup: "keep", - expectsCompletionMessage: true, - }); + registerCompletionRun("run-terminal-error", "terminal-error", "terminal error test"); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-terminal-error", - data: { phase: "error", error: "fatal failure", endedAt: 2_000 }, + emitLifecycleEvent("run-terminal-error", { + phase: "error", + error: "fatal failure", + endedAt: 2_000, }); await flushAsync(); expect(announceSpy).not.toHaveBeenCalled(); @@ -147,11 +155,7 @@ describe("subagent registry lifecycle error grace", () => { await flushAsync(); expect(announceSpy).toHaveBeenCalledTimes(1); - const announceCalls = announceSpy.mock.calls as unknown as Array>; - const first = (announceCalls[0]?.[0] ?? {}) as { - outcome?: { status?: string; error?: string }; - }; - expect(first.outcome?.status).toBe("error"); - expect(first.outcome?.error).toBe("fatal failure"); + expect(readFirstAnnounceOutcome()?.status).toBe("error"); + expect(readFirstAnnounceOutcome()?.error).toBe("fatal failure"); }); }); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 2f200535c..28933d58d 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -84,6 +84,8 @@ vi.mock("./subagent-registry.store.js", () => ({ describe("subagent registry steer restarts", () => { let mod: typeof import("./subagent-registry.js"); type RegisterSubagentRunInput = Parameters[0]; + const MAIN_REQUESTER_SESSION_KEY = "agent:main:main"; + const MAIN_REQUESTER_DISPLAY_KEY = "main"; beforeAll(async () => { mod = await import("./subagent-registry.js"); @@ -135,23 +137,65 @@ describe("subagent registry steer restarts", () => { task: string, options: Partial> = {}, ): void => { - mod.registerSubagentRun({ + registerRun({ runId, childSessionKey, - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", + task, + expectsCompletionMessage: true, requesterOrigin: { channel: "discord", to: "channel:123", accountId: "work", }, - task, - cleanup: "keep", - expectsCompletionMessage: true, ...options, }); }; + const registerRun = ( + params: { + runId: string; + childSessionKey: string; + task: string; + requesterSessionKey?: string; + requesterDisplayKey?: string; + } & Partial< + Pick + >, + ): void => { + mod.registerSubagentRun({ + runId: params.runId, + childSessionKey: params.childSessionKey, + requesterSessionKey: params.requesterSessionKey ?? MAIN_REQUESTER_SESSION_KEY, + requesterDisplayKey: params.requesterDisplayKey ?? MAIN_REQUESTER_DISPLAY_KEY, + requesterOrigin: params.requesterOrigin, + task: params.task, + cleanup: "keep", + spawnMode: params.spawnMode, + expectsCompletionMessage: params.expectsCompletionMessage, + }); + }; + + const listMainRuns = () => mod.listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY); + + const emitLifecycleEnd = ( + runId: string, + data: { + startedAt?: number; + endedAt?: number; + aborted?: boolean; + error?: string; + } = {}, + ) => { + lifecycleHandler?.({ + stream: "lifecycle", + runId, + data: { + phase: "end", + ...data, + }, + }); + }; + afterEach(async () => { announceSpy.mockClear(); announceSpy.mockResolvedValue(true); @@ -161,26 +205,19 @@ describe("subagent registry steer restarts", () => { }); it("suppresses announce for interrupted runs and only announces the replacement run", async () => { - mod.registerSubagentRun({ + registerRun({ runId: "run-old", childSessionKey: "agent:main:subagent:steer", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "initial task", - cleanup: "keep", }); - const previous = mod.listSubagentRunsForRequester("agent:main:main")[0]; + const previous = listMainRuns()[0]; expect(previous?.runId).toBe("run-old"); const marked = mod.markSubagentRunForSteerRestart("run-old"); expect(marked).toBe(true); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-old", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-old"); await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); @@ -193,15 +230,11 @@ describe("subagent registry steer restarts", () => { }); expect(replaced).toBe(true); - const runs = mod.listSubagentRunsForRequester("agent:main:main"); + const runs = listMainRuns(); expect(runs).toHaveLength(1); expect(runs[0].runId).toBe("run-new"); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-new", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-new"); await flushAnnounce(); expect(announceSpy).toHaveBeenCalledTimes(1); @@ -228,11 +261,7 @@ describe("subagent registry steer restarts", () => { "completion-mode task", ); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-completion-delayed", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-completion-delayed"); await flushAnnounce(); expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); @@ -249,7 +278,7 @@ describe("subagent registry steer restarts", () => { }), expect.objectContaining({ runId: "run-completion-delayed", - requesterSessionKey: "agent:main:main", + requesterSessionKey: MAIN_REQUESTER_SESSION_KEY, }), ); }); @@ -265,11 +294,7 @@ describe("subagent registry steer restarts", () => { { spawnMode: "session" }, ); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-persistent-session", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-persistent-session"); await flushAnnounce(); expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); @@ -278,7 +303,7 @@ describe("subagent registry steer restarts", () => { await flushAnnounce(); expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); - const run = mod.listSubagentRunsForRequester("agent:main:main")[0]; + const run = listMainRuns()[0]; expect(run?.runId).toBe("run-persistent-session"); expect(run?.cleanupCompletedAt).toBeTypeOf("number"); expect(run?.endedHookEmittedAt).toBeUndefined(); @@ -286,16 +311,13 @@ describe("subagent registry steer restarts", () => { }); it("clears announce retry state when replacing after steer restart", () => { - mod.registerSubagentRun({ + registerRun({ runId: "run-retry-reset-old", childSessionKey: "agent:main:subagent:retry-reset", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "retry reset", - cleanup: "keep", }); - const previous = mod.listSubagentRunsForRequester("agent:main:main")[0]; + const previous = listMainRuns()[0]; expect(previous?.runId).toBe("run-retry-reset-old"); if (previous) { previous.announceRetryCount = 2; @@ -309,7 +331,7 @@ describe("subagent registry steer restarts", () => { }); expect(replaced).toBe(true); - const runs = mod.listSubagentRunsForRequester("agent:main:main"); + const runs = listMainRuns(); expect(runs).toHaveLength(1); expect(runs[0].runId).toBe("run-retry-reset-new"); expect(runs[0].announceRetryCount).toBeUndefined(); @@ -317,16 +339,13 @@ describe("subagent registry steer restarts", () => { }); it("clears terminal lifecycle state when replacing after steer restart", async () => { - mod.registerSubagentRun({ + registerRun({ runId: "run-terminal-state-old", childSessionKey: "agent:main:subagent:terminal-state", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "terminal state", - cleanup: "keep", }); - const previous = mod.listSubagentRunsForRequester("agent:main:main")[0]; + const previous = listMainRuns()[0]; expect(previous?.runId).toBe("run-terminal-state-old"); if (previous) { previous.endedHookEmittedAt = Date.now(); @@ -342,17 +361,13 @@ describe("subagent registry steer restarts", () => { }); expect(replaced).toBe(true); - const runs = mod.listSubagentRunsForRequester("agent:main:main"); + const runs = listMainRuns(); expect(runs).toHaveLength(1); expect(runs[0].runId).toBe("run-terminal-state-new"); expect(runs[0].endedHookEmittedAt).toBeUndefined(); expect(runs[0].endedReason).toBeUndefined(); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-terminal-state-new", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-terminal-state-new"); await flushAnnounce(); expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); @@ -367,22 +382,15 @@ describe("subagent registry steer restarts", () => { }); it("restores announce for a finished run when steer replacement dispatch fails", async () => { - mod.registerSubagentRun({ + registerRun({ runId: "run-failed-restart", childSessionKey: "agent:main:subagent:failed-restart", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "initial task", - cleanup: "keep", }); expect(mod.markSubagentRunForSteerRestart("run-failed-restart")).toBe(true); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-failed-restart", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-failed-restart"); await flushAnnounce(); expect(announceSpy).not.toHaveBeenCalled(); @@ -398,13 +406,10 @@ describe("subagent registry steer restarts", () => { it("marks killed runs terminated and inactive", async () => { const childSessionKey = "agent:main:subagent:killed"; - mod.registerSubagentRun({ + registerRun({ runId: "run-killed", childSessionKey, - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "kill me", - cleanup: "keep", }); expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(true); @@ -415,7 +420,7 @@ describe("subagent registry steer restarts", () => { expect(updated).toBe(1); expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false); - const run = mod.listSubagentRunsForRequester("agent:main:main")[0]; + const run = listMainRuns()[0]; expect(run?.outcome).toEqual({ status: "error", error: "manual kill" }); expect(run?.cleanupHandled).toBe(true); expect(typeof run?.cleanupCompletedAt).toBe("number"); @@ -434,7 +439,7 @@ describe("subagent registry steer restarts", () => { { runId: "run-killed", childSessionKey, - requesterSessionKey: "agent:main:main", + requesterSessionKey: MAIN_REQUESTER_SESSION_KEY, }, ); }); @@ -450,35 +455,23 @@ describe("subagent registry steer restarts", () => { return true; }); - mod.registerSubagentRun({ + registerRun({ runId: "run-parent", childSessionKey: "agent:main:subagent:parent", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", task: "parent task", - cleanup: "keep", }); - mod.registerSubagentRun({ + registerRun({ runId: "run-child", childSessionKey: "agent:main:subagent:parent:subagent:child", requesterSessionKey: "agent:main:subagent:parent", requesterDisplayKey: "parent", task: "child task", - cleanup: "keep", }); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-parent", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-parent"); await flushAnnounce(); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-child", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-child"); await flushAnnounce(); const childRunIds = announceSpy.mock.calls.map( @@ -494,43 +487,33 @@ describe("subagent registry steer restarts", () => { try { announceSpy.mockResolvedValue(false); - mod.registerSubagentRun({ - runId: "run-completion-retry", - childSessionKey: "agent:main:subagent:completion", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "completion retry", - cleanup: "keep", - expectsCompletionMessage: true, - }); + registerCompletionModeRun( + "run-completion-retry", + "agent:main:subagent:completion", + "completion retry", + ); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-completion-retry", - data: { phase: "end" }, - }); + emitLifecycleEnd("run-completion-retry"); await vi.advanceTimersByTimeAsync(0); expect(announceSpy).toHaveBeenCalledTimes(1); - expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(1); + expect(listMainRuns()[0]?.announceRetryCount).toBe(1); await vi.advanceTimersByTimeAsync(999); expect(announceSpy).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(announceSpy).toHaveBeenCalledTimes(2); - expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(2); + expect(listMainRuns()[0]?.announceRetryCount).toBe(2); await vi.advanceTimersByTimeAsync(1_999); expect(announceSpy).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(1); expect(announceSpy).toHaveBeenCalledTimes(3); - expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(3); + expect(listMainRuns()[0]?.announceRetryCount).toBe(3); await vi.advanceTimersByTimeAsync(4_001); expect(announceSpy).toHaveBeenCalledTimes(3); - expect( - mod.listSubagentRunsForRequester("agent:main:main")[0]?.cleanupCompletedAt, - ).toBeTypeOf("number"); + expect(listMainRuns()[0]?.cleanupCompletedAt).toBeTypeOf("number"); } finally { vi.useRealTimers(); } @@ -540,32 +523,22 @@ describe("subagent registry steer restarts", () => { it("keeps completion cleanup pending while descendants are still active", async () => { announceSpy.mockResolvedValue(false); - mod.registerSubagentRun({ - runId: "run-parent-expiry", - childSessionKey: "agent:main:subagent:parent-expiry", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "parent completion expiry", - cleanup: "keep", - expectsCompletionMessage: true, - }); - mod.registerSubagentRun({ + registerCompletionModeRun( + "run-parent-expiry", + "agent:main:subagent:parent-expiry", + "parent completion expiry", + ); + registerRun({ runId: "run-child-active", childSessionKey: "agent:main:subagent:parent-expiry:subagent:child-active", requesterSessionKey: "agent:main:subagent:parent-expiry", requesterDisplayKey: "parent-expiry", task: "child still running", - cleanup: "keep", }); - lifecycleHandler?.({ - stream: "lifecycle", - runId: "run-parent-expiry", - data: { - phase: "end", - startedAt: Date.now() - 7 * 60_000, - endedAt: Date.now() - 6 * 60_000, - }, + emitLifecycleEnd("run-parent-expiry", { + startedAt: Date.now() - 7 * 60_000, + endedAt: Date.now() - 6 * 60_000, }); await flushAnnounce(); @@ -576,7 +549,7 @@ describe("subagent registry steer restarts", () => { }); expect(parentHookCall).toBeUndefined(); const parent = mod - .listSubagentRunsForRequester("agent:main:main") + .listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY) .find((entry) => entry.runId === "run-parent-expiry"); expect(parent?.cleanupCompletedAt).toBeUndefined(); expect(parent?.cleanupHandled).toBe(false); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 86636dced..3f08e2c3c 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -40,6 +40,58 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } +function createChannelPlugin(params: { + id: string; + label: string; + docsPath: string; + blurb: string; + actions: string[]; + supportsButtons?: boolean; + messaging?: ChannelPlugin["messaging"]; +}): ChannelPlugin { + return { + id: params.id as ChannelPlugin["id"], + meta: { + id: params.id as ChannelPlugin["id"], + label: params.label, + selectionLabel: params.label, + docsPath: params.docsPath, + blurb: params.blurb, + }, + capabilities: { chatTypes: ["direct", "group"], media: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + ...(params.messaging ? { messaging: params.messaging } : {}), + actions: { + listActions: () => params.actions as never, + ...(params.supportsButtons ? { supportsButtons: () => true } : {}), + }, + }; +} + +async function executeSend(params: { + action: Record; + toolOptions?: Partial[0]>; +}) { + const tool = createMessageTool({ + config: {} as never, + ...params.toolOptions, + }); + await tool.execute("1", { + action: "send", + ...params.action, + }); + return mocks.runMessageAction.mock.calls[0]?.[0] as + | { + params?: Record; + sandboxRoot?: string; + requesterSenderId?: string; + } + | undefined; +} + describe("message tool agent routing", () => { it("derives agentId from the session key", async () => { mockSendResult(); @@ -62,141 +114,103 @@ describe("message tool agent routing", () => { }); describe("message tool path passthrough", () => { - it("does not convert path to media for send", async () => { + it.each([ + { field: "path", value: "~/Downloads/voice.ogg" }, + { field: "filePath", value: "./tmp/note.m4a" }, + ])("does not convert $field to media for send", async ({ field, value }) => { mockSendResult({ to: "telegram:123" }); - const tool = createMessageTool({ - config: {} as never, + const call = await executeSend({ + action: { + target: "telegram:123", + [field]: value, + message: "", + }, }); - await tool.execute("1", { - action: "send", - target: "telegram:123", - path: "~/Downloads/voice.ogg", - message: "", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.params?.path).toBe("~/Downloads/voice.ogg"); - expect(call?.params?.media).toBeUndefined(); - }); - - it("does not convert filePath to media for send", async () => { - mockSendResult({ to: "telegram:123" }); - - const tool = createMessageTool({ - config: {} as never, - }); - - await tool.execute("1", { - action: "send", - target: "telegram:123", - filePath: "./tmp/note.m4a", - message: "", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.params?.filePath).toBe("./tmp/note.m4a"); + expect(call?.params?.[field]).toBe(value); expect(call?.params?.media).toBeUndefined(); }); }); describe("message tool schema scoping", () => { - const telegramPlugin: ChannelPlugin = { + const telegramPlugin = createChannelPlugin({ id: "telegram", - meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - actions: { - listActions: () => ["send", "react"] as const, - supportsButtons: () => true, - }, - }; + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send", "react"], + supportsButtons: true, + }); - const discordPlugin: ChannelPlugin = { + const discordPlugin = createChannelPlugin({ id: "discord", - meta: { - id: "discord", - label: "Discord", - selectionLabel: "Discord", - docsPath: "/channels/discord", - blurb: "Discord test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - actions: { - listActions: () => ["send", "poll"] as const, - }, - }; + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: ["send", "poll"], + }); afterEach(() => { setActivePluginRegistry(createTestRegistry([])); }); - it("hides discord components when scoped to telegram", () => { - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "telegram", source: "test", plugin: telegramPlugin }, - { pluginId: "discord", source: "test", plugin: discordPlugin }, - ]), - ); + it.each([ + { + provider: "telegram", + expectComponents: false, + expectButtons: true, + expectButtonStyle: true, + expectedActions: ["send", "react", "poll"], + }, + { + provider: "discord", + expectComponents: true, + expectButtons: false, + expectButtonStyle: false, + expectedActions: ["send", "poll", "react"], + }, + ])( + "scopes schema fields for $provider", + ({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "telegram", source: "test", plugin: telegramPlugin }, + { pluginId: "discord", source: "test", plugin: discordPlugin }, + ]), + ); - const tool = createMessageTool({ - config: {} as never, - currentChannelProvider: "telegram", - }); - const properties = getToolProperties(tool); - const actionEnum = getActionEnum(properties); + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: provider, + }); + const properties = getToolProperties(tool); + const actionEnum = getActionEnum(properties); - expect(properties.components).toBeUndefined(); - expect(properties.buttons).toBeDefined(); - const buttonItemProps = - ( - properties.buttons as { - items?: { items?: { properties?: Record } }; - } - )?.items?.items?.properties ?? {}; - expect(buttonItemProps.style).toBeDefined(); - expect(actionEnum).toContain("send"); - expect(actionEnum).toContain("react"); - // Other channels' actions are included so isolated/cron agents can use them - expect(actionEnum).toContain("poll"); - }); - - it("shows discord components when scoped to discord", () => { - setActivePluginRegistry( - createTestRegistry([ - { pluginId: "telegram", source: "test", plugin: telegramPlugin }, - { pluginId: "discord", source: "test", plugin: discordPlugin }, - ]), - ); - - const tool = createMessageTool({ - config: {} as never, - currentChannelProvider: "discord", - }); - const properties = getToolProperties(tool); - const actionEnum = getActionEnum(properties); - - expect(properties.components).toBeDefined(); - expect(properties.buttons).toBeUndefined(); - expect(actionEnum).toContain("send"); - expect(actionEnum).toContain("poll"); - // Other channels' actions are included so isolated/cron agents can use them - expect(actionEnum).toContain("react"); - }); + if (expectComponents) { + expect(properties.components).toBeDefined(); + } else { + expect(properties.components).toBeUndefined(); + } + if (expectButtons) { + expect(properties.buttons).toBeDefined(); + } else { + expect(properties.buttons).toBeUndefined(); + } + if (expectButtonStyle) { + const buttonItemProps = + ( + properties.buttons as { + items?: { items?: { properties?: Record } }; + } + )?.items?.items?.properties ?? {}; + expect(buttonItemProps.style).toBeDefined(); + } + for (const action of expectedActions) { + expect(actionEnum).toContain(action); + } + }, + ); }); describe("message tool description", () => { @@ -204,20 +218,12 @@ describe("message tool description", () => { setActivePluginRegistry(createTestRegistry([])); }); - const bluebubblesPlugin: ChannelPlugin = { + const bluebubblesPlugin = createChannelPlugin({ id: "bluebubbles", - meta: { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles", - docsPath: "/channels/bluebubbles", - blurb: "BlueBubbles test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, + label: "BlueBubbles", + docsPath: "/channels/bluebubbles", + blurb: "BlueBubbles test plugin.", + actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"], messaging: { normalizeTarget: (raw) => { const trimmed = raw.trim().replace(/^bluebubbles:/i, ""); @@ -233,11 +239,7 @@ describe("message tool description", () => { return trimmed; }, }, - actions: { - listActions: () => - ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"] as const, - }, - }; + }); it("hides BlueBubbles group actions for DM targets", () => { setActivePluginRegistry( @@ -257,43 +259,21 @@ describe("message tool description", () => { }); it("includes other configured channels when currentChannel is set", () => { - const signalPlugin: ChannelPlugin = { + const signalPlugin = createChannelPlugin({ id: "signal", - meta: { - id: "signal", - label: "Signal", - selectionLabel: "Signal", - docsPath: "/channels/signal", - blurb: "Signal test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - actions: { - listActions: () => ["send", "react"] as const, - }, - }; + label: "Signal", + docsPath: "/channels/signal", + blurb: "Signal test plugin.", + actions: ["send", "react"], + }); - const telegramPluginFull: ChannelPlugin = { + const telegramPluginFull = createChannelPlugin({ id: "telegram", - meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - actions: { - listActions: () => ["send", "react", "delete", "edit", "topic-create"] as const, - }, - }; + label: "Telegram", + docsPath: "/channels/telegram", + blurb: "Telegram test plugin.", + actions: ["send", "react", "delete", "edit", "topic-create"], + }); setActivePluginRegistry( createTestRegistry([ @@ -330,103 +310,80 @@ describe("message tool description", () => { }); describe("message tool reasoning tag sanitization", () => { - it("strips tags from text field before sending", async () => { - mockSendResult({ channel: "signal", to: "signal:+15551234567" }); - - const tool = createMessageTool({ config: {} as never }); - - await tool.execute("1", { - action: "send", + it.each([ + { + field: "text", + input: "internal reasoningHello!", + expected: "Hello!", target: "signal:+15551234567", - text: "internal reasoningHello!", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.params?.text).toBe("Hello!"); - }); - - it("strips tags from content field before sending", async () => { - mockSendResult({ channel: "discord", to: "discord:123" }); - - const tool = createMessageTool({ config: {} as never }); - - await tool.execute("1", { - action: "send", + channel: "signal", + }, + { + field: "content", + input: "reasoning hereReply text", + expected: "Reply text", target: "discord:123", - content: "reasoning hereReply text", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.params?.content).toBe("Reply text"); - }); - - it("passes through text without reasoning tags unchanged", async () => { - mockSendResult({ channel: "signal", to: "signal:+15551234567" }); - - const tool = createMessageTool({ config: {} as never }); - - await tool.execute("1", { - action: "send", + channel: "discord", + }, + { + field: "text", + input: "Normal message without any tags", + expected: "Normal message without any tags", target: "signal:+15551234567", - text: "Normal message without any tags", - }); + channel: "signal", + }, + ])( + "sanitizes reasoning tags in $field before sending", + async ({ channel, target, field, input, expected }) => { + mockSendResult({ channel, to: target }); - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.params?.text).toBe("Normal message without any tags"); - }); + const call = await executeSend({ + action: { + target, + [field]: input, + }, + }); + expect(call?.params?.[field]).toBe(expected); + }, + ); }); describe("message tool sandbox passthrough", () => { - it("forwards sandboxRoot to runMessageAction", async () => { + it.each([ + { + name: "forwards sandboxRoot to runMessageAction", + toolOptions: { sandboxRoot: "/tmp/sandbox" }, + expected: "/tmp/sandbox", + }, + { + name: "omits sandboxRoot when not configured", + toolOptions: {}, + expected: undefined, + }, + ])("$name", async ({ toolOptions, expected }) => { mockSendResult({ to: "telegram:123" }); - const tool = createMessageTool({ - config: {} as never, - sandboxRoot: "/tmp/sandbox", + const call = await executeSend({ + toolOptions, + action: { + target: "telegram:123", + message: "", + }, }); - - await tool.execute("1", { - action: "send", - target: "telegram:123", - message: "", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.sandboxRoot).toBe("/tmp/sandbox"); - }); - - it("omits sandboxRoot when not configured", async () => { - mockSendResult({ to: "telegram:123" }); - - const tool = createMessageTool({ - config: {} as never, - }); - - await tool.execute("1", { - action: "send", - target: "telegram:123", - message: "", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; - expect(call?.sandboxRoot).toBeUndefined(); + expect(call?.sandboxRoot).toBe(expected); }); it("forwards trusted requesterSenderId to runMessageAction", async () => { mockSendResult({ to: "discord:123" }); - const tool = createMessageTool({ - config: {} as never, - requesterSenderId: "1234567890", + const call = await executeSend({ + toolOptions: { requesterSenderId: "1234567890" }, + action: { + target: "discord:123", + message: "hi", + }, }); - await tool.execute("1", { - action: "send", - target: "discord:123", - message: "hi", - }); - - const call = mocks.runMessageAction.mock.calls[0]?.[0]; expect(call?.requesterSenderId).toBe("1234567890"); }); }); diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 0d381a3e4..aa831027f 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -35,6 +35,10 @@ import { createSessionsSendTool } from "./sessions-send-tool.js"; let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"]; let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"]; +const MAIN_AGENT_SESSION_KEY = "agent:main:main"; +const MAIN_AGENT_CHANNEL = "whatsapp"; + +type SessionsListResult = Awaited["execute"]>>; const installRegistry = async () => { setActivePluginRegistry( @@ -82,6 +86,52 @@ const installRegistry = async () => { ); }; +function createMainSessionsListTool() { + return createSessionsListTool({ agentSessionKey: MAIN_AGENT_SESSION_KEY }); +} + +async function executeMainSessionsList() { + return createMainSessionsListTool().execute("call1", {}); +} + +function createMainSessionsSendTool() { + return createSessionsSendTool({ + agentSessionKey: MAIN_AGENT_SESSION_KEY, + agentChannel: MAIN_AGENT_CHANNEL, + }); +} + +function getFirstListedSession(result: SessionsListResult) { + const details = result.details as + | { sessions?: Array<{ key?: string; transcriptPath?: string }> } + | undefined; + return details?.sessions?.[0]; +} + +function expectWorkerTranscriptPath( + result: SessionsListResult, + params: { containsPath: string; sessionId: string }, +) { + const session = getFirstListedSession(result); + expect(session).toMatchObject({ key: "agent:worker:main" }); + const transcriptPath = String(session?.transcriptPath ?? ""); + expect(path.normalize(transcriptPath)).toContain(path.normalize(params.containsPath)); + expect(transcriptPath).toMatch(new RegExp(`${params.sessionId}\\.jsonl$`)); +} + +async function withStubbedStateDir( + name: string, + run: (stateDir: string) => Promise, +): Promise { + const stateDir = path.join(os.tmpdir(), name); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + try { + return await run(stateDir); + } finally { + vi.unstubAllEnvs(); + } +} + describe("sanitizeTextContent", () => { it("strips minimax tool call XML and downgraded markers", () => { const input = @@ -209,11 +259,11 @@ describe("sessions_list gating", () => { }); it("filters out other agents when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); + const tool = createMainSessionsListTool(); const result = await tool.execute("call1", {}); expect(result.details).toMatchObject({ count: 1, - sessions: [{ key: "agent:main:main" }], + sessions: [{ key: MAIN_AGENT_SESSION_KEY }], }); }); }); @@ -231,10 +281,7 @@ describe("sessions_list transcriptPath resolution", () => { }); it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => { - const stateDir = path.join(os.tmpdir(), "openclaw-state-relative"); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - - try { + await withStubbedStateDir("openclaw-state-relative", async () => { callGatewayMock.mockResolvedValueOnce({ path: "agents/main/sessions/sessions.json", sessions: [ @@ -246,27 +293,16 @@ describe("sessions_list transcriptPath resolution", () => { ], }); - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - - const details = result.details as - | { sessions?: Array<{ key?: string; transcriptPath?: string }> } - | undefined; - const session = details?.sessions?.[0]; - expect(session).toMatchObject({ key: "agent:worker:main" }); - const transcriptPath = String(session?.transcriptPath ?? ""); - expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); - expect(transcriptPath).toMatch(/sess-worker\.jsonl$/); - } finally { - vi.unstubAllEnvs(); - } + const result = await executeMainSessionsList(); + expectWorkerTranscriptPath(result, { + containsPath: path.join("agents", "worker", "sessions"), + sessionId: "sess-worker", + }); + }); }); it("resolves transcriptPath even when sessions.list does not return a store path", async () => { - const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path"); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - - try { + await withStubbedStateDir("openclaw-state-no-path", async () => { callGatewayMock.mockResolvedValueOnce({ sessions: [ { @@ -277,27 +313,16 @@ describe("sessions_list transcriptPath resolution", () => { ], }); - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - - const details = result.details as - | { sessions?: Array<{ key?: string; transcriptPath?: string }> } - | undefined; - const session = details?.sessions?.[0]; - expect(session).toMatchObject({ key: "agent:worker:main" }); - const transcriptPath = String(session?.transcriptPath ?? ""); - expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); - expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/); - } finally { - vi.unstubAllEnvs(); - } + const result = await executeMainSessionsList(); + expectWorkerTranscriptPath(result, { + containsPath: path.join("agents", "worker", "sessions"), + sessionId: "sess-worker-no-path", + }); + }); }); it("falls back to agent defaults when gateway path is non-string", async () => { - const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path"); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - - try { + await withStubbedStateDir("openclaw-state-non-string-path", async () => { callGatewayMock.mockResolvedValueOnce({ path: { raw: "agents/main/sessions/sessions.json" }, sessions: [ @@ -309,27 +334,16 @@ describe("sessions_list transcriptPath resolution", () => { ], }); - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - - const details = result.details as - | { sessions?: Array<{ key?: string; transcriptPath?: string }> } - | undefined; - const session = details?.sessions?.[0]; - expect(session).toMatchObject({ key: "agent:worker:main" }); - const transcriptPath = String(session?.transcriptPath ?? ""); - expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); - expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/); - } finally { - vi.unstubAllEnvs(); - } + const result = await executeMainSessionsList(); + expectWorkerTranscriptPath(result, { + containsPath: path.join("agents", "worker", "sessions"), + sessionId: "sess-worker-shape", + }); + }); }); it("falls back to agent defaults when gateway path is '(multiple)'", async () => { - const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple"); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - - try { + await withStubbedStateDir("openclaw-state-multiple", async (stateDir) => { callGatewayMock.mockResolvedValueOnce({ path: "(multiple)", sessions: [ @@ -341,22 +355,12 @@ describe("sessions_list transcriptPath resolution", () => { ], }); - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - - const details = result.details as - | { sessions?: Array<{ key?: string; transcriptPath?: string }> } - | undefined; - const session = details?.sessions?.[0]; - expect(session).toMatchObject({ key: "agent:worker:main" }); - const transcriptPath = String(session?.transcriptPath ?? ""); - expect(path.normalize(transcriptPath)).toContain( - path.join(stateDir, "agents", "worker", "sessions"), - ); - expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/); - } finally { - vi.unstubAllEnvs(); - } + const result = await executeMainSessionsList(); + expectWorkerTranscriptPath(result, { + containsPath: path.join(stateDir, "agents", "worker", "sessions"), + sessionId: "sess-worker-multiple", + }); + }); }); it("resolves absolute {agentId} template paths per session agent", async () => { @@ -373,18 +377,12 @@ describe("sessions_list transcriptPath resolution", () => { ], }); - const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); - const result = await tool.execute("call1", {}); - - const details = result.details as - | { sessions?: Array<{ key?: string; transcriptPath?: string }> } - | undefined; - const session = details?.sessions?.[0]; - expect(session).toMatchObject({ key: "agent:worker:main" }); - const transcriptPath = String(session?.transcriptPath ?? ""); + const result = await executeMainSessionsList(); const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker")); - expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir)); - expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/); + expectWorkerTranscriptPath(result, { + containsPath: expectedSessionsDir, + sessionId: "sess-worker-template", + }); }); }); @@ -394,10 +392,7 @@ describe("sessions_send gating", () => { }); it("returns an error when neither sessionKey nor label is provided", async () => { - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); + const tool = createMainSessionsSendTool(); const result = await tool.execute("call-missing-target", { message: "hi", @@ -413,10 +408,7 @@ describe("sessions_send gating", () => { it("returns an error when label resolution fails", async () => { callGatewayMock.mockRejectedValueOnce(new Error("No session found with label: nope")); - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); + const tool = createMainSessionsSendTool(); const result = await tool.execute("call-missing-label", { label: "nope", @@ -435,10 +427,7 @@ describe("sessions_send gating", () => { }); it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { - const tool = createSessionsSendTool({ - agentSessionKey: "agent:main:main", - agentChannel: "whatsapp", - }); + const tool = createMainSessionsSendTool(); const result = await tool.execute("call1", { sessionKey: "agent:other:main", diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index ac236e3c0..14302629a 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -44,18 +44,41 @@ async function readOnboardingState(dir: string): Promise<{ }; } +async function expectBootstrapSeeded(dir: string) { + await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined(); + const state = await readOnboardingState(dir); + expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/); +} + +async function expectCompletedWithoutBootstrap(dir: string) { + await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({ + code: "ENOENT", + }); + const state = await readOnboardingState(dir); + expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); +} + +function expectSubagentAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) { + const names = files.map((file) => file.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); +} + describe("ensureAgentWorkspace", () => { it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); - await expect( - fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)), - ).resolves.toBeUndefined(); - const state = await readOnboardingState(tempDir); - expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/); - expect(state.onboardingCompletedAt).toBeUndefined(); + await expectBootstrapSeeded(tempDir); + expect((await readOnboardingState(tempDir)).onboardingCompletedAt).toBeUndefined(); }); it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => { @@ -64,11 +87,7 @@ describe("ensureAgentWorkspace", () => { await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); - await expect( - fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)), - ).resolves.toBeUndefined(); - const state = await readOnboardingState(tempDir); - expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + await expectBootstrapSeeded(tempDir); }); it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => { @@ -129,12 +148,7 @@ describe("ensureAgentWorkspace", () => { await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true }); - await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined(); - await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({ - code: "ENOENT", - }); - const state = await readOnboardingState(tempDir); - expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + await expectCompletedWithoutBootstrap(tempDir); }); }); @@ -233,27 +247,11 @@ describe("filterBootstrapFilesForSession", () => { it("filters to allowlist for subagent sessions", () => { const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1"); - const names = result.map((f) => f.name); - expect(names).toContain("AGENTS.md"); - expect(names).toContain("TOOLS.md"); - expect(names).toContain("SOUL.md"); - expect(names).toContain("IDENTITY.md"); - expect(names).toContain("USER.md"); - expect(names).not.toContain("HEARTBEAT.md"); - expect(names).not.toContain("BOOTSTRAP.md"); - expect(names).not.toContain("MEMORY.md"); + expectSubagentAllowedBootstrapNames(result); }); it("filters to allowlist for cron sessions", () => { const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check"); - const names = result.map((f) => f.name); - expect(names).toContain("AGENTS.md"); - expect(names).toContain("TOOLS.md"); - expect(names).toContain("SOUL.md"); - expect(names).toContain("IDENTITY.md"); - expect(names).toContain("USER.md"); - expect(names).not.toContain("HEARTBEAT.md"); - expect(names).not.toContain("BOOTSTRAP.md"); - expect(names).not.toContain("MEMORY.md"); + expectSubagentAllowedBootstrapNames(result); }); }); diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index 904138003..dab520e6b 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -124,6 +124,43 @@ describe("abort detection", () => { }); } + function enqueueQueuedFollowupRun(params: { + root: string; + cfg: OpenClawConfig; + sessionId: string; + sessionKey: string; + }) { + const followupRun: FollowupRun = { + prompt: "queued", + enqueuedAt: Date.now(), + run: { + agentId: "main", + agentDir: path.join(params.root, "agent"), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + messageProvider: "telegram", + agentAccountId: "acct", + sessionFile: path.join(params.root, "session.jsonl"), + workspaceDir: path.join(params.root, "workspace"), + config: params.cfg, + provider: "anthropic", + model: "claude-opus-4-5", + timeoutMs: 1000, + blockReplyBreak: "text_end", + }, + }; + enqueueFollowupRun( + params.sessionKey, + followupRun, + { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" }, + "none", + ); + } + + function expectSessionLaneCleared(sessionKey: string) { + expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`); + } + afterEach(() => { resetAbortMemoryForTest(); acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" }); @@ -338,31 +375,7 @@ describe("abort detection", () => { const { root, cfg } = await createAbortConfig({ sessionIdsByKey: { [sessionKey]: sessionId }, }); - const followupRun: FollowupRun = { - prompt: "queued", - enqueuedAt: Date.now(), - run: { - agentId: "main", - agentDir: path.join(root, "agent"), - sessionId, - sessionKey, - messageProvider: "telegram", - agentAccountId: "acct", - sessionFile: path.join(root, "session.jsonl"), - workspaceDir: path.join(root, "workspace"), - config: cfg, - provider: "anthropic", - model: "claude-opus-4-5", - timeoutMs: 1000, - blockReplyBreak: "text_end", - }, - }; - enqueueFollowupRun( - sessionKey, - followupRun, - { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" }, - "none", - ); + enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey }); expect(getFollowupQueueDepth(sessionKey)).toBe(1); const result = await runStopCommand({ @@ -374,7 +387,7 @@ describe("abort detection", () => { expect(result.handled).toBe(true); expect(getFollowupQueueDepth(sessionKey)).toBe(0); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`); + expectSessionLaneCleared(sessionKey); }); it("plain-language stop on ACP-bound session triggers ACP cancel", async () => { @@ -411,31 +424,7 @@ describe("abort detection", () => { const { root, cfg } = await createAbortConfig({ sessionIdsByKey: { [sessionKey]: sessionId }, }); - const followupRun: FollowupRun = { - prompt: "queued", - enqueuedAt: Date.now(), - run: { - agentId: "main", - agentDir: path.join(root, "agent"), - sessionId, - sessionKey, - messageProvider: "telegram", - agentAccountId: "acct", - sessionFile: path.join(root, "session.jsonl"), - workspaceDir: path.join(root, "workspace"), - config: cfg, - provider: "anthropic", - model: "claude-opus-4-5", - timeoutMs: 1000, - blockReplyBreak: "text_end", - }, - }; - enqueueFollowupRun( - sessionKey, - followupRun, - { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" }, - "none", - ); + enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey }); acpManagerMocks.resolveSession.mockReturnValue({ kind: "ready", sessionKey, @@ -453,7 +442,7 @@ describe("abort detection", () => { expect(result.handled).toBe(true); expect(getFollowupQueueDepth(sessionKey)).toBe(0); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`); + expectSessionLaneCleared(sessionKey); }); it("persists abort cutoff metadata on /stop when command and target session match", async () => { @@ -546,7 +535,7 @@ describe("abort detection", () => { }); expect(result.stoppedSubagents).toBe(1); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${childKey}`); + expectSessionLaneCleared(childKey); }); it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => { @@ -601,8 +590,8 @@ describe("abort detection", () => { // Should stop both depth-1 and depth-2 agents (cascade) expect(result.stoppedSubagents).toBe(2); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth1Key}`); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`); + expectSessionLaneCleared(depth1Key); + expectSessionLaneCleared(depth2Key); }); it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => { @@ -660,7 +649,7 @@ describe("abort detection", () => { // Should skip killing the ended depth-1 run itself, but still kill depth-2. expect(result.stoppedSubagents).toBe(1); - expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`); + expectSessionLaneCleared(depth2Key); expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith( expect.objectContaining({ runId: "run-2", childSessionKey: depth2Key }), ); diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts index 7432f3c7a..57882b3b7 100644 --- a/src/auto-reply/reply/acp-projector.test.ts +++ b/src/auto-reply/reply/acp-projector.test.ts @@ -3,17 +3,39 @@ import { prefixSystemMessage } from "../../infra/system-message.js"; import { createAcpReplyProjector } from "./acp-projector.js"; import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js"; +type Delivery = { kind: string; text?: string }; + +function createProjectorHarness(cfgOverrides?: Parameters[0]) { + const deliveries: Delivery[] = []; + const projector = createAcpReplyProjector({ + cfg: createCfg(cfgOverrides), + shouldSendToolSummaries: true, + deliver: async (kind, payload) => { + deliveries.push({ kind, text: payload.text }); + return true; + }, + }); + return { deliveries, projector }; +} + +function blockDeliveries(deliveries: Delivery[]) { + return deliveries.filter((entry) => entry.kind === "block"); +} + +function combinedBlockText(deliveries: Delivery[]) { + return blockDeliveries(deliveries) + .map((entry) => entry.text ?? "") + .join(""); +} + +function expectToolCallSummary(delivery: Delivery | undefined) { + expect(delivery?.kind).toBe("tool"); + expect(delivery?.text).toContain("Tool Call"); +} + describe("createAcpReplyProjector", () => { it("coalesces text deltas into bounded block chunks", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg(), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; - }, - }); + const { deliveries, projector } = createProjectorHarness(); await projector.onEvent({ type: "text_delta", @@ -29,22 +51,14 @@ describe("createAcpReplyProjector", () => { }); it("does not suppress identical short text across terminal turn boundaries", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 0, - maxChunkChars: 64, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + coalesceIdleMs: 0, + maxChunkChars: 64, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -53,7 +67,7 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" }); await projector.onEvent({ type: "done", stopReason: "end_turn" }); - expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([ + expect(blockDeliveries(deliveries)).toEqual([ { kind: "block", text: "A" }, { kind: "block", text: "A" }, ]); @@ -62,22 +76,14 @@ describe("createAcpReplyProjector", () => { it("flushes staggered live text deltas after idle gaps", async () => { vi.useFakeTimers(); try { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 50, - maxChunkChars: 64, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + coalesceIdleMs: 50, + maxChunkChars: 64, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -93,7 +99,7 @@ describe("createAcpReplyProjector", () => { await vi.advanceTimersByTimeAsync(760); await projector.flush(false); - expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([ + expect(blockDeliveries(deliveries)).toEqual([ { kind: "block", text: "A" }, { kind: "block", text: "B" }, { kind: "block", text: "C" }, @@ -104,22 +110,14 @@ describe("createAcpReplyProjector", () => { }); it("splits oversized live text by maxChunkChars", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 0, - maxChunkChars: 50, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + coalesceIdleMs: 0, + maxChunkChars: 50, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -127,7 +125,7 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk" }); await projector.flush(true); - expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([ + expect(blockDeliveries(deliveries)).toEqual([ { kind: "block", text: "a".repeat(50) }, { kind: "block", text: "b".repeat(50) }, { kind: "block", text: "c".repeat(20) }, @@ -137,22 +135,14 @@ describe("createAcpReplyProjector", () => { it("does not flush short live fragments mid-phrase on idle", async () => { vi.useFakeTimers(); try { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - coalesceIdleMs: 100, - maxChunkChars: 256, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + coalesceIdleMs: 100, + maxChunkChars: 256, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -184,26 +174,18 @@ describe("createAcpReplyProjector", () => { }); it("supports deliveryMode=final_only by buffering all projected output until done", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 512, - deliveryMode: "final_only", - tagVisibility: { - available_commands_update: true, - tool_call: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 512, + deliveryMode: "final_only", + tagVisibility: { + available_commands_update: true, + tool_call: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -238,32 +220,23 @@ describe("createAcpReplyProjector", () => { kind: "tool", text: prefixSystemMessage("available commands updated (7)"), }); - expect(deliveries[1]?.kind).toBe("tool"); - expect(deliveries[1]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[1]); expect(deliveries[2]).toEqual({ kind: "block", text: "What now?" }); }); it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 512, - deliveryMode: "final_only", - tagVisibility: { - available_commands_update: true, - tool_call: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 512, + deliveryMode: "final_only", + tagVisibility: { + available_commands_update: true, + tool_call: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -288,20 +261,11 @@ describe("createAcpReplyProjector", () => { kind: "tool", text: prefixSystemMessage("available commands updated (7)"), }); - expect(deliveries[1]?.kind).toBe("tool"); - expect(deliveries[1]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[1]); }); it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => { - const hidden: Array<{ kind: string; text?: string }> = []; - const hiddenProjector = createAcpReplyProjector({ - cfg: createCfg(), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - hidden.push({ kind, text: payload.text }); - return true; - }, - }); + const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness(); await hiddenProjector.onEvent({ type: "status", text: "usage updated: 10/100", @@ -311,25 +275,17 @@ describe("createAcpReplyProjector", () => { }); expect(hidden).toEqual([]); - const shown: Array<{ kind: string; text?: string }> = []; - const shownProjector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 64, - deliveryMode: "live", - tagVisibility: { - usage_update: true, - }, + const { deliveries: shown, projector: shownProjector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 64, + deliveryMode: "live", + tagVisibility: { + usage_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - shown.push({ kind, text: payload.text }); - return true; }, }); @@ -362,15 +318,7 @@ describe("createAcpReplyProjector", () => { }); it("hides available_commands_update by default", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg(), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; - }, - }); + const { deliveries, projector } = createProjectorHarness(); await projector.onEvent({ type: "status", text: "available commands updated (7)", @@ -381,24 +329,16 @@ describe("createAcpReplyProjector", () => { }); it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - tagVisibility: { - tool_call: true, - tool_call_update: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + tagVisibility: { + tool_call: true, + tool_call_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -436,32 +376,22 @@ describe("createAcpReplyProjector", () => { }); expect(deliveries.length).toBe(2); - expect(deliveries[0]?.kind).toBe("tool"); - expect(deliveries[0]?.text).toContain("Tool Call"); - expect(deliveries[1]?.kind).toBe("tool"); - expect(deliveries[1]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[0]); + expectToolCallSummary(deliveries[1]); }); it("keeps terminal tool updates even when rendered summaries are truncated", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - maxSessionUpdateChars: 48, - tagVisibility: { - tool_call: true, - tool_call_update: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + maxSessionUpdateChars: 48, + tagVisibility: { + tool_call: true, + tool_call_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -485,29 +415,21 @@ describe("createAcpReplyProjector", () => { }); expect(deliveries.length).toBe(2); - expect(deliveries[0]?.kind).toBe("tool"); - expect(deliveries[1]?.kind).toBe("tool"); + expectToolCallSummary(deliveries[0]); + expectToolCallSummary(deliveries[1]); }); it("renders fallback tool labels without leaking call ids as primary label", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - deliveryMode: "live", - tagVisibility: { - tool_call: true, - tool_call_update: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + deliveryMode: "live", + tagVisibility: { + tool_call: true, + tool_call_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -519,33 +441,25 @@ describe("createAcpReplyProjector", () => { text: "call_ABC123 (in_progress)", }); - expect(deliveries[0]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[0]); expect(deliveries[0]?.text).not.toContain("call_ABC123 ("); }); it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - repeatSuppression: false, - tagVisibility: { - available_commands_update: true, - tool_call: true, - tool_call_update: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + repeatSuppression: false, + tagVisibility: { + available_commands_update: true, + tool_call: true, + tool_call_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -589,31 +503,23 @@ describe("createAcpReplyProjector", () => { kind: "tool", text: prefixSystemMessage("available commands updated"), }); - expect(deliveries[2]?.text).toContain("Tool Call"); - expect(deliveries[3]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[2]); + expectToolCallSummary(deliveries[3]); expect(deliveries[4]).toEqual({ kind: "block", text: "hello" }); }); it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - tagVisibility: { - available_commands_update: true, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + tagVisibility: { + available_commands_update: true, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -640,23 +546,15 @@ describe("createAcpReplyProjector", () => { }); it("truncates oversized turns once and emits one truncation notice", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - maxOutputChars: 5, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + maxOutputChars: 5, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -681,26 +579,18 @@ describe("createAcpReplyProjector", () => { }); it("supports tagVisibility overrides for tool updates", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - tagVisibility: { - tool_call: true, - tool_call_update: false, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + tagVisibility: { + tool_call: true, + tool_call_update: false, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -722,26 +612,18 @@ describe("createAcpReplyProjector", () => { }); expect(deliveries.length).toBe(1); - expect(deliveries[0]?.text).toContain("Tool Call"); + expectToolCallSummary(deliveries[0]); }); it("inserts a space boundary before visible text after hidden tool updates by default", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -757,34 +639,22 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("fallback. I don't"); + expect(combinedBlockText(deliveries)).toBe("fallback. I don't"); }); it("preserves hidden boundary across nonterminal hidden tool updates", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - tagVisibility: { - tool_call: false, - tool_call_update: false, - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + tagVisibility: { + tool_call: false, + tool_call_update: false, }, }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -808,31 +678,19 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("fallback. I don't"); + expect(combinedBlockText(deliveries)).toBe("fallback. I don't"); }); it("supports hiddenBoundarySeparator=space", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - hiddenBoundarySeparator: "space", - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + hiddenBoundarySeparator: "space", }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -848,31 +706,19 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("fallback. I don't"); + expect(combinedBlockText(deliveries)).toBe("fallback. I don't"); }); it("supports hiddenBoundarySeparator=none", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - hiddenBoundarySeparator: "none", - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", + hiddenBoundarySeparator: "none", }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -888,30 +734,18 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("fallback.I don't"); + expect(combinedBlockText(deliveries)).toBe("fallback.I don't"); }); it("does not duplicate newlines when previous visible text already ends with newline", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -931,30 +765,18 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("fallback.\nI don't"); + expect(combinedBlockText(deliveries)).toBe("fallback.\nI don't"); }); it("does not insert boundary separator for hidden non-tool status updates", async () => { - const deliveries: Array<{ kind: string; text?: string }> = []; - const projector = createAcpReplyProjector({ - cfg: createCfg({ - acp: { - enabled: true, - stream: { - coalesceIdleMs: 0, - maxChunkChars: 256, - deliveryMode: "live", - }, + const { deliveries, projector } = createProjectorHarness({ + acp: { + enabled: true, + stream: { + coalesceIdleMs: 0, + maxChunkChars: 256, + deliveryMode: "live", }, - }), - shouldSendToolSummaries: true, - deliver: async (kind, payload) => { - deliveries.push({ kind, text: payload.text }); - return true; }, }); @@ -967,10 +789,6 @@ describe("createAcpReplyProjector", () => { await projector.onEvent({ type: "text_delta", text: "B", tag: "agent_message_chunk" }); await projector.flush(true); - const combinedText = deliveries - .filter((entry) => entry.kind === "block") - .map((entry) => entry.text ?? "") - .join(""); - expect(combinedText).toBe("AB"); + expect(combinedBlockText(deliveries)).toBe("AB"); }); }); diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index df3135f1b..1d8083503 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -52,6 +52,22 @@ const hoisted = vi.hoisted(() => { }; }); +function createAcpCommandSessionBindingService() { + const forward = + (fn: (...args: A) => T) => + (...args: A) => + fn(...args); + return { + bind: (input: unknown) => hoisted.sessionBindingBindMock(input), + getCapabilities: forward((params: unknown) => hoisted.sessionBindingCapabilitiesMock(params)), + listBySession: (targetSessionKey: string) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), + touch: vi.fn(), + unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), + }; +} + vi.mock("../../gateway/call.js", () => ({ callGateway: (args: unknown) => hoisted.callGatewayMock(args), })); @@ -79,18 +95,11 @@ vi.mock("../../config/sessions.js", async (importOriginal) => { vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - getSessionBindingService: () => ({ - bind: (input: unknown) => hoisted.sessionBindingBindMock(input), - getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), - listBySession: (targetSessionKey: string) => - hoisted.sessionBindingListBySessionMock(targetSessionKey), - resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), - touch: vi.fn(), - unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), - }), + const patched = { ...actual } as typeof actual & { + getSessionBindingService: () => ReturnType; }; + patched.getSessionBindingService = () => createAcpCommandSessionBindingService(); + return patched; }); // Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent. @@ -172,6 +181,128 @@ function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg) return params; } +const defaultAcpSessionKey = "agent:codex:acp:s1"; +const defaultThreadId = "thread-1"; + +type AcpSessionIdentity = { + state: "resolved"; + source: "status"; + acpxSessionId: string; + agentSessionId: string; + lastUpdatedAt: number; +}; + +function createThreadConversation(conversationId: string = defaultThreadId) { + return { + channel: "discord" as const, + accountId: "default", + conversationId, + parentConversationId: "parent-1", + }; +} + +function createBoundThreadSession(sessionKey: string = defaultAcpSessionKey) { + return createSessionBinding({ + targetSessionKey: sessionKey, + conversation: createThreadConversation(), + }); +} + +function createAcpSessionEntry(options?: { + sessionKey?: string; + state?: "idle" | "running"; + identity?: AcpSessionIdentity; +}) { + const sessionKey = options?.sessionKey ?? defaultAcpSessionKey; + return { + sessionKey, + storeSessionKey: sessionKey, + acp: { + backend: "acpx", + agent: "codex", + runtimeSessionName: "runtime-1", + ...(options?.identity ? { identity: options.identity } : {}), + mode: "persistent", + state: options?.state ?? "idle", + lastActivityAt: Date.now(), + }, + }; +} + +function createSessionBindingCapabilities() { + return { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"] as const, + }; +} + +type AcpBindInput = { + targetSessionKey: string; + conversation: { accountId: string; conversationId: string }; + placement: "current" | "child"; + metadata?: Record; +}; + +function createAcpThreadBinding(input: AcpBindInput): FakeBinding { + const nextConversationId = + input.placement === "child" ? "thread-created" : input.conversation.conversationId; + const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1"; + return createSessionBinding({ + targetSessionKey: input.targetSessionKey, + conversation: { + channel: "discord", + accountId: input.conversation.accountId, + conversationId: nextConversationId, + parentConversationId: "parent-1", + }, + metadata: { boundBy, webhookId: "wh-1" }, + }); +} + +function expectBoundIntroTextToExclude(match: string): void { + const calls = hoisted.sessionBindingBindMock.mock.calls as Array< + [{ metadata?: { introText?: unknown } }] + >; + const introText = calls + .map((call) => call[0]?.metadata?.introText) + .find((value): value is string => typeof value === "string"); + expect((introText ?? "").includes(match)).toBe(false); +} + +function mockBoundThreadSession(options?: { + sessionKey?: string; + state?: "idle" | "running"; + identity?: AcpSessionIdentity; +}) { + const sessionKey = options?.sessionKey ?? defaultAcpSessionKey; + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createBoundThreadSession(sessionKey), + ); + hoisted.readAcpSessionEntryMock.mockReturnValue( + createAcpSessionEntry({ + sessionKey, + state: options?.state, + identity: options?.identity, + }), + ); +} + +function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = createDiscordParams(commandBody, cfg); + params.ctx.MessageThreadId = defaultThreadId; + return params; +} + +async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createDiscordParams(commandBody, cfg), true); +} + +async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createThreadParams(commandBody, cfg), true); +} + describe("/acp command", () => { beforeEach(() => { acpManagerTesting.resetAcpSessionManagerForTests(); @@ -195,37 +326,12 @@ describe("/acp command", () => { storePath: "/tmp/sessions-acp.json", }); hoisted.loadSessionStoreMock.mockReset().mockReturnValue({}); - hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }); + hoisted.sessionBindingCapabilitiesMock + .mockReset() + .mockReturnValue(createSessionBindingCapabilities()); hoisted.sessionBindingBindMock .mockReset() - .mockImplementation( - async (input: { - targetSessionKey: string; - conversation: { accountId: string; conversationId: string }; - placement: "current" | "child"; - metadata?: Record; - }) => - createSessionBinding({ - targetSessionKey: input.targetSessionKey, - conversation: { - channel: "discord", - accountId: input.conversation.accountId, - conversationId: - input.placement === "child" ? "thread-created" : input.conversation.conversationId, - parentConversationId: "parent-1", - }, - metadata: { - boundBy: - typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1", - webhookId: "wh-1", - }, - }), - ); + .mockImplementation(async (input: AcpBindInput) => createAcpThreadBinding(input)); hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); @@ -275,14 +381,12 @@ describe("/acp command", () => { }); it("returns null when the message is not /acp", async () => { - const params = createDiscordParams("/status"); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/status"); expect(result).toBeNull(); }); it("shows help by default", async () => { - const params = createDiscordParams("/acp"); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp"); expect(result?.reply?.text).toContain("ACP commands:"); expect(result?.reply?.text).toContain("/acp spawn"); }); @@ -296,8 +400,7 @@ describe("/acp command", () => { backendSessionId: "acpx-1", }); - const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd"); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp spawn codex --cwd /home/bob/clawd"); expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); expect(result?.reply?.text).toContain("Created thread thread-created and bound it"); @@ -318,15 +421,7 @@ describe("/acp command", () => { }), }), ); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - introText: expect.not.stringContaining( - "session ids: pending (available after the first reply)", - ), - }), - }), - ); + expectBoundIntroTextToExclude("session ids: pending (available after the first reply)"); expect(hoisted.callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "sessions.patch", @@ -352,8 +447,7 @@ describe("/acp command", () => { }); it("requires explicit ACP target when acp.defaultAgent is not configured", async () => { - const params = createDiscordParams("/acp spawn"); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp spawn"); expect(result?.reply?.text).toContain("ACP target agent is required"); expect(hoisted.ensureSessionMock).not.toHaveBeenCalled(); @@ -372,8 +466,7 @@ describe("/acp command", () => { }, } satisfies OpenClawConfig; - const params = createDiscordParams("/acp spawn codex", cfg); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp spawn codex", cfg); expect(result?.reply?.text).toContain("spawnAcpSessions=true"); expect(hoisted.closeMock).toHaveBeenCalledTimes(1); @@ -393,38 +486,14 @@ describe("/acp command", () => { }); it("cancels the ACP session bound to the current thread", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), + mockBoundThreadSession({ state: "running" }); + const result = await runThreadAcpCommand("/acp cancel", baseCfg); + expect(result?.reply?.text).toContain( + `Cancel requested for ACP session ${defaultAcpSessionKey}`, ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "running", - lastActivityAt: Date.now(), - }, - }); - - const params = createDiscordParams("/acp cancel", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - - const result = await handleAcpCommand(params, true); - expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1"); expect(hoisted.cancelMock).toHaveBeenCalledWith({ handle: expect.objectContaining({ - sessionKey: "agent:codex:acp:s1", + sessionKey: defaultAcpSessionKey, backend: "acpx", }), reason: "manual-cancel", @@ -434,29 +503,19 @@ describe("/acp command", () => { it("sends steer instructions via ACP runtime", async () => { hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => { if (request.method === "sessions.resolve") { - return { key: "agent:codex:acp:s1" }; + return { key: defaultAcpSessionKey }; } return { ok: true }; }); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); + hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry()); hoisted.runTurnMock.mockImplementation(async function* () { yield { type: "text_delta", text: "Applied steering." }; yield { type: "done" }; }); - const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging"); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand( + `/acp steer --session ${defaultAcpSessionKey} tighten logging`, + ); expect(hoisted.runTurnMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -475,57 +534,23 @@ describe("/acp command", () => { dispatch: { enabled: false }, }, } satisfies OpenClawConfig; - const params = createDiscordParams("/acp steer tighten logging", cfg); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp steer tighten logging", cfg); expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy"); expect(hoisted.runTurnMock).not.toHaveBeenCalled(); }); it("closes an ACP session, unbinds thread targets, and clears metadata", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); + mockBoundThreadSession(); hoisted.sessionBindingUnbindMock.mockResolvedValue([ - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }) as SessionBindingRecord, + createBoundThreadSession() as SessionBindingRecord, ]); - const params = createDiscordParams("/acp close", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - - const result = await handleAcpCommand(params, true); + const result = await runThreadAcpCommand("/acp close", baseCfg); expect(hoisted.closeMock).toHaveBeenCalledTimes(1); expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith( expect.objectContaining({ - targetSessionKey: "agent:codex:acp:s1", + targetSessionKey: defaultAcpSessionKey, reason: "manual", }), ); @@ -535,22 +560,10 @@ describe("/acp command", () => { it("lists ACP sessions from the session store", async () => { hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) => - key === "agent:codex:acp:s1" - ? [ - createSessionBinding({ - targetSessionKey: key, - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }) as SessionBindingRecord, - ] - : [], + key === defaultAcpSessionKey ? [createBoundThreadSession(key) as SessionBindingRecord] : [], ); hoisted.loadSessionStoreMock.mockReturnValue({ - "agent:codex:acp:s1": { + [defaultAcpSessionKey]: { sessionId: "sess-1", updatedAt: Date.now(), label: "codex-main", @@ -569,52 +582,27 @@ describe("/acp command", () => { }, }); - const params = createDiscordParams("/acp sessions", baseCfg); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp sessions", baseCfg); expect(result?.reply?.text).toContain("ACP sessions:"); expect(result?.reply?.text).toContain("codex-main"); - expect(result?.reply?.text).toContain("thread:thread-1"); + expect(result?.reply?.text).toContain(`thread:${defaultThreadId}`); }); it("shows ACP status for the thread-bound ACP session", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - identity: { - state: "resolved", - source: "status", - acpxSessionId: "acpx-sid-1", - agentSessionId: "codex-sid-1", - lastUpdatedAt: Date.now(), - }, - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), + mockBoundThreadSession({ + identity: { + state: "resolved", + source: "status", + acpxSessionId: "acpx-sid-1", + agentSessionId: "codex-sid-1", + lastUpdatedAt: Date.now(), }, }); - const params = createDiscordParams("/acp status", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - - const result = await handleAcpCommand(params, true); + const result = await runThreadAcpCommand("/acp status", baseCfg); expect(result?.reply?.text).toContain("ACP status:"); - expect(result?.reply?.text).toContain("session: agent:codex:acp:s1"); + expect(result?.reply?.text).toContain(`session: ${defaultAcpSessionKey}`); expect(result?.reply?.text).toContain("agent session id: codex-sid-1"); expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1"); expect(result?.reply?.text).toContain("capabilities:"); @@ -622,33 +610,8 @@ describe("/acp command", () => { }); it("updates ACP runtime mode via /acp set-mode", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); - const params = createDiscordParams("/acp set-mode plan", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - - const result = await handleAcpCommand(params, true); + mockBoundThreadSession(); + const result = await runThreadAcpCommand("/acp set-mode plan", baseCfg); expect(hoisted.setModeMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -659,33 +622,9 @@ describe("/acp command", () => { }); it("updates ACP config options and keeps cwd local when using /acp set", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); + mockBoundThreadSession(); - const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg); - setModelParams.ctx.MessageThreadId = "thread-1"; - const setModel = await handleAcpCommand(setModelParams, true); + const setModel = await runThreadAcpCommand("/acp set model gpt-5.3-codex", baseCfg); expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith( expect.objectContaining({ key: "model", @@ -695,74 +634,24 @@ describe("/acp command", () => { expect(setModel?.reply?.text).toContain("Updated ACP config option"); hoisted.setConfigOptionMock.mockClear(); - const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg); - setCwdParams.ctx.MessageThreadId = "thread-1"; - const setCwd = await handleAcpCommand(setCwdParams, true); + const setCwd = await runThreadAcpCommand("/acp set cwd /tmp/worktree", baseCfg); expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled(); expect(setCwd?.reply?.text).toContain("Updated ACP cwd"); }); it("rejects non-absolute cwd values via ACP runtime option validation", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); + mockBoundThreadSession(); - const params = createDiscordParams("/acp cwd relative/path", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - const result = await handleAcpCommand(params, true); + const result = await runThreadAcpCommand("/acp cwd relative/path", baseCfg); expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)"); expect(result?.reply?.text).toContain("absolute path"); }); it("rejects invalid timeout values before backend config writes", async () => { - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createSessionBinding({ - targetSessionKey: "agent:codex:acp:s1", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "thread-1", - parentConversationId: "parent-1", - }, - }), - ); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:s1", - storeSessionKey: "agent:codex:acp:s1", - acp: { - backend: "acpx", - agent: "codex", - runtimeSessionName: "runtime-1", - mode: "persistent", - state: "idle", - lastActivityAt: Date.now(), - }, - }); + mockBoundThreadSession(); - const params = createDiscordParams("/acp timeout 10s", baseCfg); - params.ctx.MessageThreadId = "thread-1"; - const result = await handleAcpCommand(params, true); + const result = await runThreadAcpCommand("/acp timeout 10s", baseCfg); expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)"); expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled(); @@ -777,8 +666,7 @@ describe("/acp command", () => { ); }); - const params = createDiscordParams("/acp doctor", baseCfg); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp doctor", baseCfg); expect(result?.reply?.text).toContain("ACP doctor:"); expect(result?.reply?.text).toContain("healthy: no"); @@ -786,8 +674,7 @@ describe("/acp command", () => { }); it("shows deterministic install instructions via /acp install", async () => { - const params = createDiscordParams("/acp install", baseCfg); - const result = await handleAcpCommand(params, true); + const result = await runDiscordAcpCommand("/acp install", baseCfg); expect(result?.reply?.text).toContain("ACP install:"); expect(result?.reply?.text).toContain("run:"); diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 7a9f5ca34..70a7c0387 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -30,6 +30,28 @@ const hoisted = vi.hoisted(() => { }; }); +function buildFocusSessionBindingService() { + const service = { + touch: vi.fn(), + listBySession(targetSessionKey: string) { + return hoisted.sessionBindingListBySessionMock(targetSessionKey); + }, + resolveByConversation(ref: unknown) { + return hoisted.sessionBindingResolveByConversationMock(ref); + }, + getCapabilities(params: unknown) { + return hoisted.sessionBindingCapabilitiesMock(params); + }, + bind(input: unknown) { + return hoisted.sessionBindingBindMock(input); + }, + unbind(input: unknown) { + return hoisted.sessionBindingUnbindMock(input); + }, + }; + return service; +} + vi.mock("../../gateway/call.js", () => ({ callGateway: hoisted.callGatewayMock, })); @@ -56,15 +78,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal await importOriginal(); return { ...actual, - getSessionBindingService: () => ({ - bind: (input: unknown) => hoisted.sessionBindingBindMock(input), - getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), - listBySession: (targetSessionKey: string) => - hoisted.sessionBindingListBySessionMock(targetSessionKey), - resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), - touch: vi.fn(), - unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), - }), + getSessionBindingService: () => buildFocusSessionBindingService(), }; }); @@ -217,13 +231,33 @@ function createSessionBindingRecord( }; } -async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) { - hoisted.sessionBindingCapabilitiesMock.mockReturnValue({ +function createSessionBindingCapabilities() { + return { adapterAvailable: true, bindSupported: true, unbindSupported: true, - placements: ["current", "child"], - }); + placements: ["current", "child"] as const, + }; +} + +async function runUnfocusAndExpectManualUnbind(initialBindings: FakeBinding[]) { + const fake = createFakeThreadBindingManager(initialBindings); + hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); + + const params = createDiscordCommandParams("/unfocus"); + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("Thread unfocused"); + expect(fake.manager.unbindThread).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + reason: "manual", + }), + ); +} + +async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) { + hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities()); hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null); hoisted.sessionBindingBindMock.mockImplementation( async (input: { @@ -256,6 +290,12 @@ async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindin return { result }; } +async function runAgentsCommandAndText(): Promise { + const params = createDiscordCommandParams("/agents"); + const result = await handleSubagentsCommand(params, true); + return result?.reply?.text ?? ""; +} + describe("/focus, /unfocus, /agents", () => { beforeEach(() => { resetSubagentRegistryForTests(); @@ -263,12 +303,9 @@ describe("/focus, /unfocus, /agents", () => { hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null); hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex"); hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null); - hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }); + hoisted.sessionBindingCapabilitiesMock + .mockReset() + .mockReturnValue(createSessionBindingCapabilities()); hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); @@ -340,23 +377,11 @@ describe("/focus, /unfocus, /agents", () => { }); it("/unfocus removes an active thread binding for the binding owner", async () => { - const fake = createFakeThreadBindingManager([createStoredBinding()]); - hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); - - const params = createDiscordCommandParams("/unfocus"); - const result = await handleSubagentsCommand(params, true); - - expect(result?.reply?.text).toContain("Thread unfocused"); - expect(fake.manager.unbindThread).toHaveBeenCalledWith( - expect.objectContaining({ - threadId: "thread-1", - reason: "manual", - }), - ); + await runUnfocusAndExpectManualUnbind([createStoredBinding()]); }); it("/unfocus also unbinds ACP-focused thread bindings", async () => { - const fake = createFakeThreadBindingManager([ + await runUnfocusAndExpectManualUnbind([ createStoredBinding({ targetKind: "acp", targetSessionKey: "agent:codex:acp:session-1", @@ -364,18 +389,6 @@ describe("/focus, /unfocus, /agents", () => { label: "codex-session", }), ]); - hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); - - const params = createDiscordCommandParams("/unfocus"); - const result = await handleSubagentsCommand(params, true); - - expect(result?.reply?.text).toContain("Thread unfocused"); - expect(fake.manager.unbindThread).toHaveBeenCalledWith( - expect.objectContaining({ - threadId: "thread-1", - reason: "manual", - }), - ); }); it("/focus rejects rebinding when the thread is focused by another user", async () => { @@ -428,9 +441,7 @@ describe("/focus, /unfocus, /agents", () => { ]); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); - const params = createDiscordCommandParams("/agents"); - const result = await handleSubagentsCommand(params, true); - const text = result?.reply?.text ?? ""; + const text = await runAgentsCommandAndText(); expect(text).toContain("agents:"); expect(text).toContain("thread:thread-1"); @@ -464,9 +475,7 @@ describe("/focus, /unfocus, /agents", () => { ]); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); - const params = createDiscordCommandParams("/agents"); - const result = await handleSubagentsCommand(params, true); - const text = result?.reply?.text ?? ""; + const text = await runAgentsCommandAndText(); expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1"); }); diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index 26733136a..ce02f9828 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -26,21 +26,25 @@ function createDispatcher(): ReplyDispatcher { }; } +function createCoordinator(onReplyStart?: (...args: unknown[]) => Promise) { + return createAcpDispatchDeliveryCoordinator({ + cfg: createAcpTestConfig(), + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + SessionKey: "agent:codex-acp:session-1", + }), + dispatcher: createDispatcher(), + inboundAudio: false, + shouldRouteToOriginating: false, + ...(onReplyStart ? { onReplyStart } : {}), + }); +} + describe("createAcpDispatchDeliveryCoordinator", () => { it("starts reply lifecycle only once when called directly and through deliver", async () => { const onReplyStart = vi.fn(async () => {}); - const coordinator = createAcpDispatchDeliveryCoordinator({ - cfg: createAcpTestConfig(), - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - }), - dispatcher: createDispatcher(), - inboundAudio: false, - shouldRouteToOriginating: false, - onReplyStart, - }); + const coordinator = createCoordinator(onReplyStart); await coordinator.startReplyLifecycle(); await coordinator.deliver("final", { text: "hello" }); @@ -52,18 +56,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { it("starts reply lifecycle once when deliver triggers first", async () => { const onReplyStart = vi.fn(async () => {}); - const coordinator = createAcpDispatchDeliveryCoordinator({ - cfg: createAcpTestConfig(), - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - }), - dispatcher: createDispatcher(), - inboundAudio: false, - shouldRouteToOriginating: false, - onReplyStart, - }); + const coordinator = createCoordinator(onReplyStart); await coordinator.deliver("final", { text: "hello" }); await coordinator.startReplyLifecycle(); @@ -73,18 +66,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { it("does not start reply lifecycle for empty payload delivery", async () => { const onReplyStart = vi.fn(async () => {}); - const coordinator = createAcpDispatchDeliveryCoordinator({ - cfg: createAcpTestConfig(), - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - }), - dispatcher: createDispatcher(), - inboundAudio: false, - shouldRouteToOriginating: false, - onReplyStart, - }); + const coordinator = createCoordinator(onReplyStart); await coordinator.deliver("final", {}); diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 922dc5d5d..286b73a7c 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -85,6 +85,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({ })); const { tryDispatchAcpReply } = await import("./dispatch-acp.js"); +const sessionKey = "agent:codex-acp:session-1"; function createDispatcher(): { dispatcher: ReplyDispatcher; @@ -105,7 +106,7 @@ function createDispatcher(): { function setReadyAcpResolution() { managerMocks.resolveSession.mockReturnValue({ kind: "ready", - sessionKey: "agent:codex-acp:session-1", + sessionKey, meta: createAcpSessionMeta(), }); } @@ -124,6 +125,84 @@ function createAcpConfigWithVisibleToolTags(): OpenClawConfig { }); } +async function runDispatch(params: { + bodyForAgent: string; + cfg?: OpenClawConfig; + dispatcher?: ReplyDispatcher; + shouldRouteToOriginating?: boolean; + onReplyStart?: () => void; +}) { + return tryDispatchAcpReply({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + SessionKey: sessionKey, + BodyForAgent: params.bodyForAgent, + }), + cfg: params.cfg ?? createAcpTestConfig(), + dispatcher: params.dispatcher ?? createDispatcher().dispatcher, + sessionKey, + inboundAudio: false, + shouldRouteToOriginating: params.shouldRouteToOriginating ?? false, + ...(params.shouldRouteToOriginating + ? { originatingChannel: "telegram", originatingTo: "telegram:thread-1" } + : {}), + shouldSendToolSummaries: true, + bypassForCommand: false, + ...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}), + recordProcessed: vi.fn(), + markIdle: vi.fn(), + }); +} + +async function emitToolLifecycleEvents( + onEvent: (event: unknown) => Promise, + toolCallId: string, +) { + await onEvent({ + type: "tool_call", + tag: "tool_call", + toolCallId, + status: "in_progress", + title: "Run command", + text: "Run command (in_progress)", + }); + await onEvent({ + type: "tool_call", + tag: "tool_call_update", + toolCallId, + status: "completed", + title: "Run command", + text: "Run command (completed)", + }); + await onEvent({ type: "done" }); +} + +function mockToolLifecycleTurn(toolCallId: string) { + managerMocks.runTurn.mockImplementation( + async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { + await emitToolLifecycleEvents(onEvent, toolCallId); + }, + ); +} + +function mockVisibleTextTurn(text = "visible") { + managerMocks.runTurn.mockImplementationOnce( + async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { + await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" }); + await onEvent({ type: "done" }); + }, + ); +} + +async function dispatchVisibleTurn(onReplyStart: () => void) { + await runDispatch({ + bodyForAgent: "visible", + dispatcher: createDispatcher().dispatcher, + onReplyStart, + }); +} + describe("tryDispatchAcpReply", () => { beforeEach(() => { managerMocks.resolveSession.mockReset(); @@ -160,24 +239,10 @@ describe("tryDispatchAcpReply", () => { ); const { dispatcher } = createDispatcher(); - const result = await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "reply", - }), - cfg: createAcpTestConfig(), + const result = await runDispatch({ + bodyForAgent: "reply", dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, shouldRouteToOriginating: true, - originatingChannel: "telegram", - originatingTo: "telegram:thread-1", - shouldSendToolSummaries: true, - bypassForCommand: false, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(result?.counts.block).toBe(1); @@ -192,48 +257,15 @@ describe("tryDispatchAcpReply", () => { it("edits ACP tool lifecycle updates in place when supported", async () => { setReadyAcpResolution(); - managerMocks.runTurn.mockImplementation( - async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { - await onEvent({ - type: "tool_call", - tag: "tool_call", - toolCallId: "call-1", - status: "in_progress", - title: "Run command", - text: "Run command (in_progress)", - }); - await onEvent({ - type: "tool_call", - tag: "tool_call_update", - toolCallId: "call-1", - status: "completed", - title: "Run command", - text: "Run command (completed)", - }); - await onEvent({ type: "done" }); - }, - ); + mockToolLifecycleTurn("call-1"); routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" }); const { dispatcher } = createDispatcher(); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "run tool", - }), + await runDispatch({ + bodyForAgent: "run tool", cfg: createAcpConfigWithVisibleToolTags(), dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, shouldRouteToOriginating: true, - originatingChannel: "telegram", - originatingTo: "telegram:thread-1", - shouldSendToolSummaries: true, - bypassForCommand: false, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(routeMocks.routeReply).toHaveBeenCalledTimes(1); @@ -249,51 +281,18 @@ describe("tryDispatchAcpReply", () => { it("falls back to new tool message when edit fails", async () => { setReadyAcpResolution(); - managerMocks.runTurn.mockImplementation( - async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { - await onEvent({ - type: "tool_call", - tag: "tool_call", - toolCallId: "call-2", - status: "in_progress", - title: "Run command", - text: "Run command (in_progress)", - }); - await onEvent({ - type: "tool_call", - tag: "tool_call_update", - toolCallId: "call-2", - status: "completed", - title: "Run command", - text: "Run command (completed)", - }); - await onEvent({ type: "done" }); - }, - ); + mockToolLifecycleTurn("call-2"); routeMocks.routeReply .mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" }) .mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" }); messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported")); const { dispatcher } = createDispatcher(); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "run tool", - }), + await runDispatch({ + bodyForAgent: "run tool", cfg: createAcpConfigWithVisibleToolTags(), dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, shouldRouteToOriginating: true, - originatingChannel: "telegram", - originatingTo: "telegram:thread-1", - shouldSendToolSummaries: true, - bypassForCommand: false, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1); @@ -317,50 +316,15 @@ describe("tryDispatchAcpReply", () => { await onEvent({ type: "done" }); }, ); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "hidden", - }), - cfg: createAcpTestConfig(), + await runDispatch({ + bodyForAgent: "hidden", dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, - shouldRouteToOriginating: false, - shouldSendToolSummaries: true, - bypassForCommand: false, onReplyStart, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(onReplyStart).toHaveBeenCalledTimes(1); - managerMocks.runTurn.mockImplementationOnce( - async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { - await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" }); - await onEvent({ type: "done" }); - }, - ); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "visible", - }), - cfg: createAcpTestConfig(), - dispatcher: createDispatcher().dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, - shouldRouteToOriginating: false, - shouldSendToolSummaries: true, - bypassForCommand: false, - onReplyStart, - recordProcessed: vi.fn(), - markIdle: vi.fn(), - }); + mockVisibleTextTurn(); + await dispatchVisibleTurn(onReplyStart); expect(onReplyStart).toHaveBeenCalledTimes(2); }); @@ -368,31 +332,8 @@ describe("tryDispatchAcpReply", () => { setReadyAcpResolution(); const onReplyStart = vi.fn(); - managerMocks.runTurn.mockImplementationOnce( - async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => { - await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" }); - await onEvent({ type: "done" }); - }, - ); - - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "visible", - }), - cfg: createAcpTestConfig(), - dispatcher: createDispatcher().dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, - shouldRouteToOriginating: false, - shouldSendToolSummaries: true, - bypassForCommand: false, - onReplyStart, - recordProcessed: vi.fn(), - markIdle: vi.fn(), - }); + mockVisibleTextTurn(); + await dispatchVisibleTurn(onReplyStart); expect(onReplyStart).toHaveBeenCalledTimes(1); }); @@ -402,23 +343,10 @@ describe("tryDispatchAcpReply", () => { const onReplyStart = vi.fn(); const { dispatcher } = createDispatcher(); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: " ", - }), - cfg: createAcpTestConfig(), + await runDispatch({ + bodyForAgent: " ", dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, - shouldRouteToOriginating: false, - shouldSendToolSummaries: true, - bypassForCommand: false, onReplyStart, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); @@ -432,22 +360,9 @@ describe("tryDispatchAcpReply", () => { ); const { dispatcher } = createDispatcher(); - await tryDispatchAcpReply({ - ctx: buildTestCtx({ - Provider: "discord", - Surface: "discord", - SessionKey: "agent:codex-acp:session-1", - BodyForAgent: "test", - }), - cfg: createAcpTestConfig(), + await runDispatch({ + bodyForAgent: "test", dispatcher, - sessionKey: "agent:codex-acp:session-1", - inboundAudio: false, - shouldRouteToOriginating: false, - shouldSendToolSummaries: true, - bypassForCommand: false, - recordProcessed: vi.fn(), - markIdle: vi.fn(), }); expect(managerMocks.runTurn).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index a6e0c9f84..ae737b68f 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -113,6 +113,10 @@ function mockCompactionRun(params: { ); } +function createAsyncReplySpy() { + return vi.fn(async () => {}); +} + describe("createFollowupRunner compaction", () => { it("adds verbose auto-compaction notice and tracks count", async () => { const storePath = path.join( @@ -181,92 +185,97 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); } - it("drops payloads already sent via messaging tool", async () => { - const onBlockReply = vi.fn(async () => {}); + async function runMessagingCase(params: { + agentResult: Record; + queued?: FollowupRun; + runnerOverrides?: Partial<{ + sessionEntry: SessionEntry; + sessionStore: Record; + sessionKey: string; + storePath: string; + }>; + }) { + const onBlockReply = createAsyncReplySpy(); runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["hello world!"], meta: {}, + ...params.agentResult, }); + const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides); + await runner(params.queued ?? baseQueuedRun()); + return { onBlockReply }; + } - const runner = createMessagingDedupeRunner(onBlockReply); + function makeTextReplyDedupeResult(overrides?: Record) { + return { + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["different message"], + ...overrides, + }; + } - await runner(baseQueuedRun()); + it("drops payloads already sent via messaging tool", async () => { + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ text: "hello world!" }], + messagingToolSentTexts: ["hello world!"], + }, + }); expect(onBlockReply).not.toHaveBeenCalled(); }); it("delivers payloads when not duplicates", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: makeTextReplyDedupeResult(), }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner(baseQueuedRun()); - expect(onBlockReply).toHaveBeenCalledTimes(1); }); it("suppresses replies when a messaging tool sent via the same provider + target", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { + ...makeTextReplyDedupeResult(), + messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], + }, + queued: baseQueuedRun("slack"), }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner(baseQueuedRun("slack")); - expect(onBlockReply).not.toHaveBeenCalled(); }); it("suppresses replies when provider is synthetic but originating channel matches", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { + ...makeTextReplyDedupeResult(), + messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }], + }, + queued: { + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + } as FollowupRun, }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner({ - ...baseQueuedRun("heartbeat"), - originatingChannel: "telegram", - originatingTo: "268300329", - } as FollowupRun); - expect(onBlockReply).not.toHaveBeenCalled(); }); it("does not suppress replies for same target when account differs", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - messagingToolSentTargets: [ - { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" }, - ], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { + ...makeTextReplyDedupeResult(), + messagingToolSentTargets: [ + { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" }, + ], + }, + queued: { + ...baseQueuedRun("heartbeat"), + originatingChannel: "telegram", + originatingTo: "268300329", + originatingAccountId: "personal", + } as FollowupRun, }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner({ - ...baseQueuedRun("heartbeat"), - originatingChannel: "telegram", - originatingTo: "268300329", - originatingAccountId: "personal", - } as FollowupRun); - expect(routeReplyMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", @@ -278,33 +287,25 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); it("drops media URL from payload when messaging tool already sent it", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ mediaUrl: "/tmp/img.png" }], - messagingToolSentMediaUrls: ["/tmp/img.png"], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ mediaUrl: "/tmp/img.png" }], + messagingToolSentMediaUrls: ["/tmp/img.png"], + }, }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner(baseQueuedRun()); - // Media stripped → payload becomes non-renderable → not delivered. expect(onBlockReply).not.toHaveBeenCalled(); }); it("delivers media payload when not a duplicate", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ mediaUrl: "/tmp/img.png" }], - messagingToolSentMediaUrls: ["/tmp/other.png"], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { + payloads: [{ mediaUrl: "/tmp/img.png" }], + messagingToolSentMediaUrls: ["/tmp/other.png"], + }, }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner(baseQueuedRun()); - expect(onBlockReply).toHaveBeenCalledTimes(1); }); @@ -318,30 +319,28 @@ describe("createFollowupRunner messaging tool dedupe", () => { const sessionStore: Record = { [sessionKey]: sessionEntry }; await saveSessionStore(storePath, sessionStore); - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - lastCallUsage: { input: 400, output: 20 }, - model: "claude-opus-4-5", - provider: "anthropic", + const { onBlockReply } = await runMessagingCase({ + agentResult: { + ...makeTextReplyDedupeResult(), + messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], + meta: { + agentMeta: { + usage: { input: 1_000, output: 50 }, + lastCallUsage: { input: 400, output: 20 }, + model: "claude-opus-4-5", + provider: "anthropic", + }, }, }, + runnerOverrides: { + sessionEntry, + sessionStore, + sessionKey, + storePath, + }, + queued: baseQueuedRun("slack"), }); - const runner = createMessagingDedupeRunner(onBlockReply, { - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - - await runner(baseQueuedRun("slack")); - expect(onBlockReply).not.toHaveBeenCalled(); const store = loadSessionStore(storePath, { skipCache: true }); // totalTokens should reflect the last call usage snapshot, not the accumulated input. @@ -353,46 +352,36 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); it("does not fall back to dispatcher when cross-channel origin routing fails", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: {}, - }); routeReplyMock.mockResolvedValueOnce({ ok: false, error: "forced route failure", }); - - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner({ - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun); + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "hello world!" }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + } as FollowupRun, + }); expect(routeReplyMock).toHaveBeenCalled(); expect(onBlockReply).not.toHaveBeenCalled(); }); it("falls back to dispatcher when same-channel origin routing fails", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: {}, - }); routeReplyMock.mockResolvedValueOnce({ ok: false, error: "outbound adapter unavailable", }); - - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner({ - ...baseQueuedRun(" Feishu "), - originatingChannel: "FEISHU", - originatingTo: "ou_abc123", - } as FollowupRun); + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "hello world!" }] }, + queued: { + ...baseQueuedRun(" Feishu "), + originatingChannel: "FEISHU", + originatingTo: "ou_abc123", + } as FollowupRun, + }); expect(routeReplyMock).toHaveBeenCalled(); expect(onBlockReply).toHaveBeenCalledTimes(1); @@ -400,22 +389,17 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); it("routes followups with originating account/thread metadata", async () => { - const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: {}, + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "hello world!" }] }, + queued: { + ...baseQueuedRun("webchat"), + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "work", + originatingThreadId: "1739142736.000100", + } as FollowupRun, }); - const runner = createMessagingDedupeRunner(onBlockReply); - - await runner({ - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "work", - originatingThreadId: "1739142736.000100", - } as FollowupRun); - expect(routeReplyMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "discord", @@ -429,44 +413,37 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); describe("createFollowupRunner typing cleanup", () => { - it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => { + async function runTypingCase(agentResult: Record) { const typing = createMockTypingController(); runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "NO_REPLY" }], meta: {}, + ...agentResult, }); const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, + opts: { onBlockReply: createAsyncReplySpy() }, typing, typingMode: "instant", defaultModel: "anthropic/claude-opus-4-5", }); await runner(baseQueuedRun()); + return typing; + } + function expectTypingCleanup(typing: ReturnType) { expect(typing.markRunComplete).toHaveBeenCalled(); expect(typing.markDispatchIdle).toHaveBeenCalled(); + } + + it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => { + const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] }); + expectTypingCleanup(typing); }); it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => { - const typing = createMockTypingController(); - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, - typing, - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-5", - }); - - await runner(baseQueuedRun()); - - expect(typing.markRunComplete).toHaveBeenCalled(); - expect(typing.markDispatchIdle).toHaveBeenCalled(); + const typing = await runTypingCase({ payloads: [] }); + expectTypingCleanup(typing); }); it("calls both markRunComplete and markDispatchIdle on agent error", async () => { @@ -482,8 +459,7 @@ describe("createFollowupRunner typing cleanup", () => { await runner(baseQueuedRun()); - expect(typing.markRunComplete).toHaveBeenCalled(); - expect(typing.markDispatchIdle).toHaveBeenCalled(); + expectTypingCleanup(typing); }); it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => { @@ -504,8 +480,7 @@ describe("createFollowupRunner typing cleanup", () => { await runner(baseQueuedRun()); expect(onBlockReply).toHaveBeenCalled(); - expect(typing.markRunComplete).toHaveBeenCalled(); - expect(typing.markDispatchIdle).toHaveBeenCalled(); + expectTypingCleanup(typing); }); }); diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts index 3129bb61c..7b5869a58 100644 --- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -105,6 +105,56 @@ function buildNativeResetContext(): MsgContext { }; } +function createContinueDirectivesResult(resetHookTriggered: boolean) { + return { + kind: "continue" as const, + result: { + commandSource: "/new", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "123", + abortKey: "telegram:slash:123", + rawBodyNormalized: "/new", + commandBodyNormalized: "/new", + from: "telegram:123", + to: "slash:123", + resetHookTriggered, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "/new", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }; +} + describe("getReplyFromConfig reset-hook fallback", () => { beforeEach(() => { mocks.resolveReplyDirectives.mockReset(); @@ -131,53 +181,7 @@ describe("getReplyFromConfig reset-hook fallback", () => { bodyStripped: "", }); - mocks.resolveReplyDirectives.mockResolvedValue({ - kind: "continue", - result: { - commandSource: "/new", - command: { - surface: "telegram", - channel: "telegram", - channelId: "telegram", - ownerList: [], - senderIsOwner: true, - isAuthorizedSender: true, - senderId: "123", - abortKey: "telegram:slash:123", - rawBodyNormalized: "/new", - commandBodyNormalized: "/new", - from: "telegram:123", - to: "slash:123", - resetHookTriggered: false, - }, - allowTextCommands: true, - skillCommands: [], - directives: {}, - cleanedBody: "/new", - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - defaultActivation: "always", - resolvedThinkLevel: undefined, - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolvedElevatedLevel: "off", - execOverrides: undefined, - blockStreamingEnabled: false, - blockReplyChunking: undefined, - resolvedBlockStreamingBreak: undefined, - provider: "openai", - model: "gpt-4o-mini", - modelState: { - resolveDefaultThinkingLevel: async () => undefined, - }, - contextTokens: 0, - inlineStatusRequested: false, - directiveAck: undefined, - perMessageQueueMode: undefined, - perMessageQueueOptions: undefined, - }, - }); + mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(false)); }); it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => { @@ -196,53 +200,7 @@ describe("getReplyFromConfig reset-hook fallback", () => { it("does not emit fallback hooks when resetHookTriggered is already set", async () => { mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined }); - mocks.resolveReplyDirectives.mockResolvedValue({ - kind: "continue", - result: { - commandSource: "/new", - command: { - surface: "telegram", - channel: "telegram", - channelId: "telegram", - ownerList: [], - senderIsOwner: true, - isAuthorizedSender: true, - senderId: "123", - abortKey: "telegram:slash:123", - rawBodyNormalized: "/new", - commandBodyNormalized: "/new", - from: "telegram:123", - to: "slash:123", - resetHookTriggered: true, - }, - allowTextCommands: true, - skillCommands: [], - directives: {}, - cleanedBody: "/new", - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - defaultActivation: "always", - resolvedThinkLevel: undefined, - resolvedVerboseLevel: "off", - resolvedReasoningLevel: "off", - resolvedElevatedLevel: "off", - execOverrides: undefined, - blockStreamingEnabled: false, - blockReplyChunking: undefined, - resolvedBlockStreamingBreak: undefined, - provider: "openai", - model: "gpt-4o-mini", - modelState: { - resolveDefaultThinkingLevel: async () => undefined, - }, - contextTokens: 0, - inlineStatusRequested: false, - directiveAck: undefined, - perMessageQueueMode: undefined, - perMessageQueueOptions: undefined, - }, - }); + mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(true)); await getReplyFromConfig(buildNativeResetContext(), undefined, {}); diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 2eb92bc8d..3e5ab1ae2 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -21,3 +21,29 @@ vi.mock("../agents/model-selection.js", async (importOriginal) => { vi.mock("../agents/subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(), })); + +type LooseRecord = Record; + +export function makeIsolatedAgentJob(overrides?: LooseRecord) { + return { + id: "test-job", + name: "Test Job", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: "test" }, + ...overrides, + } as never; +} + +export function makeIsolatedAgentParams(overrides?: LooseRecord) { + const jobOverrides = + overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined; + return { + cfg: {}, + deps: {} as never, + job: makeIsolatedAgentJob(jobOverrides), + message: "test", + sessionKey: "cron:test", + ...overrides, + }; +} diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 0665be347..265b89a22 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -28,6 +28,23 @@ async function runExplicitTelegramAnnounceTurn(params: { }); } +async function withTelegramAnnounceFixture( + run: (params: { home: string; storePath: string; deps: CliDeps }) => Promise, + params?: { + deps?: Partial; + sessionStore?: { lastProvider?: string; lastTo?: string }; + }, +): Promise { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { + lastProvider: params?.sessionStore?.lastProvider ?? "webchat", + lastTo: params?.sessionStore?.lastTo ?? "", + }); + const deps = createCliDeps(params?.deps); + await run({ home, storePath, deps }); + }); +} + function expectDeliveredOk(result: Awaited>): void { expect(result.status).toBe("ok"); expect(result.delivered).toBe(true); @@ -36,12 +53,67 @@ function expectDeliveredOk(result: Awaited, ): Promise { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps({ - sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - }); - mockAgentPayloads([payload]); + await expectStructuredTelegramFailure({ + payload, + bestEffort: true, + expectedStatus: "ok", + expectDeliveryAttempted: true, + }); +} + +async function expectStructuredTelegramFailure(params: { + payload: Record; + bestEffort: boolean; + expectedStatus: "ok" | "error"; + expectedErrorFragment?: string; + expectDeliveryAttempted?: boolean; +}): Promise { + await withTelegramAnnounceFixture( + async ({ home, storePath, deps }) => { + mockAgentPayloads([params.payload]); + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + ...(params.bestEffort ? { bestEffort: true } : {}), + }, + }); + + expect(res.status).toBe(params.expectedStatus); + if (params.expectedStatus === "ok") { + expect(res.delivered).toBe(false); + } + if (params.expectDeliveryAttempted !== undefined) { + expect(res.deliveryAttempted).toBe(params.expectDeliveryAttempted); + } + if (params.expectedErrorFragment) { + expect(res.error).toContain(params.expectedErrorFragment); + } + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + }, + { + deps: { + sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), + }, + }, + ); +} + +async function runAnnounceFlowResult(bestEffort: boolean) { + let outcome: + | { + res: Awaited>; + deps: CliDeps; + } + | undefined; + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { + mockAgentPayloads([{ text: "hello from cron" }]); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); const res = await runTelegramAnnounceTurn({ home, storePath, @@ -50,25 +122,22 @@ async function expectBestEffortTelegramNotDelivered( mode: "announce", channel: "telegram", to: "123", - bestEffort: true, + bestEffort, }, }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(false); - expect(res.deliveryAttempted).toBe(true); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + outcome = { res, deps }; }); + if (!outcome) { + throw new Error("announce flow did not produce an outcome"); + } + return outcome; } async function expectExplicitTelegramTargetAnnounce(params: { payloads: Array>; expectedText: string; }): Promise { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads(params.payloads); const res = await runExplicitTelegramAnnounceTurn({ home, @@ -116,9 +185,7 @@ describe("runCronIsolatedAgentTurn", () => { }); it("routes announce injection to the delivery-target session key", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads([{ text: "hello from cron" }]); const res = await runCronIsolatedAgentTurn({ @@ -200,9 +267,7 @@ describe("runCronIsolatedAgentTurn", () => { }); it("skips announce when messaging tool already sent to target", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads([{ text: "sent" }], { didSendViaMessagingTool: true, messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], @@ -228,9 +293,7 @@ describe("runCronIsolatedAgentTurn", () => { }); it("skips announce for heartbeat-only output", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); + await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads([{ text: "HEARTBEAT_OK" }]); const res = await runTelegramAnnounceTurn({ home, @@ -246,76 +309,28 @@ describe("runCronIsolatedAgentTurn", () => { }); it("fails when structured direct delivery fails and best-effort is disabled", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps({ - sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - }); - mockAgentPayloads([{ text: "hello from cron", mediaUrl: "https://example.com/img.png" }]); - const res = await runTelegramAnnounceTurn({ - home, - storePath, - deps, - delivery: { mode: "announce", channel: "telegram", to: "123" }, - }); - - expect(res.status).toBe("error"); - expect(res.error).toContain("boom"); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + await expectStructuredTelegramFailure({ + payload: { text: "hello from cron", mediaUrl: "https://example.com/img.png" }, + bestEffort: false, + expectedStatus: "error", + expectedErrorFragment: "boom", }); }); it("fails when announce delivery reports false and best-effort is disabled", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); - mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); - - const res = await runTelegramAnnounceTurn({ - home, - storePath, - deps, - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: false, - }, - }); - - expect(res.status).toBe("error"); - expect(res.error).toContain("cron announce delivery failed"); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); - }); + const { res, deps } = await runAnnounceFlowResult(false); + expect(res.status).toBe("error"); + expect(res.error).toContain("cron announce delivery failed"); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); }); it("marks attempted when announce delivery reports false and best-effort is enabled", async () => { - await withTempCronHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); - mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); - - const res = await runTelegramAnnounceTurn({ - home, - storePath, - deps, - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: true, - }, - }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(false); - expect(res.deliveryAttempted).toBe(true); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); - }); + const { res, deps } = await runAnnounceFlowResult(true); + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); }); it("ignores structured direct delivery failures when best-effort is enabled", async () => { diff --git a/src/cron/isolated-agent.subagent-model.test.ts b/src/cron/isolated-agent.subagent-model.test.ts index eb8d2732a..ea651f5d8 100644 --- a/src/cron/isolated-agent.subagent-model.test.ts +++ b/src/cron/isolated-agent.subagent-model.test.ts @@ -1,23 +1,14 @@ +import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; -import type { CliDeps } from "../cli/deps.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { CronJob } from "./types.js"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(), -})); - import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { OpenClawConfig } from "../config/config.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import type { CronJob } from "./types.js"; async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" }); @@ -100,50 +91,93 @@ function mockEmbeddedAgent() { }); } +async function runSubagentModelCase(params: { + home: string; + cfgOverrides?: Partial; + jobModelOverride?: string; +}) { + const storePath = await writeSessionStore(params.home); + mockEmbeddedAgent(); + const job = makeJob(); + if (params.jobModelOverride) { + job.payload = { kind: "agentTurn", message: "do work", model: params.jobModelOverride }; + } + + await runCronIsolatedAgentTurn({ + cfg: makeCfg(params.home, storePath, params.cfgOverrides), + deps: makeDeps(), + job, + message: "do work", + sessionKey: "cron:job-sub", + lane: "cron", + }); + + return vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; +} + describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => { beforeEach(() => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([]); }); - it("uses agents.defaults.subagents.model when set", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - mockEmbeddedAgent(); - - await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - agents: { - defaults: { - model: "anthropic/claude-sonnet-4-5", - workspace: path.join(home, "openclaw"), - subagents: { model: "ollama/llama3.2:3b" }, - }, + it.each([ + { + name: "uses agents.defaults.subagents.model when set", + cfgOverrides: { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-5", + subagents: { model: "ollama/llama3.2:3b" }, }, - }), - deps: makeDeps(), - job: makeJob(), - message: "do work", - sessionKey: "cron:job-sub", - lane: "cron", - }); - - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("ollama"); - expect(call?.model).toBe("llama3.2:3b"); + }, + } satisfies Partial, + expectedProvider: "ollama", + expectedModel: "llama3.2:3b", + }, + { + name: "falls back to main model when subagents.model is unset", + cfgOverrides: undefined, + expectedProvider: "anthropic", + expectedModel: "claude-sonnet-4-5", + }, + { + name: "supports subagents.model with {primary} object format", + cfgOverrides: { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-5", + subagents: { model: { primary: "google/gemini-2.5-flash" } }, + }, + }, + } satisfies Partial, + expectedProvider: "google", + expectedModel: "gemini-2.5-flash", + }, + ])("$name", async ({ cfgOverrides, expectedProvider, expectedModel }) => { + await withTempHome(async (home) => { + const resolvedCfg = + cfgOverrides === undefined + ? undefined + : ({ + agents: { + defaults: { + ...cfgOverrides.agents?.defaults, + workspace: path.join(home, "openclaw"), + }, + }, + } satisfies Partial); + const call = await runSubagentModelCase({ home, cfgOverrides: resolvedCfg }); + expect(call?.provider).toBe(expectedProvider); + expect(call?.model).toBe(expectedModel); }); }); it("explicit job model override takes precedence over subagents.model", async () => { await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - mockEmbeddedAgent(); - - const job = makeJob(); - job.payload = { kind: "agentTurn", message: "do work", model: "openai/gpt-4o" }; - - await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { + const call = await runSubagentModelCase({ + home, + cfgOverrides: { agents: { defaults: { model: "anthropic/claude-sonnet-4-5", @@ -151,65 +185,11 @@ describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => { subagents: { model: "ollama/llama3.2:3b" }, }, }, - }), - deps: makeDeps(), - job, - message: "do work", - sessionKey: "cron:job-sub", - lane: "cron", + }, + jobModelOverride: "openai/gpt-4o", }); - - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; expect(call?.provider).toBe("openai"); expect(call?.model).toBe("gpt-4o"); }); }); - - it("falls back to main model when subagents.model is unset", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - mockEmbeddedAgent(); - - await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath), - deps: makeDeps(), - job: makeJob(), - message: "do work", - sessionKey: "cron:job-sub", - lane: "cron", - }); - - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("anthropic"); - expect(call?.model).toBe("claude-sonnet-4-5"); - }); - }); - - it("supports subagents.model with {primary} object format", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - mockEmbeddedAgent(); - - await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - agents: { - defaults: { - model: "anthropic/claude-sonnet-4-5", - workspace: path.join(home, "openclaw"), - subagents: { model: { primary: "google/gemini-2.5-flash" } }, - }, - }, - }), - deps: makeDeps(), - job: makeJob(), - message: "do work", - sessionKey: "cron:job-sub", - lane: "cron", - }); - - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("google"); - expect(call?.model).toBe("gemini-2.5-flash"); - }); - }); }); diff --git a/src/cron/isolated-agent/run.cron-model-override.test.ts b/src/cron/isolated-agent/run.cron-model-override.test.ts index 796606e4b..eb8f9eae7 100644 --- a/src/cron/isolated-agent/run.cron-model-override.test.ts +++ b/src/cron/isolated-agent/run.cron-model-override.test.ts @@ -1,183 +1,21 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + clearFastTestEnv, + loadRunCronIsolatedAgentTurn, + logWarnMock, + makeCronSession, + makeCronSessionEntry, + resolveAgentConfigMock, + resolveAllowedModelRefMock, + resolveConfiguredModelRefMock, + resolveCronSessionMock, + resetRunCronIsolatedAgentTurnHarness, + restoreFastTestEnv, + runWithModelFallbackMock, + updateSessionStoreMock, +} from "./run.test-harness.js"; -// ---------- mocks ---------- - -const resolveAgentConfigMock = vi.fn(); - -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentConfig: resolveAgentConfigMock, - resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), - resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined), - resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), - resolveDefaultAgentId: vi.fn().mockReturnValue("default"), - resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined), -})); - -vi.mock("../../agents/skills.js", () => ({ - buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({ - prompt: "", - resolvedSkills: [], - version: 42, - }), -})); - -vi.mock("../../agents/skills/refresh.js", () => ({ - getSkillsSnapshotVersion: vi.fn().mockReturnValue(42), -})); - -vi.mock("../../agents/workspace.js", () => ({ - ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), -})); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), -})); - -const resolveAllowedModelRefMock = vi.fn(); -const resolveConfiguredModelRefMock = vi.fn(); - -vi.mock("../../agents/model-selection.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), - isCliProvider: vi.fn().mockReturnValue(false), - resolveAllowedModelRef: resolveAllowedModelRefMock, - resolveConfiguredModelRef: resolveConfiguredModelRefMock, - resolveHooksGmailModel: vi.fn().mockReturnValue(null), - resolveThinkingDefault: vi.fn().mockReturnValue(undefined), - }; -}); - -vi.mock("../../agents/model-fallback.js", () => ({ - runWithModelFallback: vi.fn(), -})); - -const runWithModelFallbackMock = vi.mocked(runWithModelFallback); - -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: vi.fn(), -})); - -vi.mock("../../agents/context.js", () => ({ - lookupContextTokens: vi.fn().mockReturnValue(128000), -})); - -vi.mock("../../agents/date-time.js", () => ({ - formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"), - resolveUserTimeFormat: vi.fn().mockReturnValue("24h"), - resolveUserTimezone: vi.fn().mockReturnValue("UTC"), -})); - -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000), -})); - -vi.mock("../../agents/usage.js", () => ({ - deriveSessionTotalTokens: vi.fn().mockReturnValue(30), - hasNonzeroUsage: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), -})); - -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: vi.fn(), -})); - -vi.mock("../../agents/cli-session.js", () => ({ - getCliSessionId: vi.fn().mockReturnValue(undefined), - setCliSessionId: vi.fn(), -})); - -vi.mock("../../auto-reply/thinking.js", () => ({ - normalizeThinkLevel: vi.fn().mockReturnValue(undefined), - normalizeVerboseLevel: vi.fn().mockReturnValue("off"), - supportsXHighThinking: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../cli/outbound-send-deps.js", () => ({ - createOutboundSendDeps: vi.fn().mockReturnValue({}), -})); - -const updateSessionStoreMock = vi.fn().mockResolvedValue(undefined); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"), - resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"), - setSessionRuntimeModel: vi.fn(), - updateSessionStore: updateSessionStoreMock, -})); - -vi.mock("../../routing/session-key.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"), - normalizeAgentId: vi.fn((id: string) => id), - }; -}); - -vi.mock("../../infra/agent-events.js", () => ({ - registerAgentRunContext: vi.fn(), -})); - -vi.mock("../../infra/outbound/deliver.js", () => ({ - deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/skills-remote.js", () => ({ - getRemoteSkillEligibility: vi.fn().mockReturnValue({}), -})); - -const logWarnMock = vi.fn(); -vi.mock("../../logger.js", () => ({ - logWarn: logWarnMock, -})); - -vi.mock("../../security/external-content.js", () => ({ - buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"), - detectSuspiciousPatterns: vi.fn().mockReturnValue([]), - getHookType: vi.fn().mockReturnValue("unknown"), - isExternalHookSession: vi.fn().mockReturnValue(false), -})); - -vi.mock("../delivery.js", () => ({ - resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }), -})); - -vi.mock("./delivery-target.js", () => ({ - resolveDeliveryTarget: vi.fn().mockResolvedValue({ - channel: "discord", - to: undefined, - accountId: undefined, - error: undefined, - }), -})); - -vi.mock("./helpers.js", () => ({ - isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false), - pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined), - pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"), - pickSummaryFromOutput: vi.fn().mockReturnValue("summary"), - pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"), - resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100), -})); - -const resolveCronSessionMock = vi.fn(); -vi.mock("./session.js", () => ({ - resolveCronSession: resolveCronSessionMock, -})); - -vi.mock("../../agents/defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 128000, - DEFAULT_MODEL: "gpt-4", - DEFAULT_PROVIDER: "openai", -})); - -const { runCronIsolatedAgentTurn } = await import("./run.js"); +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); // ---------- helpers ---------- @@ -209,10 +47,7 @@ function makeParams(overrides?: Record) { function makeFreshSessionEntry(overrides?: Record) { return { - sessionId: "test-session-id", - updatedAt: 0, - systemSent: false, - skillsSnapshot: undefined, + ...makeCronSessionEntry(), // Crucially: no model or modelProvider — simulates a brand-new session model: undefined as string | undefined, modelProvider: undefined as string | undefined, @@ -249,9 +84,8 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => { let cronSession: { sessionEntry: ReturnType; [k: string]: unknown }; beforeEach(() => { - vi.clearAllMocks(); - previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; - delete process.env.OPENCLAW_TEST_FAST; + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); // Agent default model is Opus resolveConfiguredModelRefMock.mockReturnValue({ @@ -267,22 +101,14 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => { resolveAgentConfigMock.mockReturnValue(undefined); updateSessionStoreMock.mockResolvedValue(undefined); - cronSession = { - storePath: "/tmp/store.json", - store: {}, + cronSession = makeCronSession({ sessionEntry: makeFreshSessionEntry(), - systemSent: false, - isNewSession: true, - }; + }) as { sessionEntry: ReturnType; [k: string]: unknown }; resolveCronSessionMock.mockReturnValue(cronSession); }); afterEach(() => { - if (previousFastTestEnv == null) { - delete process.env.OPENCLAW_TEST_FAST; - return; - } - process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; + restoreFastTestEnv(previousFastTestEnv); }); it("persists cron payload model on session entry even when the run throws", async () => { diff --git a/src/cron/isolated-agent/run.payload-fallbacks.test.ts b/src/cron/isolated-agent/run.payload-fallbacks.test.ts index 9250a0176..c1fe0fd73 100644 --- a/src/cron/isolated-agent/run.payload-fallbacks.test.ts +++ b/src/cron/isolated-agent/run.payload-fallbacks.test.ts @@ -1,193 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + clearFastTestEnv, + loadRunCronIsolatedAgentTurn, + makeCronSession, + resolveAgentModelFallbacksOverrideMock, + resolveCronSessionMock, + resetRunCronIsolatedAgentTurnHarness, + restoreFastTestEnv, + runWithModelFallbackMock, +} from "./run.test-harness.js"; -// ---------- mocks (same pattern as run.skill-filter.test.ts) ---------- +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); -const resolveAgentModelFallbacksOverrideMock = vi.fn(); - -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentConfig: vi.fn(), - resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), - resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock, - resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), - resolveDefaultAgentId: vi.fn().mockReturnValue("default"), - resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined), -})); - -vi.mock("../../agents/skills.js", () => ({ - buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({ - prompt: "", - resolvedSkills: [], - version: 42, - }), -})); - -vi.mock("../../agents/skills/refresh.js", () => ({ - getSkillsSnapshotVersion: vi.fn().mockReturnValue(42), -})); - -vi.mock("../../agents/workspace.js", () => ({ - ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), -})); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), -})); - -vi.mock("../../agents/model-selection.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }), - isCliProvider: vi.fn().mockReturnValue(false), - resolveAllowedModelRef: vi - .fn() - .mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }), - resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }), - resolveHooksGmailModel: vi.fn().mockReturnValue(null), - resolveThinkingDefault: vi.fn().mockReturnValue(undefined), - }; -}); - -vi.mock("../../agents/model-fallback.js", () => ({ - runWithModelFallback: vi.fn().mockResolvedValue({ - result: { - payloads: [{ text: "test output" }], - meta: { agentMeta: { usage: { input: 10, output: 20 } } }, - }, - provider: "openai", - model: "gpt-4", - }), -})); - -const runWithModelFallbackMock = vi.mocked(runWithModelFallback); - -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: vi.fn().mockResolvedValue({ - payloads: [{ text: "test output" }], - meta: { agentMeta: { usage: { input: 10, output: 20 } } }, - }), -})); - -vi.mock("../../agents/context.js", () => ({ - lookupContextTokens: vi.fn().mockReturnValue(128000), -})); - -vi.mock("../../agents/date-time.js", () => ({ - formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"), - resolveUserTimeFormat: vi.fn().mockReturnValue("24h"), - resolveUserTimezone: vi.fn().mockReturnValue("UTC"), -})); - -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000), -})); - -vi.mock("../../agents/usage.js", () => ({ - deriveSessionTotalTokens: vi.fn().mockReturnValue(30), - hasNonzeroUsage: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), -})); - -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: vi.fn(), -})); - -vi.mock("../../agents/cli-session.js", () => ({ - getCliSessionId: vi.fn().mockReturnValue(undefined), - setCliSessionId: vi.fn(), -})); - -vi.mock("../../auto-reply/thinking.js", () => ({ - normalizeThinkLevel: vi.fn().mockReturnValue(undefined), - normalizeVerboseLevel: vi.fn().mockReturnValue("off"), - supportsXHighThinking: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../cli/outbound-send-deps.js", () => ({ - createOutboundSendDeps: vi.fn().mockReturnValue({}), -})); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"), - resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"), - setSessionRuntimeModel: vi.fn(), - updateSessionStore: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../routing/session-key.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"), - normalizeAgentId: vi.fn((id: string) => id), - }; -}); - -vi.mock("../../infra/agent-events.js", () => ({ - registerAgentRunContext: vi.fn(), -})); - -vi.mock("../../infra/outbound/deliver.js", () => ({ - deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/skills-remote.js", () => ({ - getRemoteSkillEligibility: vi.fn().mockReturnValue({}), -})); - -vi.mock("../../logger.js", () => ({ - logWarn: vi.fn(), -})); - -vi.mock("../../security/external-content.js", () => ({ - buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"), - detectSuspiciousPatterns: vi.fn().mockReturnValue([]), - getHookType: vi.fn().mockReturnValue("unknown"), - isExternalHookSession: vi.fn().mockReturnValue(false), -})); - -vi.mock("../delivery.js", () => ({ - resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }), -})); - -vi.mock("./delivery-target.js", () => ({ - resolveDeliveryTarget: vi.fn().mockResolvedValue({ - channel: "discord", - to: undefined, - accountId: undefined, - error: undefined, - }), -})); - -vi.mock("./helpers.js", () => ({ - isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false), - pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined), - pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"), - pickSummaryFromOutput: vi.fn().mockReturnValue("summary"), - pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"), - resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100), -})); - -const resolveCronSessionMock = vi.fn(); -vi.mock("./session.js", () => ({ - resolveCronSession: resolveCronSessionMock, -})); - -vi.mock("../../agents/defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 128000, - DEFAULT_MODEL: "gpt-4", - DEFAULT_PROVIDER: "openai", -})); - -const { runCronIsolatedAgentTurn } = await import("./run.js"); - -// ---------- helpers ---------- - -function makeJob(overrides?: Record) { +function makePayloadJob(overrides?: Record) { return { id: "test-job", name: "Test Job", @@ -198,11 +23,11 @@ function makeJob(overrides?: Record) { } as never; } -function makeParams(overrides?: Record) { +function makePayloadParams(overrides?: Record) { return { cfg: {}, deps: {} as never, - job: makeJob(overrides?.job ? (overrides.job as Record) : undefined), + job: makePayloadJob(overrides?.job as Record | undefined), message: "test", sessionKey: "cron:test", ...overrides, @@ -215,80 +40,50 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => { let previousFastTestEnv: string | undefined; beforeEach(() => { - vi.clearAllMocks(); - previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; - delete process.env.OPENCLAW_TEST_FAST; - resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined); - resolveCronSessionMock.mockReturnValue({ - storePath: "/tmp/store.json", - store: {}, - sessionEntry: { - sessionId: "test-session-id", - updatedAt: 0, - systemSent: false, - skillsSnapshot: undefined, - }, - systemSent: false, - isNewSession: true, - }); + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + resolveCronSessionMock.mockReturnValue(makeCronSession()); }); afterEach(() => { - if (previousFastTestEnv == null) { - delete process.env.OPENCLAW_TEST_FAST; - return; + restoreFastTestEnv(previousFastTestEnv); + }); + + it.each([ + { + name: "passes payload.fallbacks as fallbacksOverride when defined", + payload: { + kind: "agentTurn", + message: "test", + fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"], + }, + expectedFallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"], + }, + { + name: "falls back to agent-level fallbacks when payload.fallbacks is undefined", + payload: { kind: "agentTurn", message: "test" }, + agentFallbacks: ["openai/gpt-4o"], + expectedFallbacks: ["openai/gpt-4o"], + }, + { + name: "payload.fallbacks=[] disables fallbacks even when agent config has them", + payload: { kind: "agentTurn", message: "test", fallbacks: [] }, + agentFallbacks: ["openai/gpt-4o"], + expectedFallbacks: [], + }, + ])("$name", async ({ payload, agentFallbacks, expectedFallbacks }) => { + if (agentFallbacks) { + resolveAgentModelFallbacksOverrideMock.mockReturnValue(agentFallbacks); } - process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; - }); - it("passes payload.fallbacks as fallbacksOverride when defined", async () => { const result = await runCronIsolatedAgentTurn( - makeParams({ - job: makeJob({ - payload: { - kind: "agentTurn", - message: "test", - fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"], - }, - }), + makePayloadParams({ + job: makePayloadJob({ payload }), }), ); expect(result.status).toBe("ok"); expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); - expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([ - "anthropic/claude-sonnet-4-6", - "openai/gpt-5", - ]); - }); - - it("falls back to agent-level fallbacks when payload.fallbacks is undefined", async () => { - resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]); - - const result = await runCronIsolatedAgentTurn( - makeParams({ - job: makeJob({ payload: { kind: "agentTurn", message: "test" } }), - }), - ); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); - expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(["openai/gpt-4o"]); - }); - - it("payload.fallbacks=[] disables fallbacks even when agent config has them", async () => { - resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]); - - const result = await runCronIsolatedAgentTurn( - makeParams({ - job: makeJob({ - payload: { kind: "agentTurn", message: "test", fallbacks: [] }, - }), - }), - ); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); - expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([]); + expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(expectedFallbacks); }); }); diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 5e4c410af..67b6bfedb 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -1,198 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildWorkspaceSkillSnapshotMock, + clearFastTestEnv, + getCliSessionIdMock, + isCliProviderMock, + loadRunCronIsolatedAgentTurn, + logWarnMock, + makeCronSession, + resolveAgentConfigMock, + resolveAgentSkillsFilterMock, + resolveAllowedModelRefMock, + resolveCronSessionMock, + resetRunCronIsolatedAgentTurnHarness, + restoreFastTestEnv, + runCliAgentMock, + runWithModelFallbackMock, +} from "./run.test-harness.js"; -// ---------- mocks ---------- +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); -const buildWorkspaceSkillSnapshotMock = vi.fn(); -const resolveAgentConfigMock = vi.fn(); -const resolveAgentSkillsFilterMock = vi.fn(); -const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false }); -const isCliProviderMock = vi.fn().mockReturnValue(false); -const resolveAllowedModelRefMock = vi.fn(); -const resolveConfiguredModelRefMock = vi.fn(); -const resolveHooksGmailModelMock = vi.fn(); -const resolveThinkingDefaultMock = vi.fn(); -const logWarnMock = vi.fn(); - -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentConfig: resolveAgentConfigMock, - resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), - resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined), - resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), - resolveDefaultAgentId: vi.fn().mockReturnValue("default"), - resolveAgentSkillsFilter: resolveAgentSkillsFilterMock, -})); - -vi.mock("../../agents/skills.js", () => ({ - buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, -})); - -vi.mock("../../agents/skills/refresh.js", () => ({ - getSkillsSnapshotVersion: vi.fn().mockReturnValue(42), -})); - -vi.mock("../../agents/workspace.js", () => ({ - ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), -})); - -vi.mock("../../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), -})); - -vi.mock("../../agents/model-selection.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getModelRefStatus: getModelRefStatusMock, - isCliProvider: isCliProviderMock, - resolveAllowedModelRef: resolveAllowedModelRefMock, - resolveConfiguredModelRef: resolveConfiguredModelRefMock, - resolveHooksGmailModel: resolveHooksGmailModelMock, - resolveThinkingDefault: resolveThinkingDefaultMock, - }; -}); - -vi.mock("../../agents/model-fallback.js", () => ({ - runWithModelFallback: vi.fn().mockResolvedValue({ - result: { - payloads: [{ text: "test output" }], - meta: { agentMeta: { usage: { input: 10, output: 20 } } }, - }, - provider: "openai", - model: "gpt-4", - }), -})); - -const runWithModelFallbackMock = vi.mocked(runWithModelFallback); - -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: vi.fn().mockResolvedValue({ - payloads: [{ text: "test output" }], - meta: { agentMeta: { usage: { input: 10, output: 20 } } }, - }), -})); - -vi.mock("../../agents/context.js", () => ({ - lookupContextTokens: vi.fn().mockReturnValue(128000), -})); - -vi.mock("../../agents/date-time.js", () => ({ - formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"), - resolveUserTimeFormat: vi.fn().mockReturnValue("24h"), - resolveUserTimezone: vi.fn().mockReturnValue("UTC"), -})); - -vi.mock("../../agents/timeout.js", () => ({ - resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000), -})); - -vi.mock("../../agents/usage.js", () => ({ - deriveSessionTotalTokens: vi.fn().mockReturnValue(30), - hasNonzeroUsage: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), -})); - -const runCliAgentMock = vi.fn(); -vi.mock("../../agents/cli-runner.js", () => ({ - runCliAgent: runCliAgentMock, -})); - -const getCliSessionIdMock = vi.fn().mockReturnValue(undefined); -vi.mock("../../agents/cli-session.js", () => ({ - getCliSessionId: getCliSessionIdMock, - setCliSessionId: vi.fn(), -})); - -vi.mock("../../auto-reply/thinking.js", () => ({ - normalizeThinkLevel: vi.fn().mockReturnValue(undefined), - normalizeVerboseLevel: vi.fn().mockReturnValue("off"), - supportsXHighThinking: vi.fn().mockReturnValue(false), -})); - -vi.mock("../../cli/outbound-send-deps.js", () => ({ - createOutboundSendDeps: vi.fn().mockReturnValue({}), -})); - -vi.mock("../../config/sessions.js", () => ({ - resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"), - resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"), - setSessionRuntimeModel: vi.fn(), - updateSessionStore: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../routing/session-key.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"), - normalizeAgentId: vi.fn((id: string) => id), - }; -}); - -vi.mock("../../infra/agent-events.js", () => ({ - registerAgentRunContext: vi.fn(), -})); - -vi.mock("../../infra/outbound/deliver.js", () => ({ - deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../infra/skills-remote.js", () => ({ - getRemoteSkillEligibility: vi.fn().mockReturnValue({}), -})); - -vi.mock("../../logger.js", () => ({ - logWarn: (...args: unknown[]) => logWarnMock(...args), -})); - -vi.mock("../../security/external-content.js", () => ({ - buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"), - detectSuspiciousPatterns: vi.fn().mockReturnValue([]), - getHookType: vi.fn().mockReturnValue("unknown"), - isExternalHookSession: vi.fn().mockReturnValue(false), -})); - -vi.mock("../delivery.js", () => ({ - resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }), -})); - -vi.mock("./delivery-target.js", () => ({ - resolveDeliveryTarget: vi.fn().mockResolvedValue({ - channel: "discord", - to: undefined, - accountId: undefined, - error: undefined, - }), -})); - -vi.mock("./helpers.js", () => ({ - isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false), - pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined), - pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"), - pickSummaryFromOutput: vi.fn().mockReturnValue("summary"), - pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"), - resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100), -})); - -const resolveCronSessionMock = vi.fn(); -vi.mock("./session.js", () => ({ - resolveCronSession: resolveCronSessionMock, -})); - -vi.mock("../../agents/defaults.js", () => ({ - DEFAULT_CONTEXT_TOKENS: 128000, - DEFAULT_MODEL: "gpt-4", - DEFAULT_PROVIDER: "openai", -})); - -const { runCronIsolatedAgentTurn } = await import("./run.js"); - -// ---------- helpers ---------- - -function makeJob(overrides?: Record) { +function makeSkillJob(overrides?: Record) { return { id: "test-job", name: "Test Job", @@ -203,11 +30,11 @@ function makeJob(overrides?: Record) { } as never; } -function makeParams(overrides?: Record) { +function makeSkillParams(overrides?: Record) { return { cfg: {}, deps: {} as never, - job: makeJob(), + job: makeSkillJob(overrides?.job as Record | undefined), message: "test", sessionKey: "cron:test", ...overrides, @@ -219,57 +46,45 @@ function makeParams(overrides?: Record) { describe("runCronIsolatedAgentTurn — skill filter", () => { let previousFastTestEnv: string | undefined; beforeEach(() => { - vi.clearAllMocks(); - previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; - delete process.env.OPENCLAW_TEST_FAST; - buildWorkspaceSkillSnapshotMock.mockReturnValue({ - prompt: "", - resolvedSkills: [], - version: 42, - }); - resolveAgentConfigMock.mockReturnValue(undefined); - resolveAgentSkillsFilterMock.mockReturnValue(undefined); - resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" }); - resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }); - resolveHooksGmailModelMock.mockReturnValue(null); - resolveThinkingDefaultMock.mockReturnValue(undefined); - getModelRefStatusMock.mockReturnValue({ allowed: false }); - isCliProviderMock.mockReturnValue(false); - logWarnMock.mockReset(); - // Fresh session object per test — prevents mutation leaking between tests - resolveCronSessionMock.mockReturnValue({ - storePath: "/tmp/store.json", - store: {}, - sessionEntry: { - sessionId: "test-session-id", - updatedAt: 0, - systemSent: false, - skillsSnapshot: undefined, - }, - systemSent: false, - isNewSession: true, - }); + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + resolveCronSessionMock.mockReturnValue(makeCronSession()); }); afterEach(() => { - if (previousFastTestEnv == null) { - delete process.env.OPENCLAW_TEST_FAST; - return; - } - process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; + restoreFastTestEnv(previousFastTestEnv); }); + async function runSkillFilterCase(overrides?: Record) { + const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides)); + expect(result.status).toBe("ok"); + return result; + } + + function expectDefaultModelCall(params: { primary: string; fallbacks: string[] }) { + expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); + const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg; + const model = callCfg?.agents?.defaults?.model as { primary?: string; fallbacks?: string[] }; + expect(model?.primary).toBe(params.primary); + expect(model?.fallbacks).toEqual(params.fallbacks); + } + + function mockCliFallbackInvocation() { + runWithModelFallbackMock.mockImplementationOnce( + async (params: { run: (provider: string, model: string) => Promise }) => { + const result = await params.run("claude-cli", "claude-opus-4-6"); + return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] }; + }, + ); + } + it("passes agent-level skillFilter to buildWorkspaceSkillSnapshot", async () => { resolveAgentSkillsFilterMock.mockReturnValue(["meme-factory", "weather"]); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } }, - agentId: "scout", - }), - ); - - expect(result.status).toBe("ok"); + await runSkillFilterCase({ + cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } }, + agentId: "scout", + }); expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [ "meme-factory", @@ -280,14 +95,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { it("omits skillFilter when agent has no skills config", async () => { resolveAgentSkillsFilterMock.mockReturnValue(undefined); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { agents: { list: [{ id: "general" }] } }, - agentId: "general", - }), - ); - - expect(result.status).toBe("ok"); + await runSkillFilterCase({ + cfg: { agents: { list: [{ id: "general" }] } }, + agentId: "general", + }); expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); // When no skills config, skillFilter should be undefined (no filtering applied) expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1].skillFilter).toBeUndefined(); @@ -296,14 +107,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { it("passes empty skillFilter when agent explicitly disables all skills", async () => { resolveAgentSkillsFilterMock.mockReturnValue([]); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { agents: { list: [{ id: "silent", skills: [] }] } }, - agentId: "silent", - }), - ); - - expect(result.status).toBe("ok"); + await runSkillFilterCase({ + cfg: { agents: { list: [{ id: "silent", skills: [] }] } }, + agentId: "silent", + }); expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); // Explicit empty skills list should forward [] to filter out all skills expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", []); @@ -328,14 +135,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isNewSession: true, }); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } }, - agentId: "weather-bot", - }), - ); - - expect(result.status).toBe("ok"); + await runSkillFilterCase({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } }, + agentId: "weather-bot", + }); expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [ "weather", @@ -343,9 +146,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { }); it("forces a fresh session for isolated cron runs", async () => { - const result = await runCronIsolatedAgentTurn(makeParams()); - - expect(result.status).toBe("ok"); + await runSkillFilterCase(); expect(resolveCronSessionMock).toHaveBeenCalledOnce(); expect(resolveCronSessionMock.mock.calls[0]?.[0]).toMatchObject({ forceNew: true, @@ -372,14 +173,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isNewSession: true, }); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } }, - agentId: "weather-bot", - }), - ); - - expect(result.status).toBe("ok"); + await runSkillFilterCase({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } }, + agentId: "weather-bot", + }); expect(buildWorkspaceSkillSnapshotMock).not.toHaveBeenCalled(); }); @@ -392,27 +189,21 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { async function expectPrimaryOverridePreservesDefaults(modelOverride: unknown) { resolveAgentConfigMock.mockReturnValue({ model: modelOverride }); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { - agents: { - defaults: { - model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks }, - }, + await runSkillFilterCase({ + cfg: { + agents: { + defaults: { + model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks }, }, }, - agentId: "scout", - }), - ); + }, + agentId: "scout", + }); - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); - const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg; - const model = callCfg?.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - expect(model?.primary).toBe("anthropic/claude-sonnet-4-5"); - expect(model?.fallbacks).toEqual(defaultFallbacks); + expectDefaultModelCall({ + primary: "anthropic/claude-sonnet-4-5", + fallbacks: defaultFallbacks, + }); } it("preserves defaults when agent overrides primary as string", async () => { @@ -429,8 +220,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { }); const result = await runCronIsolatedAgentTurn( - makeParams({ - job: makeJob({ + makeSkillParams({ + job: makeSkillJob({ payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" }, }), }), @@ -449,32 +240,25 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { error: "model not allowed: anthropic/claude-sonnet-4-6", }); - const result = await runCronIsolatedAgentTurn( - makeParams({ - cfg: { - agents: { - defaults: { - model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks }, - }, + await runSkillFilterCase({ + cfg: { + agents: { + defaults: { + model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks }, }, }, - job: makeJob({ - payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" }, - }), + }, + job: makeSkillJob({ + payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" }, }), - ); - - expect(result.status).toBe("ok"); + }); expect(logWarnMock).toHaveBeenCalledWith( "cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults", ); - expect(runWithModelFallbackMock).toHaveBeenCalledOnce(); - const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg; - const model = callCfg?.agents?.defaults?.model as - | { primary?: string; fallbacks?: string[] } - | undefined; - expect(model?.primary).toBe("openai-codex/gpt-5.3-codex"); - expect(model?.fallbacks).toEqual(defaultFallbacks); + expectDefaultModelCall({ + primary: "openai-codex/gpt-5.3-codex", + fallbacks: defaultFallbacks, + }); }); it("returns an error when payload.model is invalid", async () => { @@ -483,8 +267,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { }); const result = await runCronIsolatedAgentTurn( - makeParams({ - job: makeJob({ + makeSkillParams({ + job: makeSkillJob({ payload: { kind: "agentTurn", message: "test", model: "openai/" }, }), }), @@ -507,12 +291,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { meta: { agentMeta: { sessionId: "new-cli-session-xyz", usage: { input: 5, output: 10 } } }, }); // Make runWithModelFallback invoke the run callback so the CLI path executes. - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - const result = await params.run("claude-cli", "claude-opus-4-6"); - return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] }; - }, - ); + mockCliFallbackInvocation(); resolveCronSessionMock.mockReturnValue({ storePath: "/tmp/store.json", store: {}, @@ -528,7 +307,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isNewSession: true, }); - await runCronIsolatedAgentTurn(makeParams()); + await runCronIsolatedAgentTurn(makeSkillParams()); expect(runCliAgentMock).toHaveBeenCalledOnce(); // Fresh session: cliSessionId must be undefined, not the stored value. @@ -544,12 +323,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { agentMeta: { sessionId: "existing-cli-session-def", usage: { input: 5, output: 10 } }, }, }); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - const result = await params.run("claude-cli", "claude-opus-4-6"); - return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] }; - }, - ); + mockCliFallbackInvocation(); resolveCronSessionMock.mockReturnValue({ storePath: "/tmp/store.json", store: {}, @@ -564,7 +338,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { isNewSession: false, }); - await runCronIsolatedAgentTurn(makeParams()); + await runCronIsolatedAgentTurn(makeSkillParams()); expect(runCliAgentMock).toHaveBeenCalledOnce(); // Continuation: cliSessionId should be passed through for session resume. diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts new file mode 100644 index 000000000..2756751fa --- /dev/null +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -0,0 +1,289 @@ +import { vi } from "vitest"; + +type CronSessionEntry = { + sessionId: string; + updatedAt: number; + systemSent: boolean; + skillsSnapshot: unknown; + [key: string]: unknown; +}; + +type CronSession = { + storePath: string; + store: Record; + sessionEntry: CronSessionEntry; + systemSent: boolean; + isNewSession: boolean; + [key: string]: unknown; +}; + +export const buildWorkspaceSkillSnapshotMock = vi.fn(); +export const resolveAgentConfigMock = vi.fn(); +export const resolveAgentModelFallbacksOverrideMock = vi.fn(); +export const resolveAgentSkillsFilterMock = vi.fn(); +export const getModelRefStatusMock = vi.fn(); +export const isCliProviderMock = vi.fn(); +export const resolveAllowedModelRefMock = vi.fn(); +export const resolveConfiguredModelRefMock = vi.fn(); +export const resolveHooksGmailModelMock = vi.fn(); +export const resolveThinkingDefaultMock = vi.fn(); +export const runWithModelFallbackMock = vi.fn(); +export const runEmbeddedPiAgentMock = vi.fn(); +export const runCliAgentMock = vi.fn(); +export const getCliSessionIdMock = vi.fn(); +export const updateSessionStoreMock = vi.fn(); +export const resolveCronSessionMock = vi.fn(); +export const logWarnMock = vi.fn(); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentConfig: resolveAgentConfigMock, + resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), + resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock, + resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), + resolveDefaultAgentId: vi.fn().mockReturnValue("default"), + resolveAgentSkillsFilter: resolveAgentSkillsFilterMock, +})); + +vi.mock("../../agents/skills.js", () => ({ + buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, +})); + +vi.mock("../../agents/skills/refresh.js", () => ({ + getSkillsSnapshotVersion: vi.fn().mockReturnValue(42), +})); + +vi.mock("../../agents/workspace.js", () => ({ + ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), +})); + +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }), +})); + +vi.mock("../../agents/model-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getModelRefStatus: getModelRefStatusMock, + isCliProvider: isCliProviderMock, + resolveAllowedModelRef: resolveAllowedModelRefMock, + resolveConfiguredModelRef: resolveConfiguredModelRefMock, + resolveHooksGmailModel: resolveHooksGmailModelMock, + resolveThinkingDefault: resolveThinkingDefaultMock, + }; +}); + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: runWithModelFallbackMock, +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: runEmbeddedPiAgentMock, +})); + +vi.mock("../../agents/context.js", () => ({ + lookupContextTokens: vi.fn().mockReturnValue(128000), +})); + +vi.mock("../../agents/date-time.js", () => ({ + formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"), + resolveUserTimeFormat: vi.fn().mockReturnValue("24h"), + resolveUserTimezone: vi.fn().mockReturnValue("UTC"), +})); + +vi.mock("../../agents/timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000), +})); + +vi.mock("../../agents/usage.js", () => ({ + deriveSessionTotalTokens: vi.fn().mockReturnValue(30), + hasNonzeroUsage: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../agents/cli-runner.js", () => ({ + runCliAgent: runCliAgentMock, +})); + +vi.mock("../../agents/cli-session.js", () => ({ + getCliSessionId: getCliSessionIdMock, + setCliSessionId: vi.fn(), +})); + +vi.mock("../../auto-reply/thinking.js", () => ({ + normalizeThinkLevel: vi.fn().mockReturnValue(undefined), + normalizeVerboseLevel: vi.fn().mockReturnValue("off"), + supportsXHighThinking: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../cli/outbound-send-deps.js", () => ({ + createOutboundSendDeps: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../config/sessions.js", () => ({ + resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"), + resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"), + setSessionRuntimeModel: vi.fn(), + updateSessionStore: updateSessionStoreMock, +})); + +vi.mock("../../routing/session-key.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"), + normalizeAgentId: vi.fn((id: string) => id), + }; +}); + +vi.mock("../../infra/agent-events.js", () => ({ + registerAgentRunContext: vi.fn(), +})); + +vi.mock("../../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../logger.js", () => ({ + logWarn: (...args: unknown[]) => logWarnMock(...args), +})); + +vi.mock("../../security/external-content.js", () => ({ + buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"), + detectSuspiciousPatterns: vi.fn().mockReturnValue([]), + getHookType: vi.fn().mockReturnValue("unknown"), + isExternalHookSession: vi.fn().mockReturnValue(false), +})); + +vi.mock("../delivery.js", () => ({ + resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }), +})); + +vi.mock("./delivery-target.js", () => ({ + resolveDeliveryTarget: vi.fn().mockResolvedValue({ + channel: "discord", + to: undefined, + accountId: undefined, + error: undefined, + }), +})); + +vi.mock("./helpers.js", () => ({ + isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false), + pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined), + pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"), + pickSummaryFromOutput: vi.fn().mockReturnValue("summary"), + pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"), + resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100), +})); + +vi.mock("./session.js", () => ({ + resolveCronSession: resolveCronSessionMock, +})); + +vi.mock("../../agents/defaults.js", () => ({ + DEFAULT_CONTEXT_TOKENS: 128000, + DEFAULT_MODEL: "gpt-4", + DEFAULT_PROVIDER: "openai", +})); + +export function makeCronSessionEntry(overrides?: Record): CronSessionEntry { + return { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: undefined, + ...overrides, + }; +} + +export function makeCronSession(overrides?: Record): CronSession { + return { + storePath: "/tmp/store.json", + store: {}, + sessionEntry: makeCronSessionEntry(), + systemSent: false, + isNewSession: true, + ...overrides, + } as CronSession; +} + +function makeDefaultModelFallbackResult() { + return { + result: { + payloads: [{ text: "test output" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }, + provider: "openai", + model: "gpt-4", + }; +} + +function makeDefaultEmbeddedResult() { + return { + payloads: [{ text: "test output" }], + meta: { agentMeta: { usage: { input: 10, output: 20 } } }, + }; +} + +export function resetRunCronIsolatedAgentTurnHarness(): void { + vi.clearAllMocks(); + + buildWorkspaceSkillSnapshotMock.mockReturnValue({ + prompt: "", + resolvedSkills: [], + version: 42, + }); + resolveAgentConfigMock.mockReturnValue(undefined); + resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined); + resolveAgentSkillsFilterMock.mockReturnValue(undefined); + + resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" }); + resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }); + resolveHooksGmailModelMock.mockReturnValue(null); + resolveThinkingDefaultMock.mockReturnValue(undefined); + getModelRefStatusMock.mockReturnValue({ allowed: false }); + isCliProviderMock.mockReturnValue(false); + + runWithModelFallbackMock.mockReset(); + runWithModelFallbackMock.mockResolvedValue(makeDefaultModelFallbackResult()); + runEmbeddedPiAgentMock.mockReset(); + runEmbeddedPiAgentMock.mockResolvedValue(makeDefaultEmbeddedResult()); + + runCliAgentMock.mockReset(); + getCliSessionIdMock.mockReturnValue(undefined); + + updateSessionStoreMock.mockReset(); + updateSessionStoreMock.mockResolvedValue(undefined); + + resolveCronSessionMock.mockReset(); + resolveCronSessionMock.mockReturnValue(makeCronSession()); + + logWarnMock.mockReset(); +} + +export function clearFastTestEnv(): string | undefined { + const previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; + delete process.env.OPENCLAW_TEST_FAST; + return previousFastTestEnv; +} + +export function restoreFastTestEnv(previousFastTestEnv: string | undefined): void { + if (previousFastTestEnv == null) { + delete process.env.OPENCLAW_TEST_FAST; + return; + } + process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; +} + +export async function loadRunCronIsolatedAgentTurn() { + const { runCronIsolatedAgentTurn } = await import("./run.js"); + return runCronIsolatedAgentTurn; +}