diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 87e1a87e9d06..8bed21e6d9c2 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -15,11 +15,8 @@ import { invokeNodeClaudeCliRun } from "../gateway/node-agent-cli-runtime.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { onInternalDiagnosticEvent, - onTrustedInternalDiagnosticEvent, onTrustedToolExecutionEvent, setDiagnosticsEnabledForProcess, - type DiagnosticEventPayload, - type DiagnosticEventPrivateData, waitForDiagnosticEventsDrained, } from "../infra/diagnostic-events.js"; import { @@ -29,7 +26,6 @@ import { } from "../logging/diagnostic-run-activity.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; import type { RunExit } from "../process/supervisor/types.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { registerExecApprovalRequestForHostOrThrow, resolveRegisteredExecApprovalDecision, @@ -38,6 +34,26 @@ import { makeBootstrapWarn as realMakeBootstrapWarn, resolveBootstrapContextForRun as realResolveBootstrapContextForRun, } from "./bootstrap-files.js"; +import { + buildClaudeControlRequestEvents, + buildClaudeLiveBackend, + buildClaudeLiveRunContext, + buildPreparedCliRunContext, + captureModelCallDiagnostics, + createCancelableLiveRunLifecycle, + expectPathMissing, + expectRejectsWithFields, + expectClaudeControlDecision, + expectModelCallTypes, + mockCallArg, + mockClaudeLiveRun, + requireArgAfter, + requireRecord, + requireRegexMatch, + withTempExecApprovalsFile, + withTempOpenClawHome, + type PreparedCliRunContextOverrides, +} from "./cli-runner.test-helpers.js"; import { createManagedRun, mockSuccessfulCliRun, @@ -92,6 +108,20 @@ vi.mock("../plugin-sdk/anthropic-cli.js", () => ({ type ProcessSupervisor = ReturnType; type SupervisorSpawnFn = ProcessSupervisor["spawn"]; +type ClaudeControlPolicyTestCase = { + name: string; + requestId: string; + toolUseId: string; + input: Record; + expected: { + behavior: "allow" | "deny"; + messageIncludes?: string; + updatedInput?: Record; + }; + context?: PreparedCliRunContextOverrides; + approvals?: Record; + expectedPermissionMode?: string; +}; beforeEach(() => { setDiagnosticsEnabledForProcess(true); @@ -120,290 +150,6 @@ afterEach(() => { const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`; -function mockSuccessfulClaudeJsonlRun() { - supervisorSpawnMock.mockResolvedValueOnce( - createManagedRun({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 50, - stdout: CLAUDE_OK_JSONL, - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }), - ); -} - -function createCancelableLiveRunLifecycle() { - let resolveExit!: (exit: RunExit) => void; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - return { - wait: vi.fn(() => exited), - cancel: vi.fn((_reason?: string) => { - resolveExit({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }), - }; -} - -function buildPreparedCliRunContext(params: { - provider: "claude-cli" | "codex-cli" | "google-gemini-cli"; - model: string; - runId: string; - prompt?: string; - sessionId?: string; - sessionKey?: string; - sessionEntry?: PreparedCliRunContext["params"]["sessionEntry"]; - agentId?: string; - backend?: Partial; - preparedEnv?: PreparedCliRunContext["preparedBackend"]["env"]; - resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"]; - config?: PreparedCliRunContext["params"]["config"]; - mcpConfigHash?: string; - mcpDeliveryCapture?: boolean; - skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"]; - thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"]; - executionMode?: PreparedCliRunContext["params"]["executionMode"]; - cliToolAvailability?: PreparedCliRunContext["params"]["cliToolAvailability"]; - emitCommentaryText?: boolean; - workspaceDir?: string; - timeoutMs?: number; - onSuccessfulAuthBinding?: PreparedCliRunContext["params"]["onSuccessfulAuthBinding"]; - runtimeArtifact?: PreparedCliRunContext["backendResolved"]["runtimeArtifact"]; -}): PreparedCliRunContext { - // Produces a prepared context without invoking prepare.runtime, keeping spawn - // assertions focused on execute/runtime behavior. - const workspaceDir = params.workspaceDir ?? "/tmp"; - const baseBackend = (() => { - if (params.provider === "claude-cli") { - return { - command: "claude", - args: ["-p", "--output-format", "stream-json"], - output: "jsonl" as const, - input: "stdin" as const, - modelArg: "--model", - sessionArgs: ["--session-id", "{sessionId}"], - sessionMode: "always" as const, - systemPromptFileArg: "--append-system-prompt-file", - systemPromptWhen: "first" as const, - serialize: true, - }; - } - if (params.provider === "google-gemini-cli") { - return { - command: "gemini", - args: [ - "--skip-trust", - "--approval-mode", - "auto_edit", - "--output-format", - "stream-json", - "--prompt", - "{prompt}", - ], - output: "jsonl" as const, - jsonlDialect: "gemini-stream-json" as const, - input: "arg" as const, - modelArg: "--model", - sessionMode: "existing" as const, - serialize: true, - }; - } - return { - command: "codex", - args: ["exec", "--json"], - resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check"], - output: "text" as const, - input: "arg" as const, - modelArg: "--model", - sessionMode: "existing" as const, - systemPromptFileConfigArg: "-c", - systemPromptFileConfigKey: "model_instructions_file", - systemPromptWhen: "first" as const, - serialize: true, - }; - })(); - const backend = { ...baseBackend, ...params.backend }; - return { - params: { - sessionId: params.sessionId ?? "s1", - sessionKey: params.sessionKey, - sessionEntry: params.sessionEntry, - agentId: params.agentId, - sessionFile: "/tmp/session.jsonl", - workspaceDir, - config: params.config, - prompt: params.prompt ?? "hi", - provider: params.provider, - model: params.model, - thinkLevel: params.thinkLevel, - executionMode: params.executionMode, - cliToolAvailability: params.cliToolAvailability, - emitCommentaryText: params.emitCommentaryText, - onSuccessfulAuthBinding: params.onSuccessfulAuthBinding, - timeoutMs: params.timeoutMs ?? 1_000, - runId: params.runId, - skillsSnapshot: params.skillsSnapshot, - }, - started: Date.now(), - workspaceDir, - backendResolved: { - id: params.provider, - config: backend, - bundleMcp: params.provider === "claude-cli", - pluginId: - params.provider === "claude-cli" - ? "anthropic" - : params.provider === "google-gemini-cli" - ? "google" - : "openai", - resolveExecutionArgs: params.resolveExecutionArgs, - runtimeArtifact: params.runtimeArtifact, - }, - preparedBackend: { - backend, - env: params.preparedEnv ?? {}, - ...(params.mcpConfigHash ? { mcpConfigHash: params.mcpConfigHash } : {}), - }, - reusableCliSession: { mode: "none" }, - hadSessionFile: false, - contextEngineConfig: {}, - modelId: params.model, - normalizedModel: params.model, - systemPrompt: "You are a helpful assistant.", - systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], - bootstrapPromptWarningLines: [], - authEpochVersion: 2, - ...(params.mcpDeliveryCapture ? { mcpDeliveryCapture: true } : {}), - }; -} - -function requireArgAfter(argv: string[] | undefined, flag: string): string { - const index = argv?.indexOf(flag) ?? -1; - if (index < 0) { - throw new Error(`expected CLI arg ${flag}`); - } - const value = argv?.[index + 1]?.trim(); - if (!value) { - throw new Error(`expected value after CLI arg ${flag}`); - } - return value; -} - -function requireRegexMatch(value: string, pattern: RegExp): RegExpExecArray { - const match = pattern.exec(value); - if (!match) { - throw new Error(`expected ${value} to match ${pattern}`); - } - return match; -} - -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object") { - throw new Error(`expected ${label} to be an object`); - } - return value as Record; -} - -function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0): unknown { - const call = mock.mock.calls[callIndex] as unknown[] | undefined; - if (!call) { - throw new Error(`expected mock call ${callIndex}`); - } - return call[argIndex]; -} - -async function expectRejectsWithFields( - promise: Promise, - expected: Record, -): Promise> { - // Failover errors carry structured fields; this helper verifies them while - // preserving the original object for deeper assertions. - try { - await promise; - } catch (error) { - const actual = requireRecord(error, "rejection"); - for (const [key, value] of Object.entries(expected)) { - expect(actual[key]).toBe(value); - } - return actual; - } - throw new Error("expected promise to reject"); -} - -async function expectPathMissing(targetPath: string): Promise { - try { - await fs.access(targetPath); - } catch (error) { - expect(requireRecord(error, "filesystem error").code).toBe("ENOENT"); - return; - } - throw new Error(`expected ${targetPath} to be missing`); -} - -async function withTempExecApprovalsFile( - file: Record, - run: () => Promise, -): Promise { - const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-exec-approvals-")); - await fs.mkdir(path.join(home, ".openclaw"), { recursive: true }); - await fs.writeFile( - path.join(home, ".openclaw", "exec-approvals.json"), - `${JSON.stringify(file)}\n`, - "utf-8", - ); - try { - await withEnvAsync({ HOME: home }, run); - } finally { - await fs.rm(home, { recursive: true, force: true }); - } -} - -async function withTempOpenClawHome(run: (home: string) => Promise): Promise { - const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-home-")); - try { - await withEnvAsync({ OPENCLAW_HOME: home }, async () => run(home)); - } finally { - await fs.rm(home, { recursive: true, force: true }); - } -} - -type ModelCallLifecycleEvent = Extract< - DiagnosticEventPayload, - { type: "model.call.started" | "model.call.completed" | "model.call.error" } ->; - -function captureModelCallDiagnostics(runId: string) { - const events: Array<{ - event: ModelCallLifecycleEvent; - privateData: DiagnosticEventPrivateData; - }> = []; - const stop = onTrustedInternalDiagnosticEvent((event, _metadata, privateData) => { - if ( - event.type !== "model.call.started" && - event.type !== "model.call.completed" && - event.type !== "model.call.error" - ) { - return; - } - if (event.runId === runId) { - events.push({ event, privateData }); - } - }); - return { events, stop }; -} - describe("runCliAgent spawn path", () => { it("formats output digests without logging response content", () => { expect(formatCliBackendOutputDigest("one")).toBe("outBytes=3 outHash=7692c3ad3540"); @@ -476,8 +222,7 @@ describe("runCliAgent spawn path", () => { writeCliSystemPromptFile: writeSystemPrompt, invokeNodeClaudeCliRun: invokeNode, }); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", + const context = buildClaudeLiveRunContext({ model: "claude-opus-4-8", runId: "run-node-claude", prompt: "current turn", @@ -516,7 +261,6 @@ describe("runCliAgent spawn path", () => { "{sessionId}", ], forkArg: "--fork-session", - liveSession: "claude-stdio", env: { ANTHROPIC_API_KEY: "configured-backend-key" }, clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], systemPromptWhen: "always", @@ -589,10 +333,8 @@ describe("runCliAgent spawn path", () => { }; }); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", + const context = buildClaudeLiveRunContext({ model: "claude-opus-4-8", - runId: "run-node-claude-truncated", prompt: "current turn", sessionEntry: { sessionId: "openclaw-session", @@ -604,7 +346,6 @@ describe("runCliAgent spawn path", () => { args: ["-p", "--output-format", "stream-json"], resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], forkArg: "--fork-session", - liveSession: "claude-stdio", env: { ANTHROPIC_API_KEY: "gateway-backend-key" }, systemPromptWhen: "always", }, @@ -635,7 +376,6 @@ describe("runCliAgent spawn path", () => { ); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-opus-4-8", runId: "run-node-abort", sessionEntry: { @@ -656,10 +396,7 @@ describe("runCliAgent spawn path", () => { await expect(run).rejects.toMatchObject({ name: "AbortError" }); await waitForDiagnosticEventsDrained(); expect(invokeNode.mock.calls[0]?.[0].signal?.aborted).toBe(true); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.error", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); expect(diagnostics.events[1]?.event).toMatchObject({ transport: "paired-node-cli", observationUnit: "turn", @@ -714,7 +451,6 @@ describe("runCliAgent spawn path", () => { resolveRegisteredExecApprovalDecision: resolveApproval, }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-opus-4-8", runId: "run-node-approval", sessionKey: plan.sessionKey, @@ -780,9 +516,7 @@ describe("runCliAgent spawn path", () => { ), }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-opus-4-8", - runId: "run-node-approval-timeout", timeoutMs: 25, sessionEntry: { sessionId: "openclaw-session", @@ -820,9 +554,7 @@ describe("runCliAgent spawn path", () => { resolveRegisteredExecApprovalDecision: resolveApproval, }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-opus-4-8", - runId: "run-node-approval-registration-timeout", timeoutMs: 25, sessionEntry: { sessionId: "openclaw-session", @@ -843,9 +575,7 @@ describe("runCliAgent spawn path", () => { const invokeNode = vi.fn(); setCliRunnerExecuteTestDeps({ invokeNodeClaudeCliRun: invokeNode }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-opus-4-8", - runId: "run-node-image", sessionEntry: { sessionId: "openclaw-session", updatedAt: 1, @@ -970,9 +700,6 @@ describe("runCliAgent spawn path", () => { await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-stdin-claude", prompt: "Explain this diff", }), ); @@ -1034,7 +761,6 @@ describe("runCliAgent spawn path", () => { try { const output = await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", runId: "run-claude-model-call-metadata", prompt, @@ -1056,10 +782,7 @@ describe("runCliAgent spawn path", () => { cacheWrite: 12, total: 357, }); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.completed", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.completed"]); const started = diagnostics.events[0]; const completed = diagnostics.events[1]; expect(started?.event).toMatchObject({ @@ -1140,7 +863,6 @@ describe("runCliAgent spawn path", () => { try { await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", runId: "run-claude-model-call-content", prompt, @@ -1191,7 +913,6 @@ describe("runCliAgent spawn path", () => { await expect( executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", runId: "run-claude-model-call-spawn-error", prompt: "fail now", @@ -1200,10 +921,7 @@ describe("runCliAgent spawn path", () => { ).rejects.toThrow("claude process spawn failed"); await waitForDiagnosticEventsDrained(); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.error", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); expect(diagnostics.events[1]?.event).toMatchObject({ errorCategory: "Error", requestPayloadBytes: Buffer.byteLength("fail now"), @@ -1255,7 +973,6 @@ describe("runCliAgent spawn path", () => { await expect( executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", runId: testCase.runId, }), @@ -1263,10 +980,7 @@ describe("runCliAgent spawn path", () => { ).rejects.toThrow(); await waitForDiagnosticEventsDrained(); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.error", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); expect(diagnostics.events[1]?.event).toMatchObject({ errorCategory: testCase.errorCategory, }); @@ -1304,13 +1018,7 @@ describe("runCliAgent spawn path", () => { }); }); - await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-system-prompt-file", - }), - ); + await executePreparedCliRun(buildPreparedCliRunContext({})); await expectPathMissing(systemPromptPath); }); @@ -1342,7 +1050,6 @@ describe("runCliAgent spawn path", () => { const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-soft-resume-system-prompt-file", }); context.reusableCliSession = { mode: "reuse-with-drift", @@ -1359,15 +1066,9 @@ describe("runCliAgent spawn path", () => { }); it("passes --session-id for new Claude sessions", async () => { - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); - await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-session-id", - }), - ); + await executePreparedCliRun(buildPreparedCliRunContext({})); const input = mockCallArg(supervisorSpawnMock) as { argv?: string[]; @@ -1382,13 +1083,11 @@ describe("runCliAgent spawn path", () => { }); it("does not pass a Claude session id for side-question runs", async () => { - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--max-turns", "1"]); await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", runId: "run-claude-side-question", executionMode: "side-question", backend: { sessionMode: "none" }, @@ -1406,14 +1105,11 @@ describe("runCliAgent spawn path", () => { }); it("applies backend-owned per-run args before spawning", async () => { - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--effort", "high"]); await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-thinking-args", thinkLevel: "high", resolveExecutionArgs, }), @@ -1430,7 +1126,7 @@ describe("runCliAgent spawn path", () => { }); it("preserves exact tool availability through execution-time argument resolution", async () => { - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); const toolAvailability: NonNullable = { native: [], mcp: ["mcp__openclaw__openclaw"], @@ -1439,8 +1135,6 @@ describe("runCliAgent spawn path", () => { await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", runId: "run-claude-tool-policy", cliToolAvailability: toolAvailability, resolveExecutionArgs, @@ -1458,9 +1152,6 @@ describe("runCliAgent spawn path", () => { await expect( executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-tool-policy-refused", cliToolAvailability: { native: [], mcp: ["mcp__openclaw__openclaw"], @@ -1473,14 +1164,11 @@ describe("runCliAgent spawn path", () => { }); it("maps Ultra to the strongest generic CLI backend level", async () => { - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs); await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-ultra-args", thinkLevel: "ultra", resolveExecutionArgs, }), @@ -1497,7 +1185,6 @@ describe("runCliAgent spawn path", () => { buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.5", - runId: "run-prepared-env", backend: { env: { GEMINI_CLI_HOME: "/ignored/static-home", @@ -1525,11 +1212,8 @@ describe("runCliAgent spawn path", () => { try { await fs.copyFile(process.execPath, executable); await fs.chmod(executable, 0o755); - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-strict-runtime-artifact", backend: { command: executable }, onSuccessfulAuthBinding: () => {}, runtimeArtifact: { @@ -1596,9 +1280,6 @@ describe("runCliAgent spawn path", () => { try { await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-skills-plugin", workspaceDir, skillsSnapshot: { prompt: "", @@ -1656,9 +1337,6 @@ describe("runCliAgent spawn path", () => { try { await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-skill-env", config: { skills: { entries: { @@ -1700,7 +1378,6 @@ describe("runCliAgent spawn path", () => { const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-1", }); context.reusableCliSession = { mode: "reuse", sessionId: "thread-123" }; @@ -1762,7 +1439,6 @@ describe("runCliAgent spawn path", () => { buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-process-diagnostics", }), ); @@ -1814,7 +1490,6 @@ describe("runCliAgent spawn path", () => { buildPreparedCliRunContext({ provider: "google-gemini-cli", model: "gemini-3.1-pro-preview", - runId: "run-gemini-stream-json-error", }), ), { @@ -1851,7 +1526,6 @@ describe("runCliAgent spawn path", () => { buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-codex-system-prompt-file", }), ); @@ -1894,7 +1568,6 @@ describe("runCliAgent spawn path", () => { }); }); supervisorSpawnMock.mockResolvedValueOnce({ - runId: "run-supervisor", pid: 1234, startedAtMs: Date.now(), stdin: undefined, @@ -1910,7 +1583,6 @@ describe("runCliAgent spawn path", () => { const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-abort", }); context.params.abortSignal = abortController.signal; @@ -1971,13 +1643,7 @@ describe("runCliAgent spawn path", () => { }); try { - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-stream-json", - }), - ); + const result = await executePreparedCliRun(buildPreparedCliRunContext({})); expect(result.text).toBe("Hello world"); expect(agentEvents).toEqual([ @@ -2029,9 +1695,6 @@ describe("runCliAgent spawn path", () => { try { const result = await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-side-question-stream-json", executionMode: "side-question", backend: { sessionMode: "none" }, }), @@ -2122,12 +1785,10 @@ describe("runCliAgent spawn path", () => { try { const run = executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", + buildClaudeLiveRunContext({ model: "claude-sonnet-4-6", runId: "run-live-model-call-background", prompt: "research this", - backend: { liveSession: "claude-stdio" }, config: { diagnostics: { enabled: true, @@ -2157,10 +1818,7 @@ describe("runCliAgent spawn path", () => { cacheWrite: undefined, total: undefined, }); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.completed", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.completed"]); const completed = diagnostics.events[1]; expect(completed?.event).toMatchObject({ api: "claude-code", @@ -2186,54 +1844,34 @@ describe("runCliAgent spawn path", () => { }); it("emits one terminal model-call error for a managed Claude result failure", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - `${JSON.stringify({ - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-error", - result: "managed turn failed", - usage: { input_tokens: 8, output_tokens: 2, cache_read_input_tokens: 40 }, - })}\n`, - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-model-call-error", - pid: 2346, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + runId: "live-model-call-error", + pid: 2346, + events: [ + { + type: "result", + subtype: "error_during_execution", + is_error: true, + session_id: "live-error", + result: "managed turn failed", + usage: { input_tokens: 8, output_tokens: 2, cache_read_input_tokens: 40 }, + }, + ], }); const diagnostics = captureModelCallDiagnostics("run-live-model-call-error"); try { await expect( executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", + buildClaudeLiveRunContext({ model: "claude-sonnet-4-6", runId: "run-live-model-call-error", - backend: { liveSession: "claude-stdio" }, }), ), ).rejects.toThrow(/managed turn failed/i); await waitForDiagnosticEventsDrained(); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.error", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); expect(diagnostics.events[1]?.event).toMatchObject({ transport: "stdio-live", usage: { input: 8, output: 2, cacheRead: 40 }, @@ -2251,52 +1889,26 @@ describe("runCliAgent spawn path", () => { agentEvents.push(evt.data); } }); - const writes: string[] = []; - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { const prompt = (JSON.parse(data) as { message: { content: string } }).message.content; const text = prompt === "first" ? "one" : "two"; - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-1" }), - JSON.stringify({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text }, - }, - }), - JSON.stringify({ - type: "result", - session_id: "live-session-1", - result: text, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + emit([ + { type: "system", subtype: "init", session_id: "live-session-1" }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text }, + }, + }, + { type: "result", session_id: "live-session-1", result: text }, + ]); + }, }); try { - const firstContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-1", + const firstContext = buildClaudeLiveRunContext({ prompt: "first", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-one.json"], @@ -2308,7 +1920,6 @@ describe("runCliAgent spawn path", () => { "--mcp-config", "/tmp/mcp-one.json", ], - liveSession: "claude-stdio", }, mcpConfigHash: "same-mcp-config", }); @@ -2318,10 +1929,7 @@ describe("runCliAgent spawn path", () => { sessionId: "s1", }); expect(liveGeneration).toBeDefined(); - const secondContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-2", + const secondContext = buildClaudeLiveRunContext({ prompt: "second", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-two.json"], @@ -2333,22 +1941,18 @@ describe("runCliAgent spawn path", () => { "--mcp-config", "/tmp/mcp-two.json", ], - liveSession: "claude-stdio", }, mcpConfigHash: "same-mcp-config", }); secondContext.requiredClaudeLiveSessionGeneration = liveGeneration; const second = await executePreparedCliRun(secondContext, "live-session-1"); - const changedContext = buildPreparedCliRunContext({ - provider: "claude-cli", + const changedContext = buildClaudeLiveRunContext({ model: "opus", - runId: "run-live-changed", prompt: "changed", backend: { args: ["-p"], resumeArgs: ["-p", "--resume", "{sessionId}"], - liveSession: "claude-stdio", }, mcpConfigHash: "same-mcp-config", }); @@ -2373,7 +1977,7 @@ describe("runCliAgent spawn path", () => { expect(spawnInput.argv).not.toContain("--session-id"); expect(spawnInput.argv).toContain("/tmp/mcp-one.json"); expect( - writes.map( + live.writes.map( (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, ), ).toEqual(["first", "second"]); @@ -2396,38 +2000,17 @@ describe("runCliAgent spawn path", () => { }); it("requires the exact warm Claude process even without native resume args", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-1" }), - JSON.stringify({ type: "result", session_id: "live-session-1", result: "one" }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-no-resume", + const liveRuns = Array.from({ length: 3 }, () => + mockClaudeLiveRun(supervisorSpawnMock, { pid: 2346, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); + events: [ + { type: "system", subtype: "init", session_id: "live-session-1" }, + { type: "result", session_id: "live-session-1", result: "one" }, + ], + }), + ); const firstContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-no-resume-1", prompt: "first", backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, }); @@ -2440,9 +2023,6 @@ describe("runCliAgent spawn path", () => { resetClaudeLiveSessionsForTest(); const missingContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-no-resume-2", prompt: "second", backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, }); @@ -2454,9 +2034,6 @@ describe("runCliAgent spawn path", () => { }); const replacementContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-no-resume-replacement", prompt: "replacement", backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, }); @@ -2469,7 +2046,8 @@ describe("runCliAgent spawn path", () => { expect((await executePreparedCliRun(missingContext)).text).toBe("one"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); expect( - (JSON.parse(writes.at(-1) ?? "") as { message: { content: string } }).message.content, + (JSON.parse(liveRuns[2]?.writes.at(-1) ?? "") as { message: { content: string } }).message + .content, ).toBe("bounded OpenClaw history\n\nsecond"); }); @@ -2478,66 +2056,39 @@ describe("runCliAgent spawn path", () => { const stop = onAgentEvent((event) => { agentEvents.push({ stream: event.stream, data: event.data }); }); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((_data: string, callback?: (error?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-empty-result" }), - JSON.stringify({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "Let me check." }, - }, - }), - JSON.stringify({ - type: "stream_event", - event: { - type: "content_block_start", - index: 1, - content_block: { type: "tool_use", id: "tool-1", name: "Read", input: {} }, - }, - }), - JSON.stringify({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "Final answer." }, - }, - }), - JSON.stringify({ - type: "result", - session_id: "live-empty-result", - result: "", - }), - ].join("\n") + "\n", - ); - callback?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-empty-result-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-empty-result" }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "Let me check." }, + }, + }, + { + type: "stream_event", + event: { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "tool-1", name: "Read", input: {} }, + }, + }, + { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "Final answer." }, + }, + }, + { type: "result", session_id: "live-empty-result", result: "" }, + ], }); try { const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-empty-result", + buildClaudeLiveRunContext({ emitCommentaryText: true, - backend: { liveSession: "claude-stdio" }, }), ); @@ -2588,7 +2139,6 @@ describe("runCliAgent spawn path", () => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { - runId: "live-quiet-tool-run", pid: 2345, startedAtMs: Date.now(), stdin, @@ -2598,11 +2148,7 @@ describe("runCliAgent spawn path", () => { }); const run = executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-quiet-tool", - backend: { liveSession: "claude-stdio" }, + buildClaudeLiveRunContext({ timeoutMs: 3_600_000, }), ); @@ -2643,44 +2189,19 @@ describe("runCliAgent spawn path", () => { }); it("keeps non-capture live prepared backend cleanup with the whole-run owner", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-cleanup" }), - JSON.stringify({ - type: "result", - session_id: "live-session-cleanup", - result: "ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-cleanup-run", - pid: 2346, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + runId: "live-cleanup-run", + pid: 2346, + events: [ + { type: "system", subtype: "init", session_id: "live-session-cleanup" }, + { type: "result", session_id: "live-session-cleanup", result: "ok" }, + ], }); const preparedBackendCleanup = vi.fn(async () => {}); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-cleanup", + const context = buildClaudeLiveRunContext({ prompt: "first", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-cleanup.json"], - liveSession: "claude-stdio", }, mcpConfigHash: "cleanup-mcp-config", }); @@ -2721,62 +2242,19 @@ describe("runCliAgent spawn path", () => { "utf-8", ); try { - let stdoutListener: ((chunk: string) => void) | undefined; - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "captured-live-cleanup-run", - pid: 2347, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "captured-live-cleanup", - }), - JSON.stringify({ - type: "result", - session_id: "captured-live-cleanup", - result: "ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => exited), - cancel: vi.fn(() => - resolveExit?.({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }), - ), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + cancelable: true, + pid: 2347, + events: [ + { type: "system", subtype: "init", session_id: "captured-live-cleanup" }, + { type: "result", session_id: "captured-live-cleanup", result: "ok" }, + ], }); const preparedBackendCleanup = vi.fn(async () => {}); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-captured-live-cleanup", + const context = buildClaudeLiveRunContext({ prompt: "first", backend: { args: ["-p", "--strict-mcp-config", "--mcp-config", mcpConfigPath], - liveSession: "claude-stdio", }, mcpConfigHash: "captured-cleanup-mcp-config", mcpDeliveryCapture: true, @@ -2840,7 +2318,6 @@ describe("runCliAgent spawn path", () => { const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-cleanup-delivery-evidence", mcpDeliveryCapture: true, }); @@ -2866,13 +2343,12 @@ describe("runCliAgent spawn path", () => { }, }), }); - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); try { await expect( executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", runId, }), @@ -2880,10 +2356,7 @@ describe("runCliAgent spawn path", () => { ).rejects.toThrow("system prompt cleanup failed"); await waitForDiagnosticEventsDrained(); - expect(diagnostics.events.map(({ event }) => event.type)).toEqual([ - "model.call.started", - "model.call.error", - ]); + expectModelCallTypes(diagnostics, ["model.call.started", "model.call.error"]); expect(diagnostics.events[1]?.event.callId).toBe(diagnostics.events[0]?.event.callId); } finally { diagnostics.stop(); @@ -2904,72 +2377,25 @@ describe("runCliAgent spawn path", () => { it("accepts Claude live stream-json lines larger than 256 KiB", async () => { const largeText = "x".repeat(270 * 1024); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - JSON.stringify({ - type: "result", - session_id: "live-session-large", - result: largeText, - }) + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-large", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + events: [{ type: "result", session_id: "live-session-large", result: largeText }], }); - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-large-line", - backend: { - liveSession: "claude-stdio", - }, - }), - ); + const result = await executePreparedCliRun(buildClaudeLiveRunContext()); expect(result.text).toHaveLength(largeText.length); expect(result.text).toBe(largeText); }); it("reports Claude live session reply backends as streaming until the turn finishes", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; let markWriteReady: (() => void) | undefined; const writeReady = new Promise((resolve) => { markWriteReady = resolve; }); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: () => { markWriteReady?.(); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + }, }); const operation = createReplyOperation({ sessionKey: "agent:main:main", @@ -2977,16 +2403,10 @@ describe("runCliAgent spawn path", () => { resetTriggered: false, }); operation.setPhase("running"); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-reply-streaming", + const context = buildClaudeLiveRunContext({ sessionId: "live-session-reply", sessionKey: "agent:main:main", prompt: "hello", - backend: { - liveSession: "claude-stdio", - }, }); const run = executePreparedCliRun({ @@ -3000,16 +2420,10 @@ describe("runCliAgent spawn path", () => { await writeReady; expect(replyRunRegistry.isStreaming("agent:main:main")).toBe(true); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-reply" }), - JSON.stringify({ - type: "result", - session_id: "live-session-reply", - result: "done", - }), - ].join("\n") + "\n", - ); + live.emit([ + { type: "system", subtype: "init", session_id: "live-session-reply" }, + { type: "result", session_id: "live-session-reply", result: "done" }, + ]); const result = await run; expect(result.text).toBe("done"); @@ -3018,36 +2432,15 @@ describe("runCliAgent spawn path", () => { }); it("reuses a Claude live session when resumed turns omit the system prompt arg", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; let turn = 0; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ emit }) => { turn += 1; - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-system" }), - JSON.stringify({ - type: "result", - session_id: "live-system", - result: turn === 1 ? "one" : "two", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + emit([ + { type: "system", subtype: "init", session_id: "live-system" }, + { type: "result", session_id: "live-system", result: turn === 1 ? "one" : "two" }, + ]); + }, }); const backend = { @@ -3056,18 +2449,12 @@ describe("runCliAgent spawn path", () => { }; const first = await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-system-1", prompt: "first", backend, }), ); const second = await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-system-2", prompt: "second", backend, }), @@ -3080,41 +2467,24 @@ describe("runCliAgent spawn path", () => { }); it("serializes concurrent Claude live session creation for the same key", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; let releaseSpawn: (() => void) | undefined; let turn = 0; const spawnReady = new Promise((resolve) => { releaseSpawn = resolve; }); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + beforeSpawn: () => spawnReady, + onWrite: ({ emit }) => { turn += 1; - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-concurrent" }), - JSON.stringify({ - type: "result", - session_id: "live-concurrent", - result: turn === 1 ? "one" : "two", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - await spawnReady; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + emit([ + { type: "system", subtype: "init", session_id: "live-concurrent" }, + { + type: "result", + session_id: "live-concurrent", + result: turn === 1 ? "one" : "two", + }, + ]); + }, }); const backend = { @@ -3122,18 +2492,12 @@ describe("runCliAgent spawn path", () => { }; const first = executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-concurrent-1", prompt: "first", backend, }), ); const second = executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-concurrent-2", prompt: "second", backend, }), @@ -3143,7 +2507,7 @@ describe("runCliAgent spawn path", () => { const results = await Promise.all([first, second]); expect(results.map((result) => result.text).toSorted()).toEqual(["one", "two"]); - expect(stdin.write).toHaveBeenCalledTimes(2); + expect(live.stdin.write).toHaveBeenCalledTimes(2); expect(supervisorSpawnMock).toHaveBeenCalledOnce(); }); @@ -3171,7 +2535,6 @@ describe("runCliAgent spawn path", () => { const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { - runId: "live-race-run", pid: 2350, startedAtMs: Date.now(), stdin, @@ -3180,9 +2543,6 @@ describe("runCliAgent spawn path", () => { }; }); const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-race", prompt: "first", backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, }); @@ -3304,8 +2664,6 @@ describe("runCliAgent spawn path", () => { const runs = Array.from({ length: 17 }, (_, index) => (() => { const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", runId: `run-live-cap-${index}`, prompt: `prompt ${index}`, sessionId: `session-${index}`, @@ -3343,15 +2701,7 @@ describe("runCliAgent spawn path", () => { }); it("preserves Claude resume args when building live session argv", () => { - const backend: PreparedCliRunContext["preparedBackend"]["backend"] = { - command: "claude", - args: ["-p", "--output-format", "stream-json"], - output: "jsonl", - input: "stdin", - sessionArgs: ["--session-id", "{sessionId}"], - systemPromptArg: "--append-system-prompt", - systemPromptFileArg: "--append-system-prompt-file", - }; + const backend = buildClaudeLiveBackend(); const args = buildClaudeLiveArgs({ args: [ @@ -3384,15 +2734,7 @@ describe("runCliAgent spawn path", () => { }); it("adds Claude stream-json output format when building live session argv", () => { - const backend: PreparedCliRunContext["preparedBackend"]["backend"] = { - command: "claude", - args: ["-p"], - output: "jsonl", - input: "stdin", - sessionArgs: ["--session-id", "{sessionId}"], - systemPromptArg: "--append-system-prompt", - systemPromptFileArg: "--append-system-prompt-file", - }; + const backend = buildClaudeLiveBackend({ args: ["-p"] }); const args = buildClaudeLiveArgs({ args: ["-p"], @@ -3407,82 +2749,29 @@ describe("runCliAgent spawn path", () => { }); it("answers Claude live control_request can_use_tool with allow when exec policy is full/no-ask", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-allow", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-allow-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-allow", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-allow", - result: "ok", -})} -`, - ); - } - cb?.(); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-allow", + toolUseId: "tool-allow-1", + input: { command: "ls" }, + sessionId: "live-control-allow", }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-allow", - pid: 3001, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + pid: 3001, }); const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-allow", + buildClaudeLiveRunContext({ prompt: "hello", - backend: { liveSession: "claude-stdio" }, - config: { - tools: { exec: { security: "full", ask: "off" } }, - } as PreparedCliRunContext["params"]["config"], + config: { tools: { exec: { security: "full", ask: "off" } } }, }), ); expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - type: string; - response: { - subtype: string; - request_id: string; - response: { behavior: string; toolUseID?: string; updatedInput?: unknown }; - }; - }; - expect(parsed.type).toBe("control_response"); - expect(parsed.response.subtype).toBe("success"); - expect(parsed.response.request_id).toBe("req-allow"); - expect(parsed.response.response.behavior).toBe("allow"); - expect(parsed.response.response.toolUseID).toBe("tool-allow-1"); - expect(parsed.response.response.updatedInput).toEqual({ command: "ls" }); + expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-allow", + toolUseId: "tool-allow-1", + updatedInput: { command: "ls" }, + }); }); it("reports Claude live stream progress without timer heartbeats", async () => { @@ -3537,7 +2826,6 @@ ${JSON.stringify({ const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { - runId: "live-run-diagnostics", pid: 3060, startedAtMs: Date.now(), stdin, @@ -3547,14 +2835,10 @@ ${JSON.stringify({ }); try { - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-diagnostics", + const context = buildClaudeLiveRunContext({ sessionId: "session-live-diagnostics", sessionKey: "agent:main:diagnostics", prompt: "hello", - backend: { liveSession: "claude-stdio" }, timeoutMs: 120_000, }); const resultPromise = runClaudeLiveSessionTurn({ @@ -3737,21 +3021,16 @@ ${JSON.stringify({ stdoutListener = input.onStdout; captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; return { - runId: "live-run-blocked", pid: 3061, startedAtMs: Date.now(), stdin, ...liveRunLifecycle, }; }); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-blocked", + const context = buildClaudeLiveRunContext({ sessionId: "session-live-blocked", sessionKey: "agent:main:blocked", prompt: "hello", - backend: { liveSession: "claude-stdio" }, }); context.mcpDeliveryCapture = true; @@ -3860,21 +3139,16 @@ ${JSON.stringify({ stdoutListener = input.onStdout; captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; return { - runId: "live-run-identical", pid: 3062, startedAtMs: Date.now(), stdin, ...liveRunLifecycle, }; }); - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-identical", + const context = buildClaudeLiveRunContext({ sessionId: "session-live-identical", sessionKey: "agent:main:live-identical", prompt: "hello", - backend: { liveSession: "claude-stdio" }, }); context.mcpDeliveryCapture = true; @@ -3976,7 +3250,6 @@ ${JSON.stringify({ const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; stdoutListener = input.onStdout; return { - runId: "live-run-timeout", pid: 3061, startedAtMs: Date.now(), stdin, @@ -3986,13 +3259,9 @@ ${JSON.stringify({ }); try { - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-timeout", + const context = buildClaudeLiveRunContext({ sessionId: "session-live-timeout", sessionKey: "agent:main:timeout", - backend: { liveSession: "claude-stdio" }, }); context.params.abortSignal = abortController.signal; const resultPromise = runClaudeLiveSessionTurn({ @@ -4046,117 +3315,67 @@ ${JSON.stringify({ diagnosticEvents.push(event as unknown as Record); } }); - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-deny", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-deny-1", + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + ...buildClaudeControlRequestEvents({ + requestId: "req-deny", + toolUseId: "tool-deny-1", + input: { command: "rm -rf /" }, + sessionId: "live-control-deny", + }).slice(0, 2), + { + type: "assistant", + session_id: "live-control-deny", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-deny-1", + name: "Bash", input: { command: "rm -rf /" }, }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-deny", -})} -${JSON.stringify({ - type: "assistant", - session_id: "live-control-deny", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-deny-1", - name: "Bash", - input: { command: "rm -rf /" }, - }, - ], - }, -})} -${JSON.stringify({ - type: "user", - session_id: "live-control-deny", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-deny-1", - content: "denied", - is_error: true, - }, - ], - }, -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-deny", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-deny", - pid: 3002, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + ], + }, + }, + { + type: "user", + session_id: "live-control-deny", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-deny-1", + content: "denied", + is_error: true, + }, + ], + }, + }, + { type: "result", session_id: "live-control-deny", result: "ok" }, + ], + pid: 3002, }); - const result = await (async () => { - try { - const value = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-deny", - prompt: "hello", - backend: { liveSession: "claude-stdio" }, - config: { - tools: { exec: { security: "allowlist", ask: "on-miss" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - await waitForDiagnosticEventsDrained(); - return value; - } finally { - stopDiagnostics(); - } - })(); + let result; + try { + result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + await waitForDiagnosticEventsDrained(); + } finally { + stopDiagnostics(); + } expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - type: string; - response: { - subtype: string; - request_id: string; - response: { behavior: string; message: string; decisionClassification: string }; - }; - }; - expect(parsed.response.response.behavior).toBe("deny"); - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain("security=allowlist"); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId: "req-deny", + messageIncludes: "security=allowlist", + }); expect(diagnosticEvents).toMatchObject([ { type: "tool.execution.started", @@ -4173,53 +3392,23 @@ ${JSON.stringify({ ]); expect(diagnosticEvents).toHaveLength(2); expect(JSON.stringify(diagnosticEvents)).not.toContain("rm -rf"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); + expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); }); it("does not create exec approvals file while resolving Claude live policy", async () => { await withTempOpenClawHome(async (home) => { const approvalsPath = path.join(home, ".openclaw", "exec-approvals.json"); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((_data: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - `${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-no-approvals-file", - })} -${JSON.stringify({ - type: "result", - session_id: "live-no-approvals-file", - result: "ok", -})} -`, - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-no-approvals-file", - pid: 3009, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-no-approvals-file" }, + { type: "result", session_id: "live-no-approvals-file", result: "ok" }, + ], + pid: 3009, }); const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-no-approvals-file", + buildClaudeLiveRunContext({ prompt: "hello", - backend: { liveSession: "claude-stdio" }, config: { tools: { exec: { security: "allowlist", ask: "on-miss" } }, } as PreparedCliRunContext["params"]["config"], @@ -4227,529 +3416,141 @@ ${JSON.stringify({ ); expect(result.text).toBe("ok"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); + expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); await expectPathMissing(approvalsPath); }); }); - it("answers Claude live control_request can_use_tool with allow when no exec policy is configured (default deployment)", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-default-allow", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-default-allow-1", - input: { command: "echo hi" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-default-allow", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-default-allow", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-default-allow", - pid: 3003, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - // No tools.exec configured at all — represents the default deployment - // that extensions/anthropic/cli-shared.ts already launches with - // --permission-mode bypassPermissions via normalizeClaudePermissionArgs. - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-default-allow", - prompt: "hello", - backend: { liveSession: "claude-stdio" }, - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - type: string; - response: { - subtype: string; - request_id: string; - response: { behavior: string; toolUseID?: string; updatedInput?: unknown }; - }; - }; - expect(parsed.response.response.behavior).toBe("allow"); - expect(parsed.response.response.toolUseID).toBe("tool-default-allow-1"); - expect(parsed.response.response.updatedInput).toEqual({ command: "echo hi" }); - }); - - it("answers Claude live control_request can_use_tool with deny when approval defaults are restrictive", async () => { - await withTempExecApprovalsFile( - { + it.each([ + { + name: "allows tools when no exec policy is configured (default deployment)", + requestId: "req-default-allow", + toolUseId: "tool-default-allow-1", + input: { command: "echo hi" }, + expected: { behavior: "allow", updatedInput: { command: "echo hi" } }, + }, + { + name: "denies tools when approval defaults are restrictive", + requestId: "req-approval-default-deny", + toolUseId: "tool-approval-default-deny-1", + input: { command: "ls" }, + expected: { behavior: "deny", messageIncludes: "security=allowlist" }, + approvals: { version: 1, defaults: { security: "allowlist", ask: "on-miss" }, agents: {}, }, - async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-approval-default-deny", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-approval-default-deny-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-approval-default-deny", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-approval-default-deny", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-approval-default-deny", - pid: 3005, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-approval-default-deny", - prompt: "hello", - backend: { - liveSession: "claude-stdio", - args: [ - "-p", - "--output-format", - "stream-json", - "--permission-mode", - "bypassPermissions", - ], - }, - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - response: { - response: { behavior: string; message: string; decisionClassification: string }; - }; - }; - expect(parsed.response.response.behavior).toBe("deny"); - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain("security=allowlist"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); + context: { + backend: { + liveSession: "claude-stdio", + args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], + }, }, - ); - }); - - it("answers Claude live control_request can_use_tool with deny when session exec ask is restrictive", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-session-ask-deny", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-session-ask-deny-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-session-ask-deny", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-session-ask-deny", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-session-ask-deny", - pid: 3006, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-session-ask-deny", - prompt: "hello", + expectedPermissionMode: "default", + }, + { + name: "denies tools when session exec ask is restrictive", + requestId: "req-session-ask-deny", + toolUseId: "tool-session-ask-deny-1", + input: { command: "ls" }, + expected: { behavior: "deny", messageIncludes: "ask=always" }, + context: { backend: { liveSession: "claude-stdio", args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], }, sessionEntry: { execAsk: "always" } as PreparedCliRunContext["params"]["sessionEntry"], - config: { - tools: { exec: { security: "full", ask: "off" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - response: { - response: { behavior: string; message: string; decisionClassification: string }; - }; - }; - expect(parsed.response.response.behavior).toBe("deny"); - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain("ask=always"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); - }); - - it("answers Claude live control_request can_use_tool with deny when agent approvals are restrictive", async () => { - await withTempExecApprovalsFile( - { - version: 1, - agents: { reviewer: { security: "deny" } }, + config: { tools: { exec: { security: "full", ask: "off" } } }, }, - async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-agent-approval-deny", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-agent-approval-deny-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-agent-approval-deny", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-agent-approval-deny", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-agent-approval-deny", - pid: 3007, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-agent-approval-deny", - prompt: "hello", - backend: { - liveSession: "claude-stdio", - args: [ - "-p", - "--output-format", - "stream-json", - "--permission-mode", - "bypassPermissions", - ], - }, - agentId: "reviewer", - config: { - tools: { exec: { security: "full", ask: "off" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - response: { - response: { behavior: string; message: string; decisionClassification: string }; - }; - }; - expect(parsed.response.response.behavior).toBe("deny"); - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain("security=deny"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); + expectedPermissionMode: "default", + }, + { + name: "denies tools when agent approvals are restrictive", + requestId: "req-agent-approval-deny", + toolUseId: "tool-agent-approval-deny-1", + input: { command: "ls" }, + expected: { behavior: "deny", messageIncludes: "security=deny" }, + approvals: { version: 1, agents: { reviewer: { security: "deny" } } }, + context: { + agentId: "reviewer", + backend: { + liveSession: "claude-stdio", + args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], + }, + config: { tools: { exec: { security: "full", ask: "off" } } }, }, - ); - }); - - it("answers Claude live control_request can_use_tool with deny when session-key agent approvals are restrictive", async () => { - await withTempExecApprovalsFile( - { - version: 1, - agents: { reviewer: { security: "deny" } }, + expectedPermissionMode: "default", + }, + { + name: "denies tools when session-key agent approvals are restrictive", + requestId: "req-session-key-approval-deny", + toolUseId: "tool-session-key-approval-deny-1", + input: { command: "ls" }, + expected: { behavior: "deny", messageIncludes: "security=deny" }, + approvals: { version: 1, agents: { reviewer: { security: "deny" } } }, + context: { + sessionKey: "agent:reviewer:main", + backend: { + liveSession: "claude-stdio", + args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], + }, + config: { tools: { exec: { security: "full", ask: "off" } } }, }, - async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-session-key-approval-deny", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-session-key-approval-deny-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-session-key-approval-deny", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-session-key-approval-deny", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-session-key-approval-deny", - pid: 3008, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-session-key-approval-deny", - prompt: "hello", - backend: { - liveSession: "claude-stdio", - args: [ - "-p", - "--output-format", - "stream-json", - "--permission-mode", - "bypassPermissions", - ], - }, - sessionKey: "agent:reviewer:main", - config: { - tools: { exec: { security: "full", ask: "off" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - response: { - response: { behavior: string; message: string; decisionClassification: string }; - }; - }; - expect(parsed.response.response.behavior).toBe("deny"); - expect(parsed.response.response.decisionClassification).toBe("user_reject"); - expect(parsed.response.response.message).toContain("security=deny"); - const spawnArg = supervisorSpawnMock.mock.calls.at(-1)?.[0] as { argv?: string[] }; - expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("default"); - }, - ); - }); - - it("answers Claude live control_request can_use_tool with allow when OpenClaw exec is YOLO despite raw --permission-mode default", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const writes: string[] = []; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - if (writes.length === 1) { - stdoutListener?.( - `${JSON.stringify({ - type: "control_request", - request_id: "req-permmode-allow", - request: { - subtype: "can_use_tool", - tool_name: "Bash", - tool_use_id: "tool-permmode-allow-1", - input: { command: "ls" }, - }, - })} -${JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-control-permmode-allow", -})} -${JSON.stringify({ - type: "result", - session_id: "live-control-permmode-allow", - result: "ok", -})} -`, - ); - } - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run-permmode-allow", - pid: 3004, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - // tools.exec resolves to full/off (would normally allow native Bash), and - // OpenClaw policy is authoritative over raw Claude permission-mode args. - const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-control-permmode-allow", - prompt: "hello", + expectedPermissionMode: "default", + }, + { + name: "allows tools when OpenClaw exec is YOLO despite raw --permission-mode default", + requestId: "req-permmode-allow", + toolUseId: "tool-permmode-allow-1", + input: { command: "ls" }, + expected: { behavior: "allow" }, + context: { backend: { liveSession: "claude-stdio", args: ["-p", "--output-format", "stream-json", "--permission-mode", "default"], }, - config: { - tools: { exec: { security: "full", ask: "off" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - expect(result.text).toBe("ok"); - const controlResponse = writes.find((entry) => entry.includes('"control_response"')); - expect(controlResponse, "control_response written to stdin").toBeDefined(); - const parsed = JSON.parse((controlResponse ?? "").trim()) as { - type: string; - response: { - subtype: string; - request_id: string; - response: { behavior: string; toolUseID?: string }; - }; + config: { tools: { exec: { security: "full", ask: "off" } } }, + }, + }, + ])("answers Claude live control_request can_use_tool: $name", async (testCase) => { + const run = async () => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: testCase.requestId, + toolUseId: testCase.toolUseId, + input: testCase.input, + sessionId: `live-control-${testCase.requestId}`, + }), + }); + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + ...testCase.context, + }), + ); + + expect(result.text).toBe("ok"); + expectClaudeControlDecision(live, { + ...testCase.expected, + requestId: testCase.requestId, + ...(testCase.expected.behavior === "allow" ? { toolUseId: testCase.toolUseId } : {}), + }); + if (testCase.expectedPermissionMode) { + expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe( + testCase.expectedPermissionMode, + ); + } }; - expect(parsed.response.response.behavior).toBe("allow"); - expect(parsed.response.response.toolUseID).toBe("tool-permmode-allow-1"); + + if (testCase.approvals) { + await withTempExecApprovalsFile(testCase.approvals, run); + } else { + await run(); + } }); it("cleans live-turn resources when capture activation fails before spawn", async () => { const cleanup = vi.fn(async () => undefined); const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-capture-activation-failure", mcpDeliveryCapture: true, }); @@ -4842,12 +3643,9 @@ ${JSON.stringify({ }; }); const runTurn = async (runId: string, args: string[], env: Record) => { - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", + const context = buildClaudeLiveRunContext({ runId, backend: { - liveSession: "claude-stdio", resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], }, mcpDeliveryCapture: true, @@ -4912,149 +3710,61 @@ ${JSON.stringify({ }); it("ignores non-JSON stdout lines from Claude live sessions", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - "Claude CLI warning", - JSON.stringify({ type: "system", subtype: "init", session_id: "live-mixed" }), - JSON.stringify({ - type: "result", - session_id: "live-mixed", - result: "mixed-ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + "Claude CLI warning", + { type: "system", subtype: "init", session_id: "live-mixed" }, + { type: "result", session_id: "live-mixed", result: "mixed-ok" }, + ], }); const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-mixed", - backend: { - liveSession: "claude-stdio", - }, - }), + buildPreparedCliRunContext({ backend: { liveSession: "claude-stdio" } }), ); - expect(result.text).toBe("mixed-ok"); }); it("fails Claude live turns on is_error results", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-error" }), - JSON.stringify({ - type: "result", - session_id: "live-error", - is_error: true, - result: "Credit balance is too low", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-error" }, + { + type: "result", + session_id: "live-error", + is_error: true, + result: "Credit balance is too low", + }, + ], }); await expectRejectsWithFields( executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-error", - backend: { - liveSession: "claude-stdio", - }, - }), + buildPreparedCliRunContext({ backend: { liveSession: "claude-stdio" } }), ), - { - name: "FailoverError", - message: "Credit balance is too low", - }, + { name: "FailoverError", message: "Credit balance is too low" }, ); }); it("surfaces Claude live max-turn results with run and session recovery context", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-max-turns", - }), - JSON.stringify({ - type: "result", - subtype: "error_max_turns", - session_id: "live-max-turns", - num_turns: 2, - stop_reason: "tool_use", - terminal_reason: "max_turns", - errors: ["Reached maximum number of turns (1)"], - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-max-turns" }, + { + type: "result", + subtype: "error_max_turns", + session_id: "live-max-turns", + num_turns: 2, + stop_reason: "tool_use", + terminal_reason: "max_turns", + errors: ["Reached maximum number of turns (1)"], + }, + ], }); await expectRejectsWithFields( executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", + buildClaudeLiveRunContext({ runId: "run-live-max-turns", - backend: { - liveSession: "claude-stdio", - }, }), ), { @@ -5072,172 +3782,67 @@ ${JSON.stringify({ ); }); - it("marks Claude live stderr context overflows as retryable", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - stdoutListener?.( - JSON.stringify({ type: "system", subtype: "init", session_id: "live-overflow" }) + "\n", - ); - cb?.(); - resolveExit?.({ - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "Prompt is too long", - timedOut: false, - noOutputTimedOut: false, - }); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-overflow-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => exited), - cancel: vi.fn(), - }; - }); - - await expectRejectsWithFields( - executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-overflow", - backend: { - liveSession: "claude-stdio", - }, - }), - ), - { + it.each([ + { + name: "marks Claude live stderr context overflows as retryable", + exitCode: 1, + stderr: "Prompt is too long", + events: [{ type: "system", subtype: "init", session_id: "live-overflow" }], + expected: { name: "FailoverError", reason: "context_overflow", code: "cli_context_overflow", status: 413, }, - ); - }); - - it("marks quiet Claude live exit-zero turns as retryable empty responses", async () => { - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const stdin = { - write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { - cb?.(); - resolveExit?.({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async () => ({ - runId: "live-empty-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => exited), - cancel: vi.fn(), - })); - - await expectRejectsWithFields( - executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-empty", - backend: { - liveSession: "claude-stdio", - }, - }), - ), - { + }, + { + name: "marks quiet Claude live exit-zero turns as retryable empty responses", + exitCode: 0, + stderr: "", + events: [], + expected: { name: "FailoverError", reason: "empty_response", code: "cli_unknown_empty_failure", }, - ); - }); - - it("preserves Claude live stderr classification on exit-zero failures", async () => { - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const stdin = { - write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { - cb?.(); - resolveExit?.({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "Prompt is too long", - timedOut: false, - noOutputTimedOut: false, - }); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async () => ({ - runId: "live-exit-zero-overflow-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => exited), - cancel: vi.fn(), - })); - - await expectRejectsWithFields( - executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-exit-zero-overflow", - backend: { - liveSession: "claude-stdio", - }, - }), - ), - { + }, + { + name: "preserves Claude live stderr classification on exit-zero failures", + exitCode: 0, + stderr: "Prompt is too long", + events: [], + expected: { name: "FailoverError", reason: "context_overflow", code: "cli_context_overflow", }, + }, + ])("$name", async (testCase) => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: testCase.events, + exitOnWrite: { + reason: "exit", + exitCode: testCase.exitCode, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: testCase.stderr, + timedOut: false, + noOutputTimedOut: false, + }, + }); + + await expectRejectsWithFields( + executePreparedCliRun( + buildPreparedCliRunContext({ backend: { liveSession: "claude-stdio" } }), + ), + testCase.expected, ); }); it("fails when Claude exits before a live turn starts", async () => { - supervisorSpawnMock.mockImplementationOnce(async () => ({ - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin: { - write: vi.fn(), - end: vi.fn(), - }, - wait: vi.fn(async () => ({ + mockClaudeLiveRun(supervisorSpawnMock, { + exitImmediately: { reason: "exit", exitCode: 1, exitSignal: null, @@ -5246,22 +3851,12 @@ ${JSON.stringify({ stderr: "startup failed", timedOut: false, noOutputTimedOut: false, - })), - cancel: vi.fn(), - })); + }, + }); - await expect( - executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-startup-exit", - backend: { - liveSession: "claude-stdio", - }, - }), - ), - ).rejects.toThrow("Claude CLI live session closed before handling the turn"); + await expect(executePreparedCliRun(buildClaudeLiveRunContext())).rejects.toThrow( + "Claude CLI live session closed before handling the turn", + ); }); it("restarts the Claude live process after request abort", async () => { @@ -5320,14 +3915,7 @@ ${JSON.stringify({ }; }); - const firstContext = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-abort-1", - backend: { - liveSession: "claude-stdio", - }, - }); + const firstContext = buildClaudeLiveRunContext({}); firstContext.params.abortSignal = abortController.signal; const first = executePreparedCliRun(firstContext); @@ -5349,16 +3937,7 @@ ${JSON.stringify({ ].join("\n") + "\n", ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-abort-2", - backend: { - liveSession: "claude-stdio", - }, - }), - ); + const second = await executePreparedCliRun(buildClaudeLiveRunContext({})); expect(second.text).toBe("second-ok"); expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); @@ -5380,7 +3959,6 @@ ${JSON.stringify({ end: vi.fn(), }; supervisorSpawnMock.mockImplementationOnce(async () => ({ - runId: "live-run-stuck-stdin", pid: 2345, startedAtMs: Date.now(), stdin, @@ -5392,14 +3970,8 @@ ${JSON.stringify({ })); try { - const context = buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-stuck-stdin", + const context = buildClaudeLiveRunContext({ timeoutMs: 10_000, - backend: { - liveSession: "claude-stdio", - }, }); const run = runClaudeLiveSessionTurn({ context, @@ -5479,15 +4051,9 @@ ${JSON.stringify({ try { const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-skills-1", + buildClaudeLiveRunContext({ prompt: "first", workspaceDir, - backend: { - liveSession: "claude-stdio", - }, skillsSnapshot: { prompt: "weather", skills: [{ name: "weather" }], @@ -5512,15 +4078,9 @@ ${JSON.stringify({ }), ); const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-skills-2", + buildClaudeLiveRunContext({ prompt: "second", workspaceDir, - backend: { - liveSession: "claude-stdio", - }, skillsSnapshot: { prompt: "git", skills: [{ name: "git" }], @@ -5557,60 +4117,28 @@ ${JSON.stringify({ it("closes idle Claude live sessions after ten minutes", async () => { vi.useFakeTimers(); - const writes: string[] = []; - let stdoutListener: ((chunk: string) => void) | undefined; - const cancel = vi.fn(); - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - writes.push(data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session-idle" }), - JSON.stringify({ - type: "result", - session_id: "live-session-idle", - result: "idle-ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-session-idle" }, + { type: "result", session_id: "live-session-idle", result: "idle-ok" }, + ], }); try { const result = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-idle", + buildClaudeLiveRunContext({ prompt: "idle", - backend: { - liveSession: "claude-stdio", - }, }), ); expect(result.text).toBe("idle-ok"); - expect(cancel).not.toHaveBeenCalled(); + expect(live.lifecycle.cancel).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1); - expect(cancel).not.toHaveBeenCalled(); + expect(live.lifecycle.cancel).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(cancel).toHaveBeenCalledWith("manual-cancel"); + expect(live.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); expect( - writes.map( + live.writes.map( (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, ), ).toEqual(["idle"]); @@ -5700,25 +4228,13 @@ ${JSON.stringify({ }); const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-stderr-1", + buildClaudeLiveRunContext({ prompt: "first", - backend: { - liveSession: "claude-stdio", - }, }), ); const second = executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-live-stderr-2", + buildClaudeLiveRunContext({ prompt: "second", - backend: { - liveSession: "claude-stdio", - }, }), ); @@ -5745,13 +4261,7 @@ ${JSON.stringify({ }), ); - const run = executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "claude-cli", - model: "sonnet", - runId: "run-claude-api-error", - }), - ); + const run = executePreparedCliRun(buildPreparedCliRunContext({})); await expectRejectsWithFields(run, { name: "FailoverError", @@ -5767,7 +4277,6 @@ ${JSON.stringify({ buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-env-sanitized", backend: { env: { NODE_OPTIONS: "--require ./malicious.js", @@ -5791,44 +4300,38 @@ ${JSON.stringify({ expect(input.env?.LD_PRELOAD).toBeUndefined(); }); - it("applies clearEnv after sanitizing backend env overrides", async () => { - process.env.SAFE_CLEAR = "from-base"; - mockSuccessfulCliRun(); - await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "codex-cli", - model: "gpt-5.4", - runId: "run-clear-env", - backend: { - env: { - SAFE_KEEP: "keep-me", - }, - clearEnv: ["SAFE_CLEAR"], - }, - }), - "thread-123", - ); - - const input = mockCallArg(supervisorSpawnMock) as { - env?: Record; - }; - expect(input.env?.SAFE_KEEP).toBe("keep-me"); - expect(input.env?.SAFE_CLEAR).toBeUndefined(); - }); - - it("can preserve selected clearEnv keys for live CLI backend probes", async () => { + it.each([ + { + name: "applies clearEnv after sanitizing backend env overrides", + baseEnv: { SAFE_CLEAR: "from-base" }, + backend: { env: { SAFE_KEEP: "keep-me" }, clearEnv: ["SAFE_CLEAR"] }, + expected: { SAFE_KEEP: "keep-me", SAFE_CLEAR: undefined }, + }, + { + name: "can preserve selected clearEnv keys for live CLI backend probes", + baseEnv: { SAFE_CLEAR: "from-base" }, + preserve: ["SAFE_CLEAR"], + backend: { clearEnv: ["SAFE_CLEAR", "SAFE_DROP"] }, + expected: { SAFE_CLEAR: "from-base", SAFE_DROP: undefined }, + }, + { + name: "keeps explicit backend env overrides even when clearEnv drops inherited values", + baseEnv: { SAFE_OVERRIDE: "from-base" }, + backend: { env: { SAFE_OVERRIDE: "from-override" }, clearEnv: ["SAFE_OVERRIDE"] }, + expected: { SAFE_OVERRIDE: "from-override" }, + }, + ])("$name", async (testCase) => { + Object.assign(process.env, testCase.baseEnv); + if (testCase.preserve) { + process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV = JSON.stringify(testCase.preserve); + } try { - process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV = '["SAFE_CLEAR"]'; - process.env.SAFE_CLEAR = "from-base"; mockSuccessfulCliRun(); await executePreparedCliRun( buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-clear-env-preserve", - backend: { - clearEnv: ["SAFE_CLEAR", "SAFE_DROP"], - }, + backend: testCase.backend as Partial, }), "thread-123", ); @@ -5836,48 +4339,25 @@ ${JSON.stringify({ const input = mockCallArg(supervisorSpawnMock) as { env?: Record; }; - expect(input.env?.SAFE_CLEAR).toBe("from-base"); - expect(input.env?.SAFE_DROP).toBeUndefined(); + for (const [key, value] of Object.entries(testCase.expected)) { + expect(input.env?.[key]).toBe(value); + } } finally { delete process.env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV; - delete process.env.SAFE_CLEAR; + for (const key of Object.keys(testCase.baseEnv)) { + delete process.env[key]; + } } }); - it("keeps explicit backend env overrides even when clearEnv drops inherited values", async () => { - process.env.SAFE_OVERRIDE = "from-base"; - mockSuccessfulCliRun(); - await executePreparedCliRun( - buildPreparedCliRunContext({ - provider: "codex-cli", - model: "gpt-5.4", - runId: "run-clear-env-override", - backend: { - env: { - SAFE_OVERRIDE: "from-override", - }, - clearEnv: ["SAFE_OVERRIDE"], - }, - }), - "thread-123", - ); - - const input = mockCallArg(supervisorSpawnMock) as { - env?: Record; - }; - expect(input.env?.SAFE_OVERRIDE).toBe("from-override"); - }); - it("keeps selected Claude auth authoritative over ambient and configured credentials", async () => { vi.stubEnv("OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV", '["ANTHROPIC_API_KEY"]'); vi.stubEnv("ANTHROPIC_API_KEY", "ambient-api-key"); - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", - runId: "run-claude-selected-auth-authority", preparedEnv: { CLAUDE_CODE_OAUTH_TOKEN: "selected-oauth-token", CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1", @@ -5913,13 +4393,11 @@ ${JSON.stringify({ vi.stubEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "none"); vi.stubEnv("OTEL_SDK_DISABLED", "true"); vi.stubEnv("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"); - mockSuccessfulClaudeJsonlRun(); + mockSuccessfulCliRun(CLAUDE_OK_JSONL); await executePreparedCliRun( buildPreparedCliRunContext({ - provider: "claude-cli", model: "claude-sonnet-4-6", - runId: "run-claude-env-hardened", preparedEnv: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "100000", }, @@ -6018,7 +4496,6 @@ ${JSON.stringify({ const context = buildPreparedCliRunContext({ provider: "codex-cli", model: "gpt-5.4", - runId: "run-warning", }); context.reusableCliSession = { mode: "reuse", sessionId: "thread-123" }; context.bootstrapPromptWarningLines = [ diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts new file mode 100644 index 000000000000..10e04a6c04b3 --- /dev/null +++ b/src/agents/cli-runner.test-helpers.ts @@ -0,0 +1,655 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { expect, vi } from "vitest"; +import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; +import type { McpLoopbackRequestContext } from "../gateway/mcp-grant-store.js"; +import { + onTrustedInternalDiagnosticEvent, + type DiagnosticEventPayload, + type DiagnosticEventPrivateData, +} from "../infra/diagnostic-events.js"; +import type { CliBackendPlugin } from "../plugins/cli-backend.types.js"; +import type { RunExit } from "../process/supervisor/types.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import type { PreparedCliRunContext } from "./cli-runner/types.js"; +import type { RunCliAgentParams } from "./cli-runner/types.js"; + +type CliProvider = "claude-cli" | "codex-cli" | "google-gemini-cli"; +type McpLoopbackClientGrant = ReturnType< + (typeof import("../gateway/mcp-grant-store.js"))["mintMcpLoopbackClientGrant"] +>; +type ModelCallLifecycleEvent = Extract< + DiagnosticEventPayload, + { type: "model.call.started" | "model.call.completed" | "model.call.error" } +>; + +export type TestCliBackendParams = { + bundleMcp?: boolean; + reseedFromRawTranscriptWhenUncompacted?: boolean; + systemPromptWhen?: "first" | "always" | "never"; +}; + +export function wrappedPluginSystemContext(text: string) { + return `---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`; +} + +export function captureModelCallDiagnostics(runId: string) { + const events: Array<{ + event: ModelCallLifecycleEvent; + privateData: DiagnosticEventPrivateData; + }> = []; + const stop = onTrustedInternalDiagnosticEvent((event, _metadata, privateData) => { + if ( + (event.type === "model.call.started" || + event.type === "model.call.completed" || + event.type === "model.call.error") && + event.runId === runId + ) { + events.push({ event, privateData }); + } + }); + return { events, stop }; +} + +export function expectModelCallTypes( + diagnostics: { events: Array<{ event: { type: string } }> }, + types: string[], +) { + expect(diagnostics.events.map(({ event }) => event.type)).toEqual(types); +} + +export function createTestMcpLoopbackServerConfig(port: number) { + return { + mcpServers: { + openclaw: { + type: "http", + url: `http://127.0.0.1:${port}/mcp`, + alwaysLoad: true, + headers: { + Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", + "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", + }, + }, + }, + }; +} + +export function createTestMcpLoopbackClientGrant(params: { + context: McpLoopbackRequestContext; +}): McpLoopbackClientGrant { + return { token: "loopback-token", context: structuredClone(params.context) }; +} + +export async function createTestMcpLoopbackServer(port = 0) { + return { port, close: vi.fn(async () => undefined) }; +} + +export function buildDefaultTestCliBackend( + params: TestCliBackendParams = {}, +): CliBackendPlugin & { pluginId: string } { + return { + id: "test-cli", + pluginId: "test-cli-plugin", + bundleMcp: params.bundleMcp === true, + ...(params.bundleMcp ? { bundleMcpMode: "claude-config-file" as const } : {}), + config: { + command: "test-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: params.systemPromptWhen ?? "first", + sessionMode: "existing", + output: "text", + input: "arg", + ...(params.reseedFromRawTranscriptWhenUncompacted + ? { reseedFromRawTranscriptWhenUncompacted: true } + : {}), + }, + }; +} + +export type PreparedCliRunContextOverrides = { + provider?: CliProvider; + model?: string; + runId?: string; + prompt?: string; + sessionId?: string; + sessionKey?: string; + sessionEntry?: PreparedCliRunContext["params"]["sessionEntry"]; + agentId?: string; + backend?: Partial; + preparedEnv?: PreparedCliRunContext["preparedBackend"]["env"]; + resolveExecutionArgs?: PreparedCliRunContext["backendResolved"]["resolveExecutionArgs"]; + config?: PreparedCliRunContext["params"]["config"]; + mcpConfigHash?: string; + mcpDeliveryCapture?: boolean; + skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"]; + thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"]; + executionMode?: PreparedCliRunContext["params"]["executionMode"]; + cliToolAvailability?: PreparedCliRunContext["params"]["cliToolAvailability"]; + emitCommentaryText?: boolean; + workspaceDir?: string; + timeoutMs?: number; + onSuccessfulAuthBinding?: PreparedCliRunContext["params"]["onSuccessfulAuthBinding"]; + runtimeArtifact?: PreparedCliRunContext["backendResolved"]["runtimeArtifact"]; +}; + +export function buildPreparedCliRunContext( + overrides: PreparedCliRunContextOverrides = {}, +): PreparedCliRunContext { + const provider = overrides.provider ?? "claude-cli"; + const model = overrides.model ?? "sonnet"; + const workspaceDir = overrides.workspaceDir ?? "/tmp"; + const baseBackend = + provider === "claude-cli" + ? { + command: "claude", + args: ["-p", "--output-format", "stream-json"], + output: "jsonl" as const, + input: "stdin" as const, + modelArg: "--model", + sessionArgs: ["--session-id", "{sessionId}"], + sessionMode: "always" as const, + systemPromptFileArg: "--append-system-prompt-file", + systemPromptWhen: "first" as const, + serialize: true, + } + : provider === "google-gemini-cli" + ? { + command: "gemini", + args: [ + "--skip-trust", + "--approval-mode", + "auto_edit", + "--output-format", + "stream-json", + "--prompt", + "{prompt}", + ], + output: "jsonl" as const, + jsonlDialect: "gemini-stream-json" as const, + input: "arg" as const, + modelArg: "--model", + sessionMode: "existing" as const, + serialize: true, + } + : { + command: "codex", + args: ["exec", "--json"], + resumeArgs: ["exec", "resume", "{sessionId}", "--skip-git-repo-check"], + output: "text" as const, + input: "arg" as const, + modelArg: "--model", + sessionMode: "existing" as const, + systemPromptFileConfigArg: "-c", + systemPromptFileConfigKey: "model_instructions_file", + systemPromptWhen: "first" as const, + serialize: true, + }; + const backend = { ...baseBackend, ...overrides.backend }; + return { + params: { + sessionId: overrides.sessionId ?? "s1", + sessionKey: overrides.sessionKey, + sessionEntry: overrides.sessionEntry, + agentId: overrides.agentId, + sessionFile: "/tmp/session.jsonl", + workspaceDir, + config: overrides.config, + prompt: overrides.prompt ?? "hi", + provider, + model, + thinkLevel: overrides.thinkLevel, + executionMode: overrides.executionMode, + cliToolAvailability: overrides.cliToolAvailability, + emitCommentaryText: overrides.emitCommentaryText, + onSuccessfulAuthBinding: overrides.onSuccessfulAuthBinding, + timeoutMs: overrides.timeoutMs ?? 1_000, + runId: overrides.runId ?? "run-test", + skillsSnapshot: overrides.skillsSnapshot, + }, + started: Date.now(), + workspaceDir, + backendResolved: { + id: provider, + config: backend, + bundleMcp: provider === "claude-cli", + pluginId: + provider === "claude-cli" + ? "anthropic" + : provider === "google-gemini-cli" + ? "google" + : "openai", + resolveExecutionArgs: overrides.resolveExecutionArgs, + runtimeArtifact: overrides.runtimeArtifact, + }, + preparedBackend: { + backend, + env: overrides.preparedEnv ?? {}, + ...(overrides.mcpConfigHash ? { mcpConfigHash: overrides.mcpConfigHash } : {}), + }, + reusableCliSession: { mode: "none" }, + hadSessionFile: false, + contextEngineConfig: {}, + modelId: model, + normalizedModel: model, + systemPrompt: "You are a helpful assistant.", + systemPromptReport: {} as PreparedCliRunContext["systemPromptReport"], + bootstrapPromptWarningLines: [], + authEpochVersion: 2, + ...(overrides.mcpDeliveryCapture ? { mcpDeliveryCapture: true } : {}), + }; +} + +export function buildClaudeLiveRunContext(overrides: PreparedCliRunContextOverrides = {}) { + return buildPreparedCliRunContext({ + ...overrides, + backend: { ...overrides.backend, liveSession: "claude-stdio" }, + }); +} + +export function buildClaudeLiveBackend( + overrides: Partial = {}, +) { + return { + command: "claude", + args: ["-p", "--output-format", "stream-json"], + output: "jsonl" as const, + input: "stdin" as const, + sessionArgs: ["--session-id", "{sessionId}"], + systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", + ...overrides, + }; +} + +export function createCancelableLiveRunLifecycle() { + let resolveExit!: (exit: RunExit) => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + return { + wait: vi.fn(() => exited), + cancel: vi.fn((_reason?: string) => { + resolveExit({ + reason: "manual-cancel", + exitCode: null, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }), + }; +} + +export function requireArgAfter(argv: string[] | undefined, flag: string): string { + const index = argv?.indexOf(flag) ?? -1; + if (index < 0) { + throw new Error(`expected CLI arg ${flag}`); + } + const value = argv?.[index + 1]?.trim(); + if (!value) { + throw new Error(`expected value after CLI arg ${flag}`); + } + return value; +} + +export function requireRegexMatch(value: string, pattern: RegExp): RegExpExecArray { + const match = pattern.exec(value); + if (!match) { + throw new Error(`expected ${value} to match ${pattern}`); + } + return match; +} + +export function requireRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object") { + throw new Error(`expected ${label} to be an object`); + } + return value as Record; +} + +export function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0) { + const call = mock.mock.calls[callIndex] as unknown[] | undefined; + if (!call) { + throw new Error(`expected mock call ${callIndex}`); + } + return call[argIndex]; +} + +export async function expectRejectsWithFields( + promise: Promise, + expected: Record, +) { + try { + await promise; + } catch (error) { + const actual = requireRecord(error, "rejection"); + for (const [key, value] of Object.entries(expected)) { + expect(actual[key]).toBe(value); + } + return actual; + } + throw new Error("expected promise to reject"); +} + +export async function expectPathMissing(targetPath: string) { + try { + await fs.promises.access(targetPath); + } catch (error) { + expect(requireRecord(error, "filesystem error").code).toBe("ENOENT"); + return; + } + throw new Error(`expected ${targetPath} to be missing`); +} + +export async function withTempExecApprovalsFile( + file: Record, + run: () => Promise, +) { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-exec-approvals-")); + await fs.promises.mkdir(path.join(home, ".openclaw"), { recursive: true }); + await fs.promises.writeFile( + path.join(home, ".openclaw", "exec-approvals.json"), + `${JSON.stringify(file)}\n`, + "utf-8", + ); + try { + await withEnvAsync({ HOME: home }, run); + } finally { + await fs.promises.rm(home, { recursive: true, force: true }); + } +} + +export async function withTempOpenClawHome(run: (home: string) => Promise) { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-home-")); + try { + await withEnvAsync({ OPENCLAW_HOME: home }, async () => run(home)); + } finally { + await fs.promises.rm(home, { recursive: true, force: true }); + } +} + +type PrepareCliRun = (params: RunCliAgentParams) => Promise; + +export function createCliRunnerPrepareFixture(prepareCliRun: PrepareCliRun) { + const tempDirs = new Set(); + const hadStateDir = Object.hasOwn(process.env, "OPENCLAW_STATE_DIR"); + const originalStateDir = process.env.OPENCLAW_STATE_DIR; + let defaultSession: { dir: string; sessionFile: string } | undefined; + + const createSession = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-prepare-")); + tempDirs.add(dir); + process.env.OPENCLAW_STATE_DIR = dir; + const sessionFile = path.join(dir, "agents", "main", "sessions", "session-test.jsonl"); + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync( + sessionFile, + `${JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: "session-test", + timestamp: new Date(0).toISOString(), + cwd: dir, + })}\n`, + "utf-8", + ); + return { dir, sessionFile }; + }; + + const getSession = () => (defaultSession ??= createSession()); + return { + get session() { + return getSession(); + }, + createSession, + prepare(overrides: Partial = {}) { + const { dir, sessionFile } = getSession(); + const defaults: RunCliAgentParams = { + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test", + config: {}, + }; + return prepareCliRun(Object.assign(defaults, overrides)); + }, + appendTranscript(entry: { + id: string; + parentId: string | null; + timestamp: string; + message: unknown; + }) { + const { sessionFile } = getSession(); + fs.appendFileSync(sessionFile, `${JSON.stringify({ type: "message", ...entry })}\n`, "utf-8"); + }, + cleanup() { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + defaultSession = undefined; + if (hadStateDir) { + process.env.OPENCLAW_STATE_DIR = originalStateDir; + } else { + delete process.env.OPENCLAW_STATE_DIR; + } + }, + }; +} + +export function createWeatherSkillFixture(root: string, materialized: boolean) { + const skillDir = path.join(root, "skills", materialized ? "weather" : "missing"); + const skillFilePath = path.join(skillDir, "SKILL.md"); + if (materialized) { + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + skillFilePath, + [ + "---", + "name: weather", + "description: Use weather tools for forecasts.", + "---", + "", + "Read forecast data before replying.", + ].join("\n"), + "utf-8", + ); + } + const prompt = [ + "", + " ", + " weather", + " Use weather tools for forecasts.", + ` ${skillFilePath}`, + " ", + "", + ].join("\n"); + return { + skillDir, + skillFilePath, + snapshot: { + prompt, + skills: [{ name: "weather" }], + resolvedSkills: [ + { + name: "weather", + description: "Use weather tools for forecasts.", + filePath: skillFilePath, + baseDir: skillDir, + source: "test", + sourceInfo: { + path: skillDir, + source: "test", + scope: "project", + origin: "top-level", + baseDir: skillDir, + }, + disableModelInvocation: false, + }, + ], + } satisfies NonNullable, + }; +} + +type SupervisorSpawnMock = (typeof import("./cli-runner.test-support.js"))["supervisorSpawnMock"]; + +type ClaudeLiveRunFixture = ReturnType; + +export function mockClaudeLiveRun( + spawnMock: SupervisorSpawnMock, + options: { + cancelable?: boolean; + beforeSpawn?: () => Promise; + events?: Array | string>; + exitImmediately?: RunExit; + exitOnWrite?: RunExit; + onWrite?: (params: { + data: string; + emit: (events: Array | string>) => void; + writeIndex: number; + }) => void; + runId?: string; + pid?: number; + } = {}, +) { + let stdoutListener: ((chunk: string) => void) | undefined; + let resolveExit: ((exit: RunExit) => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + let spawnInput: { + argv?: string[]; + env?: Record; + onStdout?: (chunk: string) => void; + } = {}; + const writes: string[] = []; + const emit = (events: Array | string>) => { + stdoutListener?.( + `${events.map((event) => (typeof event === "string" ? event : JSON.stringify(event))).join("\n")}\n`, + ); + }; + const stdin = { + write: vi.fn((data: string, callback?: (error?: Error | null) => void) => { + writes.push(data); + const writeIndex = writes.length - 1; + if (options.onWrite) { + options.onWrite({ data, emit, writeIndex }); + } else if (writeIndex === 0 && options.events) { + emit(options.events); + } + callback?.(); + if (options.exitOnWrite) { + resolveExit?.(options.exitOnWrite); + } + }), + end: vi.fn(), + }; + const lifecycle = options.cancelable + ? createCancelableLiveRunLifecycle() + : { + wait: vi.fn(() => + options.exitImmediately + ? Promise.resolve(options.exitImmediately) + : options.exitOnWrite + ? exited + : new Promise(() => {}), + ), + cancel: vi.fn(), + }; + spawnMock.mockImplementationOnce(async (...args: unknown[]) => { + spawnInput = (args[0] ?? {}) as typeof spawnInput; + stdoutListener = spawnInput.onStdout; + await options.beforeSpawn?.(); + return { + runId: options.runId ?? "live-run", + pid: options.pid ?? 2345, + startedAtMs: Date.now(), + stdin, + ...lifecycle, + }; + }); + return { + emit, + get spawnInput() { + return spawnInput; + }, + stdin, + lifecycle, + writes, + }; +} + +export function buildClaudeControlRequestEvents(params: { + requestId: string; + toolUseId: string; + input: Record; + sessionId?: string; +}) { + const sessionId = params.sessionId ?? "live-control"; + return [ + { + type: "control_request", + request_id: params.requestId, + request: { + subtype: "can_use_tool", + tool_name: "Bash", + tool_use_id: params.toolUseId, + input: params.input, + }, + }, + { type: "system", subtype: "init", session_id: sessionId }, + { type: "result", session_id: sessionId, result: "ok" }, + ]; +} + +export function expectClaudeControlDecision( + fixture: ClaudeLiveRunFixture, + expected: { + behavior: "allow" | "deny"; + requestId: string; + toolUseId?: string; + updatedInput?: Record; + messageIncludes?: string; + }, +) { + const encoded = fixture.writes.find((entry) => entry.includes('"control_response"')); + expect(encoded, "control_response written to stdin").toBeDefined(); + const parsed = JSON.parse((encoded ?? "").trim()) as { + type: string; + response: { + subtype: string; + request_id: string; + response: { + behavior: string; + decisionClassification?: string; + message?: string; + toolUseID?: string; + updatedInput?: unknown; + }; + }; + }; + expect(parsed.type).toBe("control_response"); + expect(parsed.response.subtype).toBe("success"); + expect(parsed.response.request_id).toBe(expected.requestId); + expect(parsed.response.response.behavior).toBe(expected.behavior); + if (expected.toolUseId) { + expect(parsed.response.response.toolUseID).toBe(expected.toolUseId); + } + if (expected.updatedInput) { + expect(parsed.response.response.updatedInput).toEqual(expected.updatedInput); + } + if (expected.messageIncludes) { + expect(parsed.response.response.decisionClassification).toBe("user_reject"); + expect(parsed.response.response.message).toContain(expected.messageIncludes); + } + return parsed; +} diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index 424a61144876..b6fdc8bab6e7 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -42,14 +42,14 @@ setCliRunnerPrepareTestDeps({ }); /** Queue one successful CLI supervisor run. */ -export function mockSuccessfulCliRun() { +export function mockSuccessfulCliRun(stdout = "ok") { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ reason: "exit", exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout, stderr: "", timedOut: false, noOutputTimedOut: false, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 4e7584e89d46..4ddf1d7e2d75 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -5,7 +5,6 @@ import os from "node:os"; import path from "node:path"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import { expectDefined } from "@openclaw/normalization-core"; -import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildGroupChatContext, buildGroupIntro } from "../../auto-reply/reply/groups.js"; @@ -14,7 +13,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js"; import { registerContextEngineForOwner } from "../../context-engine/registry.js"; import type { ContextEngine } from "../../context-engine/types.js"; -import type { McpLoopbackRequestContext } from "../../gateway/mcp-grant-store.js"; import type { CliBackendPlugin } from "../../plugins/cli-backend.types.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { @@ -34,6 +32,16 @@ import { setCliAuthEpochTestDeps, } from "../cli-auth-epoch.test-support.js"; import { testing as cliBackendsTesting } from "../cli-backends.test-support.js"; +import { + buildDefaultTestCliBackend, + createCliRunnerPrepareFixture, + createTestMcpLoopbackClientGrant, + createTestMcpLoopbackServer, + createTestMcpLoopbackServerConfig, + createWeatherSkillFixture, + wrappedPluginSystemContext, + type TestCliBackendParams, +} from "../cli-runner.test-helpers.js"; import { hashCliSessionText } from "../cli-session.js"; import { resetContextWindowCacheForTest } from "../context.js"; import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js"; @@ -45,10 +53,6 @@ import { prepareCliRunContext } from "./prepare.js"; import { setCliRunnerPrepareTestDeps } from "./prepare.test-support.js"; import type { RunCliAgentParams } from "./types.js"; -type McpLoopbackClientGrant = ReturnType< - (typeof import("../../gateway/mcp-grant-store.js"))["mintMcpLoopbackClientGrant"] ->; - function registerTestContextEngine( id: string, factory: Parameters[1], @@ -62,8 +66,6 @@ const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({}))); const ensureSandboxWorkspaceForSessionMock = vi.hoisted(() => vi.fn<() => Promise>(async () => null), ); -let sessionFileEnvSnapshot: ReturnType | undefined; - vi.mock("../../config/config.js", () => ({ getRuntimeConfig: getRuntimeConfigMock, })); @@ -115,73 +117,6 @@ const mockBuildActiveMusicGenerationTaskPromptContextForSession = vi.mocked( buildActiveMusicGenerationTaskPromptContextForSession, ); -function wrappedPluginSystemContext(text: string): string { - return `---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`; -} - -function createTestMcpLoopbackServerConfig(port: number) { - // Mirrors the runtime loopback config shape so tests cover env placeholder - // substitution without starting the real MCP HTTP server. - return { - mcpServers: { - openclaw: { - type: "http", - url: `http://127.0.0.1:${port}/mcp`, - alwaysLoad: true, - headers: { - Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", - "x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}", - }, - }, - }, - }; -} - -function createTestMcpLoopbackClientGrant(params: { - context: McpLoopbackRequestContext; -}): McpLoopbackClientGrant { - return { - token: "loopback-token", - context: structuredClone(params.context), - }; -} - -async function createTestMcpLoopbackServer(port = 0) { - return { - port, - close: vi.fn(async () => undefined), - }; -} - -type TestCliBackendParams = { - bundleMcp?: boolean; - reseedFromRawTranscriptWhenUncompacted?: boolean; - systemPromptWhen?: "first" | "always" | "never"; -}; - -function buildDefaultTestCliBackend( - params: TestCliBackendParams = {}, -): CliBackendPlugin & { pluginId: string } { - return { - id: "test-cli", - pluginId: "test-cli-plugin", - bundleMcp: params.bundleMcp === true, - ...(params.bundleMcp ? { bundleMcpMode: "claude-config-file" as const } : {}), - config: { - command: "test-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: params.systemPromptWhen ?? "first", - sessionMode: "existing", - output: "text", - input: "arg", - ...(params.reseedFromRawTranscriptWhenUncompacted - ? { reseedFromRawTranscriptWhenUncompacted: true } - : {}), - }, - }; -} - let defaultTestCliBackend = buildDefaultTestCliBackend(); function createCliBackendConfig(params: TestCliBackendParams = {}): OpenClawConfig { @@ -241,48 +176,11 @@ function setCliBackendForPrepareTest( }); } -function createSessionFile() { - // Prepare tests use canonical OpenClaw session paths because several cases - // assert that external or stale transcript paths are ignored. - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-prepare-")); - sessionFileEnvSnapshot ??= captureEnv(["OPENCLAW_STATE_DIR"]); - setTestEnvValue("OPENCLAW_STATE_DIR", dir); - const sessionFile = path.join(dir, "agents", "main", "sessions", "session-test.jsonl"); - fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); - fs.writeFileSync( - sessionFile, - `${JSON.stringify({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: "session-test", - timestamp: new Date(0).toISOString(), - cwd: dir, - })}\n`, - "utf-8", - ); - return { dir, sessionFile }; -} - -function appendTranscriptEntry( - sessionFile: string, - entry: { - id: string; - parentId: string | null; - timestamp: string; - message: unknown; - }, -): void { - fs.appendFileSync( - sessionFile, - `${JSON.stringify({ - type: "message", - id: entry.id, - parentId: entry.parentId, - timestamp: entry.timestamp, - message: entry.message, - })}\n`, - "utf-8", - ); +function setRawCliBackendForPrepareTest(backend: CliBackendPlugin & { pluginId: string }) { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupCliBackend: () => undefined, + resolveRuntimeCliBackends: () => [backend], + }); } type CliContextBudgetTestCase = { @@ -295,6 +193,8 @@ type CliContextBudgetTestCase = { }; describe("prepareCliRunContext", () => { + let fixture: ReturnType; + it.each([ { name: "Claude CLI with a selected-agent cap", @@ -327,96 +227,85 @@ describe("prepareCliRunContext", () => { model: "claude-opus-4-7", }, ])("resolves canonical model budgets for $name", async (testCase) => { - const { dir, sessionFile } = createSessionFile(); const prepareExecution = vi.fn(async () => undefined); const baseConfig = createCliBackendConfig(); - try { - setCliBackendForPrepareTest({ - id: testCase.provider, - command: testCase.provider === "claude-cli" ? "claude" : testCase.provider, - modelProvider: "fixture-anthropic", - pluginId: "fixture-plugin", - prepareExecution, - modelAliases: testCase.modelAliases, - }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: testCase.provider, - model: testCase.model, - timeoutMs: 1_000, - runId: "run-configured-context-budget", - config: { - ...baseConfig, - agents: { - ...baseConfig.agents, - ...(testCase.agentContextTokens - ? { list: [{ id: "main", contextTokens: testCase.agentContextTokens }] } - : {}), - }, - models: { - providers: { - "fixture-anthropic": { - baseUrl: "https://api.anthropic.com", - contextTokens: 200_000, - models: [ - { - id: "claude-opus-4-7", - name: "Claude Opus 4.7", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - contextTokens: 100_000, - }, - ], - }, - "collision-provider": { - baseUrl: "https://collision.invalid", - models: [ - { - id: "large", - name: "Unrelated Large", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 32_000, - maxTokens: 4_096, - contextTokens: 32_000, - }, - ], - }, - "claude-cli": { - baseUrl: "https://runtime.invalid", - models: [ - { - id: "large", - name: "Configured Alias Source", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8_192, - contextTokens: 200_000, - }, - ], - }, + setCliBackendForPrepareTest({ + id: testCase.provider, + command: testCase.provider === "claude-cli" ? "claude" : testCase.provider, + modelProvider: "fixture-anthropic", + pluginId: "fixture-plugin", + prepareExecution, + modelAliases: testCase.modelAliases, + }); + const context = await fixture.prepare({ + provider: testCase.provider, + model: testCase.model, + config: { + ...baseConfig, + agents: { + ...baseConfig.agents, + ...(testCase.agentContextTokens + ? { list: [{ id: "main", contextTokens: testCase.agentContextTokens }] } + : {}), + }, + models: { + providers: { + "fixture-anthropic": { + baseUrl: "https://api.anthropic.com", + contextTokens: 200_000, + models: [ + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + contextTokens: 100_000, + }, + ], + }, + "collision-provider": { + baseUrl: "https://collision.invalid", + models: [ + { + id: "large", + name: "Unrelated Large", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 4_096, + contextTokens: 32_000, + }, + ], + }, + "claude-cli": { + baseUrl: "https://runtime.invalid", + models: [ + { + id: "large", + name: "Configured Alias Source", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + contextTokens: 200_000, + }, + ], }, }, - } satisfies OpenClawConfig, - }); + }, + } satisfies OpenClawConfig, + }); - expect(context.backendResolved.modelProvider).toBe("fixture-anthropic"); - expect(context.contextWindowInfo?.tokens).toBe(testCase.expectedContextTokens); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ contextTokenBudget: testCase.expectedContextTokens }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.backendResolved.modelProvider).toBe("fixture-anthropic"); + expect(context.contextWindowInfo?.tokens).toBe(testCase.expectedContextTokens); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ contextTokenBudget: testCase.expectedContextTokens }), + ); }); beforeEach(() => { @@ -455,6 +344,7 @@ describe("prepareCliRunContext", () => { mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(undefined); ensureSandboxWorkspaceForSessionMock.mockReset(); ensureSandboxWorkspaceForSessionMock.mockResolvedValue(null); + fixture = createCliRunnerPrepareFixture(prepareCliRunContext); }); afterEach(() => { @@ -470,73 +360,55 @@ describe("prepareCliRunContext", () => { clearMemoryPluginState(); setActivePluginRegistry(createTestRegistry()); vi.unstubAllEnvs(); - sessionFileEnvSnapshot?.restore(); - sessionFileEnvSnapshot = undefined; + fixture.cleanup(); }); it("honors an explicit auth agent directory independently of session identity", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const modelOwnerAgentDir = path.join(dir, "ops-agent"); const systemAgentDir = path.join(dir, "openclaw-agent"); const prepareExecution = vi.fn(async () => undefined); fs.mkdirSync(modelOwnerAgentDir, { recursive: true }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test-plugin", - bundleMcp: false, - prepareExecution, - config: { - command: "test-cli", - args: ["--print"], - output: "text", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test-plugin", + bundleMcp: false, + prepareExecution, + config: { + command: "test-cli", + args: ["--print"], + output: "text", + input: "arg", + sessionMode: "existing", + }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:openclaw:main", - agentId: "openclaw", - sessionFile, - workspaceDir: dir, - agentDir: modelOwnerAgentDir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - authProfileId: "test-cli:ops", - timeoutMs: 1_000, - runId: "run-test-explicit-agent-dir", - config: { - agents: { - list: [ - { id: "ops", default: true, agentDir: modelOwnerAgentDir }, - { id: "openclaw", agentDir: systemAgentDir }, - ], - }, + const context = await fixture.prepare({ + sessionKey: "agent:openclaw:main", + agentId: "openclaw", + agentDir: modelOwnerAgentDir, + authProfileId: "test-cli:ops", + config: { + agents: { + list: [ + { id: "ops", default: true, agentDir: modelOwnerAgentDir }, + { id: "openclaw", agentDir: systemAgentDir }, + ], }, - }); + }, + }); - expect(context.effectiveAuthProfileId).toBe("test-cli:ops"); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - agentDir: modelOwnerAgentDir, - authProfileId: "test-cli:ops", - }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.effectiveAuthProfileId).toBe("test-cli:ops"); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: modelOwnerAgentDir, + authProfileId: "test-cli:ops", + }), + ); }); it("passes raw refreshed OAuth profile fields to profile-owned CLI preparation", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "google-gemini-cli:user@example.test"; const prepareExecution = vi.fn(async () => ({ @@ -567,67 +439,52 @@ describe("prepareCliRunContext", () => { }, agentDir, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: false, + authEpochMode: "profile-only", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ resolveApiKeyForProfile, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-oauth-raw-profile-fields", - authProfileId, - onSuccessfulAuthBinding: () => {}, - config: {}, - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:main", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + authProfileId, + onSuccessfulAuthBinding: () => {}, + config: {}, + }); - expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "raw-access-token", - refresh: "raw-refresh-token", - expires: 1_800_000_000_000, - }), + expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "google-gemini-cli", + access: "raw-access-token", + refresh: "raw-refresh-token", + expires: 1_800_000_000_000, }), - ); - expect(context.authBindingFingerprint).toMatch(/^[a-f0-9]{64}$/); - expect(context.authBindingSkipsLocalCredential).toBe(true); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }), + ); + expect(context.authBindingFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(context.authBindingSkipsLocalCredential).toBe(true); }); it("stages the resolved OAuth fallback profile for Gemini CLI preparation", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const legacyProfileId = "google-gemini-cli:default"; const resolvedProfileId = "google-gemini-cli:user@example.test"; @@ -667,64 +524,49 @@ describe("prepareCliRunContext", () => { }, agentDir, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: false, + authEpochMode: "profile-only", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ resolveApiKeyForProfile, }); - try { - await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-oauth-fallback-profile", - authProfileId: legacyProfileId, - config: {}, - }); + await fixture.prepare({ + sessionKey: "agent:main:main", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + authProfileId: legacyProfileId, + config: {}, + }); - expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId: resolvedProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "resolved-access-token", - refresh: "resolved-refresh-token", - expires: 1_800_000_000_000, - }), + expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: resolvedProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "google-gemini-cli", + access: "resolved-access-token", + refresh: "resolved-refresh-token", + expires: 1_800_000_000_000, }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }), + ); }); it("selects the configured Gemini CLI OAuth profile when no explicit profile is passed", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "google-gemini-cli:user@example.test"; const prepareExecution = vi.fn(async () => ({ @@ -755,78 +597,63 @@ describe("prepareCliRunContext", () => { }, agentDir, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: false, + authEpochMode: "profile-only", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ resolveApiKeyForProfile, }); - try { - await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-oauth-default-profile", - config: { - auth: { - profiles: { - [authProfileId]: { - provider: "google-gemini-cli", - mode: "oauth", - email: "user@example.test", - }, + await fixture.prepare({ + sessionKey: "agent:main:main", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + config: { + auth: { + profiles: { + [authProfileId]: { + provider: "google-gemini-cli", + mode: "oauth", + email: "user@example.test", }, }, - } as OpenClawConfig, - }); + }, + } as OpenClawConfig, + }); - expect(resolveApiKeyForProfile).toHaveBeenCalledWith( - expect.objectContaining({ - profileId: authProfileId, - agentDir, + expect(resolveApiKeyForProfile).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: authProfileId, + agentDir, + }), + ); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "google-gemini-cli", + access: "raw-access-token", + refresh: "raw-refresh-token", + expires: 1_800_000_000_000, }), - ); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "raw-access-token", - refresh: "raw-refresh-token", - expires: 1_800_000_000_000, - }), - }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }), + ); }); it("stages adopted OAuth credentials for Gemini CLI preparation", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "google-gemini-cli:user@example.test"; const prepareExecution = vi.fn(async () => ({ @@ -866,64 +693,49 @@ describe("prepareCliRunContext", () => { }, agentDir, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: false, + authEpochMode: "profile-only", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ resolveApiKeyForProfile, }); - try { - await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-oauth-adopted-credential", - authProfileId, - config: {}, - }); + await fixture.prepare({ + sessionKey: "agent:main:main", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + authProfileId, + config: {}, + }); - expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "google-gemini-cli", - access: "adopted-access-token", - refresh: "adopted-refresh-token", - expires: 1_900_000_000_000, - }), + expect(resolveApiKeyForProfile).toHaveBeenCalledOnce(); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "google-gemini-cli", + access: "adopted-access-token", + refresh: "adopted-refresh-token", + expires: 1_900_000_000_000, }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }), + ); }); it("does not expose auth profile credentials to non-bundled prepare hooks", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "test-cli:secret"; const prepareExecution = vi.fn(async (_ctx: unknown) => undefined); @@ -941,53 +753,36 @@ describe("prepareCliRunContext", () => { }, agentDir, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test-plugin", - bundleMcp: false, - prepareExecution, - config: { - command: "test-cli", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test-plugin", + bundleMcp: false, + prepareExecution, + config: { + command: "test-cli", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); - try { - await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-non-gemini-credential-boundary", - authProfileId, - config: {}, - }); + await fixture.prepare({ + sessionKey: "agent:main:main", + authProfileId, + config: {}, + }); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - }), - ); - expect(prepareExecution.mock.calls[0]?.[0]).not.toHaveProperty("authCredential"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + }), + ); + expect(prepareExecution.mock.calls[0]?.[0]).not.toHaveProperty("authCredential"); }); it("refreshes and forwards a selected Claude CLI OAuth profile", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "anthropic:claude-cli"; const prepareExecution = vi.fn(async () => undefined); @@ -1024,35 +819,25 @@ describe("prepareCliRunContext", () => { })), }); - try { - await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - agentDir, - prompt: "latest ask", - provider: "claude-cli", - model: "sonnet", - timeoutMs: 1_000, - runId: "run-test-claude-profile-forwarding", - authProfileId, - config: {}, - }); + await fixture.prepare({ + sessionKey: "agent:main:main", + agentDir, + provider: "claude-cli", + model: "sonnet", + authProfileId, + config: {}, + }); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ - authProfileId, - authCredential: expect.objectContaining({ - type: "oauth", - provider: "claude-cli", - access: "stored-access-token", - }), + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId, + authCredential: expect.objectContaining({ + type: "oauth", + provider: "claude-cli", + access: "stored-access-token", }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + }), + ); }); it.each([ @@ -1067,7 +852,7 @@ describe("prepareCliRunContext", () => { expectedAuthProfileId: undefined, }, ])("$name", async (testCase) => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const agentDir = path.join(dir, "agents", "main", "agent"); const authProfileId = "claude-cli:stored"; const prepareExecution = vi.fn(async () => ({ env: { TEST_PREPARED_ENV: "1" } })); @@ -1086,42 +871,31 @@ describe("prepareCliRunContext", () => { agentDir, ); - try { - setCliBackendForPrepareTest({ - prepareExecution, - autoSelectAuthProfile: testCase.autoSelectAuthProfile, - }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - agentDir, - prompt: "latest ask", - provider: "claude-cli", - model: "sonnet", - timeoutMs: 1_000, - runId: "run-test-environment-only-prepare-hook", - config: { - auth: { - profiles: { - [authProfileId]: { provider: "claude-cli", mode: "api_key" }, - }, + setCliBackendForPrepareTest({ + prepareExecution, + autoSelectAuthProfile: testCase.autoSelectAuthProfile, + }); + const context = await fixture.prepare({ + sessionKey: "agent:main:main", + agentDir, + provider: "claude-cli", + model: "sonnet", + config: { + auth: { + profiles: { + [authProfileId]: { provider: "claude-cli", mode: "api_key" }, }, }, - }); + }, + }); - expect(context.effectiveAuthProfileId).toBe(testCase.expectedAuthProfileId); - expect(prepareExecution).toHaveBeenCalledWith( - expect.objectContaining({ authProfileId: testCase.expectedAuthProfileId }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.effectiveAuthProfileId).toBe(testCase.expectedAuthProfileId); + expect(prepareExecution).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: testCase.expectedAuthProfileId }), + ); }); it("keeps bundled Claude secret input on the private prepared runner context", async () => { - const { dir, sessionFile } = createSessionFile(); const secretInput = { fd: 3, fingerprint: "credential-a", @@ -1132,33 +906,23 @@ describe("prepareCliRunContext", () => { secretInput, })); - try { - setCliBackendForPrepareTest({ - prepareExecution: prepareExecution as CliBackendPlugin["prepareExecution"], - }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "sonnet", - timeoutMs: 1_000, - runId: "run-test-private-secret-input", - config: {}, - }); + setCliBackendForPrepareTest({ + prepareExecution: prepareExecution as CliBackendPlugin["prepareExecution"], + }); + const context = await fixture.prepare({ + provider: "claude-cli", + model: "sonnet", + config: {}, + }); - expect(context.preparedBackend.secretInput).toBe(secretInput); - expect(context.preparedBackend.env).toMatchObject({ - CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.preparedBackend.secretInput).toBe(secretInput); + expect(context.preparedBackend.env).toMatchObject({ + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", + }); }); it("lets Gemini CLI preparation override generated MCP system settings auth", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const profileSystemSettingsPath = path.join(dir, "profile-system-settings.json"); const getActiveMcpLoopbackRuntime = vi.fn(() => ({ port: 31783, @@ -1170,24 +934,19 @@ describe("prepareCliRunContext", () => { GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileSystemSettingsPath, }, })); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: true, - bundleMcpMode: "gemini-system-settings", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime, @@ -1199,16 +958,10 @@ describe("prepareCliRunContext", () => { let cleanup: (() => Promise) | undefined; try { - const context = await prepareCliRunContext({ - sessionId: "session-test", + const context = await fixture.prepare({ sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", provider: "google-gemini-cli", model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-mcp-system-settings", config: {}, }); cleanup = context.preparedBackend.cleanup; @@ -1232,57 +985,35 @@ describe("prepareCliRunContext", () => { ); } finally { await cleanup?.(); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("preserves backend staging for queued execution without running it during prepare", async () => { - const { dir, sessionFile } = createSessionFile(); const beforeExecution = vi.fn(async () => {}); const prepareExecution = vi.fn(async () => ({ beforeExecution })); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test-plugin", - bundleMcp: false, - prepareExecution, - config: { - command: "test-cli", - args: ["--print"], - sessionMode: "existing", - output: "text", - input: "arg", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test-plugin", + bundleMcp: false, + prepareExecution, + config: { + command: "test-cli", + args: ["--print"], + sessionMode: "existing", + output: "text", + input: "arg", + }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-staging-thunk", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({}); - expect(prepareExecution).toHaveBeenCalledOnce(); - expect(beforeExecution).not.toHaveBeenCalled(); - await context.preparedBackend.beforeExecution?.(); - expect(beforeExecution).toHaveBeenCalledOnce(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(prepareExecution).toHaveBeenCalledOnce(); + expect(beforeExecution).not.toHaveBeenCalled(); + await context.preparedBackend.beforeExecution?.(); + expect(beforeExecution).toHaveBeenCalledOnce(); }); it("cleans generated Gemini MCP settings when auth preparation fails", async () => { - const { dir, sessionFile } = createSessionFile(); let generatedSystemSettingsPath: string | undefined; const getActiveMcpLoopbackRuntime = vi.fn(() => ({ port: 31783, @@ -1295,24 +1026,19 @@ describe("prepareCliRunContext", () => { throw new Error("Gemini auth profile was selected but no credential material was found"); }); const revokeMcpLoopbackClientGrant = vi.fn(() => true); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "google-gemini-cli", - pluginId: "google", - bundleMcp: true, - bundleMcpMode: "gemini-system-settings", - prepareExecution, - config: { - command: "gemini", - args: ["--prompt", "{prompt}"], - output: "json", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "google-gemini-cli", + pluginId: "google", + bundleMcp: true, + bundleMcpMode: "gemini-system-settings", + prepareExecution, + config: { + command: "gemini", + args: ["--prompt", "{prompt}"], + output: "json", + input: "arg", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime, @@ -1323,32 +1049,21 @@ describe("prepareCliRunContext", () => { resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), }); - try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - timeoutMs: 1_000, - runId: "run-test-gemini-mcp-cleanup-on-auth-failure", - config: {}, - }), - ).rejects.toThrow(/no credential material/); + await expect( + fixture.prepare({ + sessionKey: "agent:main:main", + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + config: {}, + }), + ).rejects.toThrow(/no credential material/); - expect(generatedSystemSettingsPath).toBeTruthy(); - expect(fs.existsSync(generatedSystemSettingsPath ?? "")).toBe(false); - expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(generatedSystemSettingsPath).toBeTruthy(); + expect(fs.existsSync(generatedSystemSettingsPath ?? "")).toBe(false); + expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); }); it("cleans prepared execution resources when auth epoch resolution fails", async () => { - const { dir, sessionFile } = createSessionFile(); const preparedExecutionCleanup = vi.fn(async () => undefined); const prepareExecution = vi.fn(async () => ({ cleanup: preparedExecutionCleanup })); setCliAuthEpochTestDeps({ @@ -1356,54 +1071,37 @@ describe("prepareCliRunContext", () => { throw new Error("auth epoch read failed"); }, }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test", - bundleMcp: false, - authEpochMode: "profile-only", - prepareExecution, - config: { - command: "test-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - output: "text", - input: "arg", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test", + bundleMcp: false, + authEpochMode: "profile-only", + prepareExecution, + config: { + command: "test-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + output: "text", + input: "arg", + sessionMode: "existing", + }, }); - try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-prepare-execution-cleanup-on-auth-epoch-failure", - authProfileId: "test-cli:profile", - config: {}, - }), - ).rejects.toThrow("auth epoch read failed"); + await expect( + fixture.prepare({ + sessionKey: "agent:main:main", + authProfileId: "test-cli:profile", + config: {}, + }), + ).rejects.toThrow("auth epoch read failed"); - expect(prepareExecution).toHaveBeenCalledOnce(); - expect(preparedExecutionCleanup).toHaveBeenCalledOnce(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(prepareExecution).toHaveBeenCalledOnce(); + expect(preparedExecutionCleanup).toHaveBeenCalledOnce(); }); it("cleans prepared MCP and skills plugin dirs when mid-prepare reference lookup fails", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const tempEnvSnapshot = captureEnv(["TMPDIR", "TMP", "TEMP"]); const tempRoot = path.join(dir, "tmp"); const skillsPluginDir = path.join(dir, "claude-skills-plugin"); @@ -1437,16 +1135,8 @@ describe("prepareCliRunContext", () => { try { await expect( - prepareCliRunContext({ - sessionId: "session-test", + fixture.prepare({ sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-mid-prepare-cleanup", config: createCliBackendConfig({ bundleMcp: true }), }), ).rejects.toThrow("reference path lookup failed"); @@ -1458,13 +1148,11 @@ describe("prepareCliRunContext", () => { ).toEqual([]); } finally { tempEnvSnapshot.restore(); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("prepares side questions without agent-turn context, tools, hooks, or reusable sessions", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -1482,27 +1170,22 @@ describe("prepareCliRunContext", () => { cleanup: vi.fn(async () => undefined), })); const prepareExecution = vi.fn(async () => undefined); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "always-on", - sideQuestionToolMode: "disabled", - prepareExecution, - config: { - command: "test-cli", - args: ["--print"], - liveSession: "claude-stdio", - sessionMode: "always", - output: "jsonl", - input: "stdin", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "always-on", + sideQuestionToolMode: "disabled", + prepareExecution, + config: { + command: "test-cli", + args: ["--print"], + liveSession: "claude-stdio", + sessionMode: "always", + output: "jsonl", + input: "stdin", + }, }); setCliRunnerPrepareTestDeps({ resolveBootstrapContextForRun, @@ -1527,18 +1210,12 @@ describe("prepareCliRunContext", () => { resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: "docs", sourcePath: "src" })), }); - const context = await prepareCliRunContext({ - sessionId: "session-test", + const context = await fixture.prepare({ sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, config: createCliBackendConfig({ bundleMcp: true }), prompt: "side question prompt", executionMode: "side-question", - provider: "test-cli", - model: "test-model", timeoutMs: 120_000, - runId: "run-side-question", extraSystemPrompt: "BTW system prompt", disableTools: true, cliSessionId: "existing-cli-session", @@ -1585,30 +1262,25 @@ describe("prepareCliRunContext", () => { expectedText: undefined, }, ])("renders $name", async ({ nativeToolMode, transportsSystemPrompt, expectedText }) => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const bootstrapPath = path.join(dir, "BOOTSTRAP.md"); const config = { agents: { defaults: { workspace: dir } }, } satisfies OpenClawConfig; - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test", - bundleMcp: false, - nativeToolMode, - config: { - command: "test-cli", - args: ["--print"], - ...(transportsSystemPrompt ? { systemPromptArg: "--system-prompt" } : {}), - systemPromptWhen: "first", - sessionMode: "existing", - output: "text", - input: "arg", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test", + bundleMcp: false, + nativeToolMode, + config: { + command: "test-cli", + args: ["--print"], + ...(transportsSystemPrompt ? { systemPromptArg: "--system-prompt" } : {}), + systemPromptWhen: "first", + sessionMode: "existing", + output: "text", + input: "arg", + }, }); setCliRunnerPrepareTestDeps({ isWorkspaceBootstrapPending: vi.fn(async () => true), @@ -1630,76 +1302,121 @@ describe("prepareCliRunContext", () => { })), }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - config, - prompt: "Hello", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: `run-bootstrap-${nativeToolMode ?? "limited"}`, - trigger: "user", - extraSystemPrompt: "stable prompt", - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: hashCliSessionText("stable prompt"), - cwdHash: hashCliSessionText(dir), - }, - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:main", + config, + prompt: "Hello", + runId: `run-bootstrap-${nativeToolMode ?? "limited"}`, + trigger: "user", + extraSystemPrompt: "stable prompt", + cliSessionBinding: { + sessionId: "cli-session", + extraSystemPromptHash: hashCliSessionText("stable prompt"), + cwdHash: hashCliSessionText(dir), + }, + }); - if (expectedText) { - expect(context.systemPrompt).toContain("## Bootstrap Pending"); - expect(context.systemPrompt).toContain(expectedText); - if (nativeToolMode === "always-on") { - expect(context.systemPrompt).toContain("## " + bootstrapPath); - expect(context.systemPrompt).toContain("Complete the first-run ritual"); - expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([ - expect.objectContaining({ - name: "BOOTSTRAP.md", - injectedChars: expect.any(Number), - truncated: false, - }), - ]); - } else { - expect(context.systemPrompt).not.toContain("## " + bootstrapPath); - expect(context.systemPrompt).not.toContain("Complete the first-run ritual"); - expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([]); - } - expect(context.reusableCliSession).toEqual({ - mode: "reuse-with-drift", - sessionId: "cli-session", - drift: { reasons: ["system-prompt"] }, - }); + if (expectedText) { + expect(context.systemPrompt).toContain("## Bootstrap Pending"); + expect(context.systemPrompt).toContain(expectedText); + if (nativeToolMode === "always-on") { + expect(context.systemPrompt).toContain("## " + bootstrapPath); + expect(context.systemPrompt).toContain("Complete the first-run ritual"); + expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([ + expect.objectContaining({ + name: "BOOTSTRAP.md", + injectedChars: expect.any(Number), + truncated: false, + }), + ]); } else { - expect(context.systemPrompt).not.toContain("## Bootstrap Pending"); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", - sessionId: "cli-session", - }); + expect(context.systemPrompt).not.toContain("## " + bootstrapPath); + expect(context.systemPrompt).not.toContain("Complete the first-run ritual"); + expect(context.systemPromptReport.injectedWorkspaceFiles).toEqual([]); } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); + expect(context.reusableCliSession).toEqual({ + mode: "reuse-with-drift", + sessionId: "cli-session", + drift: { reasons: ["system-prompt"] }, + }); + } else { + expect(context.systemPrompt).not.toContain("## Bootstrap Pending"); + expect(context.reusableCliSession).toEqual({ + mode: "reuse", + sessionId: "cli-session", + }); } }); it("applies prompt-build hook context to Claude-style CLI preparation", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - appendTranscriptEntry(sessionFile, { - id: "msg-1", - parentId: null, - timestamp: new Date(1).toISOString(), - message: { role: "user", content: "earlier context", timestamp: 1 }, - }); - appendTranscriptEntry(sessionFile, { - id: "msg-2", - parentId: "msg-1", - timestamp: new Date(2).toISOString(), - message: { + const { dir } = fixture.session; + fixture.appendTranscript({ + id: "msg-1", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { role: "user", content: "earlier context", timestamp: 1 }, + }); + fixture.appendTranscript({ + id: "msg-2", + parentId: "msg-1", + timestamp: new Date(2).toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: "earlier reply" }], + api: "responses", + provider: "test-cli", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 2, + }, + }); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async ({ messages }: { messages: unknown[] }) => ({ + prependContext: `history:${messages.length}`, + systemPrompt: "hook system", + prependSystemContext: "prepend system", + appendSystemContext: "append system", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + + // The hook receives historical messages, while the final prompt receives + // only the hook-approved prepend context plus the latest user prompt. + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + runId: "run-test", + messageChannel: "telegram", + messageProvider: "acp", + config: { + ...createCliBackendConfig(), + }, + }); + + expect(context.params.prompt).toBe("history:2\n\nlatest ask"); + expect(context.contextEngineTurnPrompt).toBe("latest ask"); + expect(context.systemPrompt).toBe( + `${wrappedPluginSystemContext("prepend system")}\n\nhook system\n\n${wrappedPluginSystemContext("append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, + ); + expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1); + const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< + [unknown, unknown] + >; + expect(beforePromptBuildCalls[0]?.[0]).toEqual({ + prompt: "latest ask", + messages: [ + { role: "user", content: "earlier context", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "earlier reply" }], api: "responses", @@ -1716,153 +1433,73 @@ describe("prepareCliRunContext", () => { stopReason: "stop", timestamp: 2, }, - }); - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), - runBeforePromptBuild: vi.fn(async ({ messages }: { messages: unknown[] }) => ({ - prependContext: `history:${messages.length}`, - systemPrompt: "hook system", - prependSystemContext: "prepend system", - appendSystemContext: "append system", - })), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - - // The hook receives historical messages, while the final prompt receives - // only the hook-approved prepend context plus the latest user prompt. - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - agentId: "main", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test", - messageChannel: "telegram", - messageProvider: "acp", - config: { - ...createCliBackendConfig(), - }, - }); - - expect(context.params.prompt).toBe("history:2\n\nlatest ask"); - expect(context.contextEngineTurnPrompt).toBe("latest ask"); - expect(context.systemPrompt).toBe( - `${wrappedPluginSystemContext("prepend system")}\n\nhook system\n\n${wrappedPluginSystemContext("append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, - ); - expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1); - const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< - [unknown, unknown] - >; - expect(beforePromptBuildCalls[0]?.[0]).toEqual({ - prompt: "latest ask", - messages: [ - { role: "user", content: "earlier context", timestamp: 1 }, - { - role: "assistant", - content: [{ type: "text", text: "earlier reply" }], - api: "responses", - provider: "test-cli", - model: "test-model", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: 2, - }, - ], - }); - const hookContext = beforePromptBuildCalls[0]?.[1] as - | { - runId?: string; - agentId?: string; - sessionKey?: string; - sessionId?: string; - workspaceDir?: string; - modelProviderId?: string; - modelId?: string; - messageProvider?: string; - trigger?: string; - channelId?: string; - } - | undefined; - expect(hookContext?.runId).toBe("run-test"); - expect(hookContext?.agentId).toBe("main"); - expect(hookContext?.sessionKey).toBe("agent:main:test"); - expect(hookContext?.sessionId).toBe("session-test"); - expect(hookContext?.workspaceDir).toBe(dir); - expect(hookContext?.modelProviderId).toBe("test-cli"); - expect(hookContext?.modelId).toBe("test-model"); - expect(hookContext?.messageProvider).toBe("acp"); - expect(hookContext?.trigger).toBe("user"); - expect(hookContext?.channelId).toBe("telegram"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + ], + }); + const hookContext = beforePromptBuildCalls[0]?.[1] as + | { + runId?: string; + agentId?: string; + sessionKey?: string; + sessionId?: string; + workspaceDir?: string; + modelProviderId?: string; + modelId?: string; + messageProvider?: string; + trigger?: string; + channelId?: string; + } + | undefined; + expect(hookContext?.runId).toBe("run-test"); + expect(hookContext?.agentId).toBe("main"); + expect(hookContext?.sessionKey).toBe("agent:main:test"); + expect(hookContext?.sessionId).toBe("session-test"); + expect(hookContext?.workspaceDir).toBe(dir); + expect(hookContext?.modelProviderId).toBe("test-cli"); + expect(hookContext?.modelId).toBe("test-model"); + expect(hookContext?.messageProvider).toBe("acp"); + expect(hookContext?.trigger).toBe("user"); + expect(hookContext?.channelId).toBe("telegram"); }); it("prepends current-turn context after prompt-build hooks without changing hook or transcript prompt", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), - runBeforePromptBuild: vi.fn(async () => ({ - prependContext: "trusted hook context", - appendContext: "trusted hook tail", - })), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async () => ({ + prependContext: "trusted hook context", + appendContext: "trusted hook tail", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - // Current inbound metadata is untrusted channel context. It should shape - // the CLI prompt without contaminating transcript or hook inputs. - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - agentId: "main", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - transcriptPrompt: "latest ask", - currentInboundContext: { - text: "Sender (untrusted metadata):\nsender_id=U123", - promptJoiner: " ", - }, - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-context", - config: createCliBackendConfig(), - }); + // Current inbound metadata is untrusted channel context. It should shape + // the CLI prompt without contaminating transcript or hook inputs. + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + transcriptPrompt: "latest ask", + currentInboundContext: { + text: "Sender (untrusted metadata):\nsender_id=U123", + promptJoiner: " ", + }, + runId: "run-test-context", + }); - expect(context.params.prompt).toBe( - "Sender (untrusted metadata):\nsender_id=U123 trusted hook context\n\nlatest ask\n\ntrusted hook tail", - ); - expect(context.params.transcriptPrompt).toBe("latest ask"); - expect(context.contextEngineTurnPrompt).toBe("latest ask"); - expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1); - const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< - [unknown, unknown] - >; - const promptBuildParams = beforePromptBuildCalls[0]?.[0] as { prompt?: string } | undefined; - expect(promptBuildParams?.prompt).toBe("latest ask"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.prompt).toBe( + "Sender (untrusted metadata):\nsender_id=U123 trusted hook context\n\nlatest ask\n\ntrusted hook tail", + ); + expect(context.params.transcriptPrompt).toBe("latest ask"); + expect(context.contextEngineTurnPrompt).toBe("latest ask"); + expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledTimes(1); + const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< + [unknown, unknown] + >; + const promptBuildParams = beforePromptBuildCalls[0]?.[0] as { prompt?: string } | undefined; + expect(promptBuildParams?.prompt).toBe("latest ask"); }); it("uses compact current-turn context when a room event resumes a CLI session", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -1872,231 +1509,163 @@ describe("prepareCliRunContext", () => { timestamp: 1, }, }); - try { - // Room resumes carry compact event text into the CLI prompt but keep the - // richer room context in OpenClaw history for reseed and audits. - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - agentId: "main", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "[OpenClaw room event]", - currentInboundEventKind: "room_event", - currentInboundContext: { - text: "Room context:\nAlice: lunch?\n\nCurrent event:\nBob: yes", - resumableText: "Current event:\nBob: yes", - }, - cliSessionBinding: { - sessionId: "cli-session", - }, - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-resumable-context", - config: createCliBackendConfig({ - reseedFromRawTranscriptWhenUncompacted: true, - }), - }); + // Room resumes carry compact event text into the CLI prompt but keep the + // richer room context in OpenClaw history for reseed and audits. + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + prompt: "[OpenClaw room event]", + currentInboundEventKind: "room_event", + currentInboundContext: { + text: "Room context:\nAlice: lunch?\n\nCurrent event:\nBob: yes", + resumableText: "Current event:\nBob: yes", + }, + cliSessionBinding: { + sessionId: "cli-session", + }, + config: createCliBackendConfig({ + reseedFromRawTranscriptWhenUncompacted: true, + }), + }); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - expect(context.params.prompt).toBe("Current event:\nBob: yes\n\n[OpenClaw room event]"); - expect(context.openClawHistoryPrompt).toContain("Room context:\nAlice: lunch?"); - expect(context.openClawHistoryPrompt).toContain("Current event:\nBob: yes"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); + expect(context.params.prompt).toBe("Current event:\nBob: yes\n\n[OpenClaw room event]"); + expect(context.openClawHistoryPrompt).toContain("Room context:\nAlice: lunch?"); + expect(context.openClawHistoryPrompt).toContain("Current event:\nBob: yes"); }); it("marks inter-session prompts after CLI prompt-build hook context is applied", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), - runBeforePromptBuild: vi.fn(async () => ({ - prependContext: "trusted hook context", - })), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async () => ({ + prependContext: "trusted hook context", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - agentId: "main", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "foreign reply text", - inputProvenance: { - kind: "inter_session", - sourceSessionKey: "agent:main:slack:dm:U123", - sourceChannel: "slack", - sourceTool: "sessions_send", - }, - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + prompt: "foreign reply text", + inputProvenance: { + kind: "inter_session", + sourceSessionKey: "agent:main:slack:dm:U123", + sourceChannel: "slack", + sourceTool: "sessions_send", + }, + runId: "run-test", + }); - expect(context.params.prompt).toMatch(/^\[Inter-session message/); - expect(context.params.prompt).toContain("sourceSession=agent:main:slack:dm:U123"); - expect(context.params.prompt).toContain("isUser=false"); - expect(context.params.prompt).toContain("trusted hook context"); - expect(context.params.prompt).toContain("foreign reply text"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.prompt).toMatch(/^\[Inter-session message/); + expect(context.params.prompt).toContain("sourceSession=agent:main:slack:dm:U123"); + expect(context.params.prompt).toContain("isUser=false"); + expect(context.params.prompt).toContain("trusted hook context"); + expect(context.params.prompt).toContain("foreign reply text"); }); it("applies agent_turn_prepare-only context on the CLI path", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "agent_turn_prepare"), - runAgentTurnPrepare: vi.fn(async () => ({ - prependContext: "turn prepend", - appendContext: "turn append", - })), - runBeforePromptBuild: vi.fn(), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "agent_turn_prepare"), + runAgentTurnPrepare: vi.fn(async () => ({ + prependContext: "turn prepend", + appendContext: "turn append", + })), + runBeforePromptBuild: vi.fn(), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - agentId: "main", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-turn-prepare", - messageChannel: "telegram", - currentChannelId: "chat-1", - senderId: "user-456", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + agentId: "main", + trigger: "user", + runId: "run-test-turn-prepare", + messageChannel: "telegram", + currentChannelId: "chat-1", + senderId: "user-456", + }); - expect(context.params.prompt).toBe("turn prepend\n\nlatest ask\n\nturn append"); - expect(hookRunner.runAgentTurnPrepare).toHaveBeenCalledTimes(1); - const agentTurnPrepareCalls = hookRunner.runAgentTurnPrepare.mock.calls as unknown as Array< - [unknown, unknown] - >; - expect(agentTurnPrepareCalls[0]?.[0]).toEqual({ - prompt: "latest ask", - messages: [], - queuedInjections: [], - }); - const turnPrepareContext = agentTurnPrepareCalls[0]?.[1] as - | { - channel?: string; - chatId?: string; - runId?: string; - senderId?: string; - sessionKey?: string; - } - | undefined; - expect(turnPrepareContext?.runId).toBe("run-test-turn-prepare"); - expect(turnPrepareContext?.sessionKey).toBe("agent:main:test"); - expect(turnPrepareContext?.channel).toBe("telegram"); - expect(turnPrepareContext?.chatId).toBe("chat-1"); - expect(turnPrepareContext?.senderId).toBe("user-456"); - expect(hookRunner.runBeforePromptBuild).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.prompt).toBe("turn prepend\n\nlatest ask\n\nturn append"); + expect(hookRunner.runAgentTurnPrepare).toHaveBeenCalledTimes(1); + const agentTurnPrepareCalls = hookRunner.runAgentTurnPrepare.mock.calls as unknown as Array< + [unknown, unknown] + >; + expect(agentTurnPrepareCalls[0]?.[0]).toEqual({ + prompt: "latest ask", + messages: [], + queuedInjections: [], + }); + const turnPrepareContext = agentTurnPrepareCalls[0]?.[1] as + | { + channel?: string; + chatId?: string; + runId?: string; + senderId?: string; + sessionKey?: string; + } + | undefined; + expect(turnPrepareContext?.runId).toBe("run-test-turn-prepare"); + expect(turnPrepareContext?.sessionKey).toBe("agent:main:test"); + expect(turnPrepareContext?.channel).toBe("telegram"); + expect(turnPrepareContext?.chatId).toBe("chat-1"); + expect(turnPrepareContext?.senderId).toBe("user-456"); + expect(hookRunner.runBeforePromptBuild).not.toHaveBeenCalled(); }); it("applies before_prompt_build hook context for CLI preparation", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const hookRunner = { - hasHooks: vi.fn((_hookName: string) => true), - runBeforePromptBuild: vi.fn(async () => ({ - prependContext: "prompt prepend", - systemPrompt: "prompt system", - prependSystemContext: "prompt prepend system", - appendSystemContext: "prompt append system", - })), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const hookRunner = { + hasHooks: vi.fn((_hookName: string) => true), + runBeforePromptBuild: vi.fn(async () => ({ + prependContext: "prompt prepend", + systemPrompt: "prompt system", + prependSystemContext: "prompt prepend system", + appendSystemContext: "prompt append system", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-prompt-build", - messageChannel: "discord", - currentChannelId: "channel:room-1", - senderId: "user-789", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + messageChannel: "discord", + currentChannelId: "channel:room-1", + senderId: "user-789", + }); - expect(context.params.prompt).toBe("prompt prepend\n\nlatest ask"); - expect(context.systemPrompt).toBe( - `${wrappedPluginSystemContext("prompt prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, - ); - expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce(); - const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< - [unknown, unknown] - >; - const promptContext = beforePromptBuildCalls[0]?.[1] as - | { channel?: string; chatId?: string; senderId?: string } - | undefined; - expect(promptContext?.channel).toBe("discord"); - expect(promptContext?.chatId).toBe("room-1"); - expect(promptContext?.senderId).toBe("user-789"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.prompt).toBe("prompt prepend\n\nlatest ask"); + expect(context.systemPrompt).toBe( + `${wrappedPluginSystemContext("prompt prepend system")}\n\nprompt system\n\n${wrappedPluginSystemContext("prompt append system")}${SYSTEM_PROMPT_CACHE_BOUNDARY}\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, + ); + expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce(); + const beforePromptBuildCalls = hookRunner.runBeforePromptBuild.mock.calls as unknown as Array< + [unknown, unknown] + >; + const promptContext = beforePromptBuildCalls[0]?.[1] as + | { channel?: string; chatId?: string; senderId?: string } + | undefined; + expect(promptContext?.channel).toBe("discord"); + expect(promptContext?.chatId).toBe("room-1"); + expect(promptContext?.senderId).toBe("user-789"); }); it("preserves the base prompt when prompt-build hooks fail", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), - runBeforePromptBuild: vi.fn(async () => { - throw new Error("hook exploded"); - }), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async () => { + throw new Error("hook exploded"); + }), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-hook-failure", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({}); - expect(context.params.prompt).toBe("latest ask"); - expect(context.systemPrompt).toContain( - "You are a personal assistant running inside OpenClaw.", - ); - expect(context.systemPrompt).toContain("Current model identity: test-cli/test-model."); - expect(context.systemPrompt).not.toContain("hook exploded"); - expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.prompt).toBe("latest ask"); + expect(context.systemPrompt).toContain("You are a personal assistant running inside OpenClaw."); + expect(context.systemPrompt).toContain("Current model identity: test-cli/test-model."); + expect(context.systemPrompt).not.toContain("hook exploded"); + expect(hookRunner.runBeforePromptBuild).toHaveBeenCalledOnce(); }); it("does not allocate a non-legacy context engine before fallible CLI preparation finishes", async () => { - const { dir, sessionFile } = createSessionFile(); const engineId = `cli-prepare-late-engine-${Date.now().toString(36)}`; const dispose = vi.fn(async () => {}); const factory = vi.fn((): ContextEngine => { @@ -2115,33 +1684,20 @@ describe("prepareCliRunContext", () => { }), }); - try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-prepare-failure", - config: { - ...createCliBackendConfig(), - plugins: { slots: { contextEngine: engineId } }, - }, - }), - ).rejects.toThrow("reference path lookup failed"); + await expect( + fixture.prepare({ + config: { + ...createCliBackendConfig(), + plugins: { slots: { contextEngine: engineId } }, + }, + }), + ).rejects.toThrow("reference path lookup failed"); - expect(factory).not.toHaveBeenCalled(); - expect(dispose).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(factory).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); }); it("cleans up prepared CLI backend when context-engine resolution fails", async () => { - const { dir, sessionFile } = createSessionFile(); const cleanup = vi.fn(async () => {}); const prepareExecution = vi.fn(async () => ({ cleanup })); registerContextEngineForOwner( @@ -2152,52 +1708,33 @@ describe("prepareCliRunContext", () => { "core", { allowSameOwnerRefresh: true }, ); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test-plugin", - bundleMcp: false, - prepareExecution, - config: { - command: "test-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - sessionMode: "existing", - output: "text", - input: "arg", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test-plugin", + bundleMcp: false, + prepareExecution, + config: { + command: "test-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + sessionMode: "existing", + output: "text", + input: "arg", + }, }); try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-context-engine-resolution-failure", - config: createCliBackendConfig(), - }), - ).rejects.toThrow("context engine failed"); + await expect(fixture.prepare({})).rejects.toThrow("context engine failed"); expect(prepareExecution).toHaveBeenCalledOnce(); expect(cleanup).toHaveBeenCalledOnce(); } finally { registerLegacyContextEngine(); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("rejects CLI runs for context engines that require pre-prompt assembly", async () => { - const { dir, sessionFile } = createSessionFile(); const engineId = `cli-unsupported-engine-${Date.now().toString(36)}`; registerTestContextEngine(engineId, (): ContextEngine => { return { @@ -2217,32 +1754,20 @@ describe("prepareCliRunContext", () => { }; }); - try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-context-engine-host-compat", - config: { - ...createCliBackendConfig(), - plugins: { slots: { contextEngine: engineId } }, - }, - }), - ).rejects.toThrow( - `Context engine "${engineId}" cannot run operation "agent-run" on CLI backend "test-cli".`, - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + await expect( + fixture.prepare({ + config: { + ...createCliBackendConfig(), + plugins: { slots: { contextEngine: engineId } }, + }, + }), + ).rejects.toThrow( + `Context engine "${engineId}" cannot run operation "agent-run" on CLI backend "test-cli".`, + ); }); it("uses runtime config when resolving the CLI context engine", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const engineId = `cli-runtime-config-engine-${Date.now().toString(36)}`; const runtimeAgentDir = path.join(dir, "runtime-agent"); const runtimeConfig = { @@ -2261,164 +1786,98 @@ describe("prepareCliRunContext", () => { }); registerTestContextEngine(engineId, factory); getRuntimeConfigMock.mockReturnValue(runtimeConfig); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "test-cli", - pluginId: "test-plugin", - bundleMcp: false, - config: { - command: "test-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - sessionMode: "existing", - output: "text", - input: "arg", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "test-cli", + pluginId: "test-plugin", + bundleMcp: false, + config: { + command: "test-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + sessionMode: "existing", + output: "text", + input: "arg", + }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-runtime-config-context-engine", - }); + const context = await fixture.prepare({ + config: undefined, + }); - expect(context.contextEngine?.info.id).toBe(engineId); - expect(context.contextEngineConfig).toBe(runtimeConfig); - expect(context.params.config).toBe(runtimeConfig); - expect(factory).toHaveBeenCalledWith( - expect.objectContaining({ - agentDir: runtimeAgentDir, - config: runtimeConfig, - workspaceDir: dir, - }), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.contextEngine?.info.id).toBe(engineId); + expect(context.contextEngineConfig).toBe(runtimeConfig); + expect(context.params.config).toBe(runtimeConfig); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: runtimeAgentDir, + config: runtimeConfig, + workspaceDir: dir, + }), + ); }); it("uses explicit static prompt text for CLI session reuse hashing", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-static-prompt", - extraSystemPrompt: "## Inbound Context\nchannel=telegram", - extraSystemPromptStatic: "", - cliSessionBinding: { - sessionId: "cli-session", - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig(), - }); + const { dir } = fixture.session; + const context = await fixture.prepare({ + extraSystemPrompt: "## Inbound Context\nchannel=telegram", + extraSystemPromptStatic: "", + cliSessionBinding: { + sessionId: "cli-session", + cwdHash: hashCliSessionText(dir), + }, + }); - expect(context.systemPrompt).toContain("## Inbound Context\nchannel=telegram"); - expect(context.extraSystemPromptHash).toBeUndefined(); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.systemPrompt).toContain("## Inbound Context\nchannel=telegram"); + expect(context.extraSystemPromptHash).toBeUndefined(); + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); }); it("invalidates CLI session reuse when explicit message-target policy changes", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-message-policy", - sourceReplyDeliveryMode: "message_tool_only", - requireExplicitMessageTarget: true, - cliSessionBinding: { - sessionId: "cli-session", - messageToolPolicyHash: hashCliSessionText( - JSON.stringify({ - sourceReplyDeliveryMode: "message_tool_only", - requireExplicitMessageTarget: false, - }), - ), - }, - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sourceReplyDeliveryMode: "message_tool_only", + requireExplicitMessageTarget: true, + cliSessionBinding: { + sessionId: "cli-session", + messageToolPolicyHash: hashCliSessionText( + JSON.stringify({ + sourceReplyDeliveryMode: "message_tool_only", + requireExplicitMessageTarget: false, + }), + ), + }, + }); - expect(context.messageToolPolicyHash).toBeDefined(); - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "message-policy", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.messageToolPolicyHash).toBeDefined(); + expect(context.reusableCliSession).toEqual({ + mode: "invalidate", + invalidatedReason: "message-policy", + }); }); it("requires explicit message targets by default for CLI subagents", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:subagent:child", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-subagent-message-policy", - sourceReplyDeliveryMode: "message_tool_only", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:subagent:child", + sourceReplyDeliveryMode: "message_tool_only", + }); - expect(context.params.requireExplicitMessageTarget).toBe(true); - expect(context.messageToolPolicyHash).toBe( - hashCliSessionText( - JSON.stringify({ - sourceReplyDeliveryMode: "message_tool_only", - requireExplicitMessageTarget: true, - }), - ), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.params.requireExplicitMessageTarget).toBe(true); + expect(context.messageToolPolicyHash).toBe( + hashCliSessionText( + JSON.stringify({ + sourceReplyDeliveryMode: "message_tool_only", + requireExplicitMessageTarget: true, + }), + ), + ); }); it("uses cwd for CLI system prompt workspace guidance", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-")); try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, + const context = await fixture.prepare({ cwd: taskDir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-cwd-prompt", - config: createCliBackendConfig(), }); expect(context.cwd).toBe(taskDir); @@ -2426,12 +1885,10 @@ describe("prepareCliRunContext", () => { expect(context.systemPrompt).not.toContain(`Working directory: ${dir}`); } finally { fs.rmSync(taskDir, { recursive: true, force: true }); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("passes Telegram channel context into CLI system prompts without core rich guidance", async () => { - const { dir, sessionFile } = createSessionFile(); setActivePluginRegistry( createTestRegistry([ { @@ -2447,132 +1904,81 @@ describe("prepareCliRunContext", () => { ]), ); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-telegram-channel", - messageChannel: "telegram", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + messageChannel: "telegram", + }); - expect(context.systemPrompt).toContain("channel=telegram"); - expect(context.systemPrompt).not.toContain("Telegram rich ON"); - expect(context.systemPrompt).not.toContain("Telegram rich OFF"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.systemPrompt).toContain("channel=telegram"); + expect(context.systemPrompt).not.toContain("Telegram rich ON"); + expect(context.systemPrompt).not.toContain("Telegram rich OFF"); }); it("ignores volatile prompt text when static prompt text matches", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const staticPrompt = "## Direct Context\nYou are in a Telegram direct conversation."; - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-volatile-prompt", - extraSystemPrompt: `## Inbound Context\nchannel=heartbeat\n\n${staticPrompt}`, - extraSystemPromptStatic: staticPrompt, - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: hashCliSessionText(staticPrompt), - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig(), - }); + const { dir } = fixture.session; + const staticPrompt = "## Direct Context\nYou are in a Telegram direct conversation."; + const context = await fixture.prepare({ + extraSystemPrompt: `## Inbound Context\nchannel=heartbeat\n\n${staticPrompt}`, + extraSystemPromptStatic: staticPrompt, + cliSessionBinding: { + sessionId: "cli-session", + extraSystemPromptHash: hashCliSessionText(staticPrompt), + cwdHash: hashCliSessionText(dir), + }, + }); - expect(context.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt)); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt)); + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); }); it("soft-resumes content drift and surfaces a per-turn drift note", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - currentInboundContext: { - text: "Conversation info (untrusted metadata):\nchannel=telegram", - }, - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-soft-resume-drift-note", - extraSystemPrompt: "new stable prompt", - extraSystemPromptStatic: "new stable prompt", - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: hashCliSessionText("old stable prompt"), - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig(), - }); - - expect(context.reusableCliSession).toEqual({ - mode: "reuse-with-drift", + const { dir } = fixture.session; + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + currentInboundContext: { + text: "Conversation info (untrusted metadata):\nchannel=telegram", + }, + extraSystemPrompt: "new stable prompt", + extraSystemPromptStatic: "new stable prompt", + cliSessionBinding: { sessionId: "cli-session", - drift: { reasons: ["system-prompt"] }, - }); - expect(context.openClawHistoryPrompt).toBeUndefined(); - expect(context.params.prompt).toContain( - "OpenClaw resumed this CLI session after prompt content changed.", - ); - expect(context.params.prompt).toContain("changed=system-prompt"); - expect(context.params.prompt).toContain("latest ask"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + extraSystemPromptHash: hashCliSessionText("old stable prompt"), + cwdHash: hashCliSessionText(dir), + }, + }); + + expect(context.reusableCliSession).toEqual({ + mode: "reuse-with-drift", + sessionId: "cli-session", + drift: { reasons: ["system-prompt"] }, + }); + expect(context.openClawHistoryPrompt).toBeUndefined(); + expect(context.params.prompt).toContain( + "OpenClaw resumed this CLI session after prompt content changed.", + ); + expect(context.params.prompt).toContain("changed=system-prompt"); + expect(context.params.prompt).toContain("latest ask"); }); it("invalidates content drift when the backend cannot receive a resumed system prompt", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-soft-resume-unsupported-backend", - extraSystemPrompt: "new stable prompt", - extraSystemPromptStatic: "new stable prompt", - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: hashCliSessionText("old stable prompt"), - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig({ systemPromptWhen: "never" }), - }); + const { dir } = fixture.session; + const context = await fixture.prepare({ + extraSystemPrompt: "new stable prompt", + extraSystemPromptStatic: "new stable prompt", + cliSessionBinding: { + sessionId: "cli-session", + extraSystemPromptHash: hashCliSessionText("old stable prompt"), + cwdHash: hashCliSessionText(dir), + }, + config: createCliBackendConfig({ systemPromptWhen: "never" }), + }); - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "system-prompt", - }); - expect(context.params.prompt).not.toContain( - "OpenClaw resumed this CLI session after prompt content changed.", - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.reusableCliSession).toEqual({ + mode: "invalidate", + invalidatedReason: "system-prompt", + }); + expect(context.params.prompt).not.toContain( + "OpenClaw resumed this CLI session after prompt content changed.", + ); }); it.each([ @@ -2591,7 +1997,7 @@ describe("prepareCliRunContext", () => { ] as const)( "reuses CLI session bindings across new inbound messages with stable binding facts for $name", async ({ stableMode, staticPrompt, expectedStrongPrompt }) => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; try { const getActiveMcpLoopbackRuntime = vi.fn(() => ({ port: 31783, @@ -2618,32 +2024,17 @@ describe("prepareCliRunContext", () => { extraSystemPromptStatic: staticPrompt, sourceReplyDeliveryMode: stableMode, }; - const first = await prepareCliRunContext({ - sessionId: "session-test", + const first = await fixture.prepare({ sessionKey: "main", - sessionFile, - workspaceDir: dir, prompt: "first ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-stable-binding-facts-a", extraSystemPrompt: `volatile msg-1\n\n${staticPrompt}`, sourceReplyDeliveryMode: "message_tool_only", currentMessageId: "msg-1", cliSessionBindingFacts, - config: createCliBackendConfig(), }); - const second = await prepareCliRunContext({ - sessionId: "session-test", + const second = await fixture.prepare({ sessionKey: "main", - sessionFile, - workspaceDir: dir, prompt: "second ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-stable-binding-facts-b", extraSystemPrompt: `volatile msg-2\n\n${staticPrompt}`, sourceReplyDeliveryMode: stableMode, currentMessageId: "msg-2", @@ -2655,7 +2046,6 @@ describe("prepareCliRunContext", () => { promptToolNamesHash: first.promptToolNamesHash, cwdHash: hashCliSessionText(dir), }, - config: createCliBackendConfig(), }); expect(first.extraSystemPromptHash).toBe(hashCliSessionText(staticPrompt)); @@ -2683,228 +2073,186 @@ describe("prepareCliRunContext", () => { ); it("reuses CLI session bindings across explicit mention toggles with stable group prompt facts", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const baseGroupCtx = { - ChatType: "group", - Provider: "telegram", - BotUsername: "SirPinchALotBot", - } as const; - const mentionedStaticPrompt = [ - buildGroupChatContext({ - sessionCtx: { - ...baseGroupCtx, - ExplicitlyMentionedBot: true, - }, - sourceReplyDeliveryMode: "automatic", - silentReplyPolicy: "allow", - silentToken: "NO_REPLY", - }), - buildGroupIntro({ - defaultActivation: "mention", - }), - ].join("\n\n"); - const unmentionedStaticPrompt = [ - buildGroupChatContext({ - sessionCtx: { - ...baseGroupCtx, - ExplicitlyMentionedBot: false, - }, - sourceReplyDeliveryMode: "automatic", - silentReplyPolicy: "allow", - silentToken: "NO_REPLY", - }), - buildGroupIntro({ - defaultActivation: "mention", - }), - ].join("\n\n"); - expect(unmentionedStaticPrompt).toBe(mentionedStaticPrompt); - - const first = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:group:chat123", - sessionFile, - workspaceDir: dir, - prompt: "first ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-mention-binding-a", - extraSystemPrompt: [ - "The incoming message explicitly mentions your channel identity @SirPinchALotBot.", - mentionedStaticPrompt, - ].join("\n\n"), + const { dir } = fixture.session; + const baseGroupCtx = { + ChatType: "group", + Provider: "telegram", + BotUsername: "SirPinchALotBot", + } as const; + const mentionedStaticPrompt = [ + buildGroupChatContext({ + sessionCtx: { + ...baseGroupCtx, + ExplicitlyMentionedBot: true, + }, sourceReplyDeliveryMode: "automatic", - cliSessionBindingFacts: { - extraSystemPromptStatic: mentionedStaticPrompt, - sourceReplyDeliveryMode: "automatic", + silentReplyPolicy: "allow", + silentToken: "NO_REPLY", + }), + buildGroupIntro({ + defaultActivation: "mention", + }), + ].join("\n\n"); + const unmentionedStaticPrompt = [ + buildGroupChatContext({ + sessionCtx: { + ...baseGroupCtx, + ExplicitlyMentionedBot: false, }, - config: createCliBackendConfig(), - }); - const second = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:group:chat123", - sessionFile, - workspaceDir: dir, - prompt: "second ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-mention-binding-b", - extraSystemPrompt: unmentionedStaticPrompt, sourceReplyDeliveryMode: "automatic", - cliSessionBindingFacts: { - extraSystemPromptStatic: unmentionedStaticPrompt, - sourceReplyDeliveryMode: "automatic", - }, - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: first.extraSystemPromptHash, - messageToolPolicyHash: first.messageToolPolicyHash, - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig(), - }); + silentReplyPolicy: "allow", + silentToken: "NO_REPLY", + }), + buildGroupIntro({ + defaultActivation: "mention", + }), + ].join("\n\n"); + expect(unmentionedStaticPrompt).toBe(mentionedStaticPrompt); - expect(second.extraSystemPromptHash).toBe(first.extraSystemPromptHash); - expect(second.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + const first = await fixture.prepare({ + sessionKey: "agent:main:telegram:group:chat123", + prompt: "first ask", + extraSystemPrompt: [ + "The incoming message explicitly mentions your channel identity @SirPinchALotBot.", + mentionedStaticPrompt, + ].join("\n\n"), + sourceReplyDeliveryMode: "automatic", + cliSessionBindingFacts: { + extraSystemPromptStatic: mentionedStaticPrompt, + sourceReplyDeliveryMode: "automatic", + }, + }); + const second = await fixture.prepare({ + sessionKey: "agent:main:telegram:group:chat123", + prompt: "second ask", + extraSystemPrompt: unmentionedStaticPrompt, + sourceReplyDeliveryMode: "automatic", + cliSessionBindingFacts: { + extraSystemPromptStatic: unmentionedStaticPrompt, + sourceReplyDeliveryMode: "automatic", + }, + cliSessionBinding: { + sessionId: "cli-session", + extraSystemPromptHash: first.extraSystemPromptHash, + messageToolPolicyHash: first.messageToolPolicyHash, + cwdHash: hashCliSessionText(dir), + }, + }); + + expect(second.extraSystemPromptHash).toBe(first.extraSystemPromptHash); + expect(second.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); }); it("invalidates CLI session bindings when owner policy changes prompt tool scope", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - const resolveMcpLoopbackScopedTools = vi.fn((scope: { senderIsOwner?: boolean }) => ({ - agentId: "main", - tools: [ - { - name: "message", - label: "Message", - description: "Send a message", - parameters: { type: "object", properties: {} }, - execute: vi.fn(), - }, - ...(scope.senderIsOwner === false - ? [] - : [ - { - name: "gateway", - label: "Gateway", - description: "Manage the gateway", - parameters: { type: "object", properties: {} }, - execute: vi.fn(), - }, - ]), - ], - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - resolveMcpLoopbackScopedTools, - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "native-cli", - pluginId: "native-plugin", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - config: { - command: "native-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - output: "text", - input: "arg", - sessionMode: "existing", - }, - }, - ], - }); - const cliSessionBindingFacts = { - extraSystemPromptStatic: "group:telegram:group:message_tool_only", - sourceReplyDeliveryMode: "message_tool_only" as const, - }; - const first = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:group:chat123", - sessionFile, - workspaceDir: dir, - prompt: "first ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-owner-tool-scope-a", - extraSystemPrompt: "volatile owner turn", - currentMessageId: "owner-message", - senderIsOwner: true, - cliSessionBindingFacts, - config: createCliBackendConfig({ bundleMcp: true }), - }); - const second = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:group:chat123", - sessionFile, - workspaceDir: dir, - prompt: "second ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-owner-tool-scope-b", - extraSystemPrompt: "volatile non-owner turn", - currentMessageId: "non-owner-message", - senderIsOwner: false, - cliSessionBindingFacts, - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: first.extraSystemPromptHash, - messageToolPolicyHash: first.messageToolPolicyHash, - promptToolNamesHash: first.promptToolNamesHash, - cwdHash: hashCliSessionText(dir), - mcpConfigHash: first.preparedBackend.mcpConfigHash, - mcpResumeHash: first.preparedBackend.mcpResumeHash, + const { dir } = fixture.session; + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + const resolveMcpLoopbackScopedTools = vi.fn((scope: { senderIsOwner?: boolean }) => ({ + agentId: "main", + tools: [ + { + name: "message", + label: "Message", + description: "Send a message", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), }, - config: createCliBackendConfig({ bundleMcp: true }), - }); - - expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledTimes(2); - expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - senderIsOwner: true, - currentMessageId: undefined, - sourceReplyDeliveryMode: "message_tool_only", - }), - ); - expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - senderIsOwner: false, - currentMessageId: undefined, - sourceReplyDeliveryMode: "message_tool_only", - }), - ); - expect(second.promptToolNamesHash).not.toBe(first.promptToolNamesHash); - expect(second.reusableCliSession).toEqual({ - mode: "reuse-with-drift", + ...(scope.senderIsOwner === false + ? [] + : [ + { + name: "gateway", + label: "Gateway", + description: "Manage the gateway", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ]), + ], + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + resolveMcpLoopbackScopedTools, + }); + setRawCliBackendForPrepareTest({ + id: "native-cli", + pluginId: "native-plugin", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + config: { + command: "native-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + output: "text", + input: "arg", + sessionMode: "existing", + }, + }); + const cliSessionBindingFacts = { + extraSystemPromptStatic: "group:telegram:group:message_tool_only", + sourceReplyDeliveryMode: "message_tool_only" as const, + }; + const first = await fixture.prepare({ + sessionKey: "agent:main:telegram:group:chat123", + prompt: "first ask", + provider: "native-cli", + extraSystemPrompt: "volatile owner turn", + currentMessageId: "owner-message", + senderIsOwner: true, + cliSessionBindingFacts, + config: createCliBackendConfig({ bundleMcp: true }), + }); + const second = await fixture.prepare({ + sessionKey: "agent:main:telegram:group:chat123", + prompt: "second ask", + provider: "native-cli", + extraSystemPrompt: "volatile non-owner turn", + currentMessageId: "non-owner-message", + senderIsOwner: false, + cliSessionBindingFacts, + cliSessionBinding: { sessionId: "cli-session", - drift: { reasons: ["prompt-tools"] }, - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + extraSystemPromptHash: first.extraSystemPromptHash, + messageToolPolicyHash: first.messageToolPolicyHash, + promptToolNamesHash: first.promptToolNamesHash, + cwdHash: hashCliSessionText(dir), + mcpConfigHash: first.preparedBackend.mcpConfigHash, + mcpResumeHash: first.preparedBackend.mcpResumeHash, + }, + config: createCliBackendConfig({ bundleMcp: true }), + }); + + expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledTimes(2); + expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + senderIsOwner: true, + currentMessageId: undefined, + sourceReplyDeliveryMode: "message_tool_only", + }), + ); + expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + senderIsOwner: false, + currentMessageId: undefined, + sourceReplyDeliveryMode: "message_tool_only", + }), + ); + expect(second.promptToolNamesHash).not.toBe(first.promptToolNamesHash); + expect(second.reusableCliSession).toEqual({ + mode: "reuse-with-drift", + sessionId: "cli-session", + drift: { reasons: ["prompt-tools"] }, + }); }); it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -2915,42 +2263,30 @@ describe("prepareCliRunContext", () => { }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-raw-reseed-opt-in", - extraSystemPrompt: "changed stable prompt", - extraSystemPromptStatic: "changed stable prompt", - cliSessionBinding: { - sessionId: "cli-session", - extraSystemPromptHash: hashCliSessionText("old stable prompt"), - }, - config: createCliBackendConfig({ - reseedFromRawTranscriptWhenUncompacted: true, - }), - }); - - expect(context.reusableCliSession).toEqual({ - mode: "reuse-with-drift", + const context = await fixture.prepare({ + extraSystemPrompt: "changed stable prompt", + extraSystemPromptStatic: "changed stable prompt", + cliSessionBinding: { sessionId: "cli-session", - drift: { reasons: ["system-prompt"] }, - }); - expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask"); - expect(context.openClawHistoryPrompt).toContain("latest ask"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + extraSystemPromptHash: hashCliSessionText("old stable prompt"), + }, + config: createCliBackendConfig({ + reseedFromRawTranscriptWhenUncompacted: true, + }), + }); + + expect(context.reusableCliSession).toEqual({ + mode: "reuse-with-drift", + sessionId: "cli-session", + drift: { reasons: ["system-prompt"] }, + }); + expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask"); + expect(context.openClawHistoryPrompt).toContain("latest ask"); }); it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + const { dir } = fixture.session; + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -2961,417 +2297,390 @@ describe("prepareCliRunContext", () => { }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-session-expired-reseed-opt-in", - cliSessionBinding: { - sessionId: "cli-session", - cwdHash: hashCliSessionText(dir), - }, - config: createCliBackendConfig({ - reseedFromRawTranscriptWhenUncompacted: true, - }), - }); + const context = await fixture.prepare({ + cliSessionBinding: { + sessionId: "cli-session", + cwdHash: hashCliSessionText(dir), + }, + config: createCliBackendConfig({ + reseedFromRawTranscriptWhenUncompacted: true, + }), + }); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - expect(context.openClawHistoryPrompt).toContain("prior resumable ask"); - expect(context.openClawHistoryPrompt).toContain("latest ask"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); + expect(context.openClawHistoryPrompt).toContain("prior resumable ask"); + expect(context.openClawHistoryPrompt).toContain("latest ask"); }); it("applies direct-run prepend system context helpers on the CLI path", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue( - "active image task", - ); - mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue( - "active video task", - ); - const hookRunner = { - hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), - runBeforePromptBuild: vi.fn(async () => ({ - systemPrompt: "hook system", - prependSystemContext: "hook prepend system", - })), - }; - mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); + mockBuildActiveImageGenerationTaskPromptContextForSession.mockReturnValue("active image task"); + mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue("active video task"); + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_prompt_build"), + runBeforePromptBuild: vi.fn(async () => ({ + systemPrompt: "hook system", + prependSystemContext: "hook prepend system", + })), + }; + mockGetGlobalHookRunner.mockReturnValue(hookRunner as never); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - trigger: "user", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-prepend-helper", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:test", + trigger: "user", + }); - expect(context.systemPrompt).toBe( - `${wrappedPluginSystemContext("hook prepend system")}\n\nhook system${SYSTEM_PROMPT_CACHE_BOUNDARY}active image task\n\nactive video task\n\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, - ); - expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith( - "agent:main:test", - ); - expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith( - "agent:main:test", - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.systemPrompt).toBe( + `${wrappedPluginSystemContext("hook prepend system")}\n\nhook system${SYSTEM_PROMPT_CACHE_BOUNDARY}active image task\n\nactive video task\n\nCurrent model identity: test-cli/test-model. Model question: answer this current-run value.`, + ); + expect(mockBuildActiveImageGenerationTaskPromptContextForSession).toHaveBeenCalledWith( + "agent:main:test", + ); + expect(mockBuildActiveVideoGenerationTaskPromptContextForSession).toHaveBeenCalledWith( + "agent:main:test", + ); }); it("skips bundle MCP preparation when tools are disabled", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - ensureMcpLoopbackServer, - createMcpLoopbackServerConfig, - }); + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); + const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, + createMcpLoopbackServerConfig, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-disable-tools", - config: createCliBackendConfig({ bundleMcp: true }), - disableTools: true, - }); + const context = await fixture.prepare({ + config: createCliBackendConfig({ bundleMcp: true }), + disableTools: true, + }); - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled(); - expect(context.preparedBackend.mcpConfigHash).toBeUndefined(); - expect(context.preparedBackend.env).toBeUndefined(); - expect(context.preparedBackend.backend.args).toEqual(["--print"]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); + expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); + expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled(); + expect(context.preparedBackend.mcpConfigHash).toBeUndefined(); + expect(context.preparedBackend.env).toBeUndefined(); + expect(context.preparedBackend.backend.args).toEqual(["--print"]); }); it("uses loopback-scoped tools when building bundled MCP CLI prompts", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - registerTestMemoryPromptBuilder(({ availableTools }) => - availableTools.has("memory_search") - ? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""] - : [], - ); - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); - const activateMcpLoopbackClientGrantCapture = vi.fn(() => true); - const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true); - const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); - const revokeMcpLoopbackClientGrant = vi.fn(() => true); - const resolveMcpLoopbackScopedTools = vi.fn(() => ({ - agentId: "main", - tools: [ - { - name: "memory_search", - label: "Memory Search", - description: "Search memory", - parameters: { type: "object", properties: {} }, - execute: vi.fn(), - }, - ], - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - ensureMcpLoopbackServer, - createMcpLoopbackServerConfig, - activateMcpLoopbackClientGrantCapture, - deactivateMcpLoopbackClientGrantCapture, - mintMcpLoopbackClientGrant, - revokeMcpLoopbackClientGrant, - resolveMcpLoopbackScopedTools, - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "native-cli", - pluginId: "native-plugin", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - config: { - command: "native-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - output: "text", - input: "arg", - sessionMode: "existing", - }, - }, - ], - }); - const baselineContext = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "main", - agentId: "worker", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-loopback-prompt-tools-baseline", - config: createCliBackendConfig({ bundleMcp: true }), - }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "main", - agentId: "worker", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-loopback-prompt-tools", - config: createCliBackendConfig({ bundleMcp: true }), - cliSessionBinding: { - sessionId: "cli-session", - promptToolNamesHash: "old-tool-surface", - ...(baselineContext.preparedBackend.mcpConfigHash - ? { mcpConfigHash: baselineContext.preparedBackend.mcpConfigHash } - : {}), - ...(baselineContext.preparedBackend.mcpResumeHash - ? { mcpResumeHash: baselineContext.preparedBackend.mcpResumeHash } - : {}), + registerTestMemoryPromptBuilder(({ availableTools }) => + availableTools.has("memory_search") + ? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""] + : [], + ); + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); + const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); + const activateMcpLoopbackClientGrantCapture = vi.fn(() => true); + const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true); + const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); + const revokeMcpLoopbackClientGrant = vi.fn(() => true); + const resolveMcpLoopbackScopedTools = vi.fn(() => ({ + agentId: "main", + tools: [ + { + name: "memory_search", + label: "Memory Search", + description: "Search memory", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), }, - }); - - expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith({ - cfg: expect.any(Object), - sessionKey: "agent:worker:main", - runtimePolicySessionKey: undefined, - agentId: "worker", - messageProvider: undefined, - clientCaps: undefined, - currentChannelId: undefined, - currentThreadTs: undefined, - currentMessageId: undefined, - currentInboundAudio: undefined, - accountId: undefined, - inboundEventKind: undefined, - sourceReplyDeliveryMode: undefined, - taskSuggestionDeliveryMode: undefined, - requireExplicitMessageTarget: false, - senderIsOwner: false, - nodeExecAllowed: true, - modelProvider: "native-cli", - modelId: "test-model", - execSession: undefined, - execOverrides: undefined, - bashElevated: undefined, - trigger: undefined, - approvalReviewerDeviceId: undefined, - channelContext: undefined, - senderName: undefined, - senderUsername: undefined, - senderE164: undefined, - groupId: undefined, - groupChannel: undefined, - groupSpace: undefined, - spawnedBy: undefined, - }); - expect(context.systemPrompt).toContain("## Memory Recall"); - expect(context.systemPrompt).toContain("tools=memory_search"); - expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual([ - "memory_search", - ]); - expect(context.promptToolNamesHash).toBe( - hashCliSessionText(JSON.stringify(["memory_search"])), - ); - expect(context.reusableCliSession).toEqual({ - mode: "reuse-with-drift", + ], + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, + createMcpLoopbackServerConfig, + activateMcpLoopbackClientGrantCapture, + deactivateMcpLoopbackClientGrantCapture, + mintMcpLoopbackClientGrant, + revokeMcpLoopbackClientGrant, + resolveMcpLoopbackScopedTools, + }); + setRawCliBackendForPrepareTest({ + id: "native-cli", + pluginId: "native-plugin", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + config: { + command: "native-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + output: "text", + input: "arg", + sessionMode: "existing", + }, + }); + const baselineContext = await fixture.prepare({ + sessionKey: "main", + agentId: "worker", + provider: "native-cli", + config: createCliBackendConfig({ bundleMcp: true }), + }); + const context = await fixture.prepare({ + sessionKey: "main", + agentId: "worker", + provider: "native-cli", + runId: "run-test-loopback-prompt-tools", + config: createCliBackendConfig({ bundleMcp: true }), + cliSessionBinding: { sessionId: "cli-session", - drift: { reasons: ["prompt-tools"] }, - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + promptToolNamesHash: "old-tool-surface", + ...(baselineContext.preparedBackend.mcpConfigHash + ? { mcpConfigHash: baselineContext.preparedBackend.mcpConfigHash } + : {}), + ...(baselineContext.preparedBackend.mcpResumeHash + ? { mcpResumeHash: baselineContext.preparedBackend.mcpResumeHash } + : {}), + }, + }); + + expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith({ + cfg: expect.any(Object), + sessionKey: "agent:worker:main", + runtimePolicySessionKey: undefined, + agentId: "worker", + messageProvider: undefined, + clientCaps: undefined, + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + currentInboundAudio: undefined, + accountId: undefined, + inboundEventKind: undefined, + sourceReplyDeliveryMode: undefined, + taskSuggestionDeliveryMode: undefined, + requireExplicitMessageTarget: false, + senderIsOwner: false, + nodeExecAllowed: true, + modelProvider: "native-cli", + modelId: "test-model", + execSession: undefined, + execOverrides: undefined, + bashElevated: undefined, + trigger: undefined, + approvalReviewerDeviceId: undefined, + channelContext: undefined, + senderName: undefined, + senderUsername: undefined, + senderE164: undefined, + groupId: undefined, + groupChannel: undefined, + groupSpace: undefined, + spawnedBy: undefined, + }); + expect(context.systemPrompt).toContain("## Memory Recall"); + expect(context.systemPrompt).toContain("tools=memory_search"); + expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual([ + "memory_search", + ]); + expect(context.promptToolNamesHash).toBe(hashCliSessionText(JSON.stringify(["memory_search"]))); + expect(context.reusableCliSession).toEqual({ + mode: "reuse-with-drift", + sessionId: "cli-session", + drift: { reasons: ["prompt-tools"] }, + }); }); it("fails bundled MCP preparation when the loopback runtime is unavailable", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - registerTestMemoryPromptBuilder(({ availableTools }) => - availableTools.has("memory_search") - ? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""] - : [], - ); - const getActiveMcpLoopbackRuntime = vi.fn(() => undefined); - const ensureMcpLoopbackServer = vi.fn(async () => { - throw new Error("loopback unavailable"); - }); - const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); - const resolveMcpLoopbackScopedTools = vi.fn(() => ({ - agentId: "main", - tools: [ - { - name: "memory_search", - label: "Memory Search", - description: "Search memory", - parameters: { type: "object", properties: {} }, - execute: vi.fn(), - }, - ], - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - ensureMcpLoopbackServer, - createMcpLoopbackServerConfig, - resolveMcpLoopbackScopedTools, - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "native-cli", - pluginId: "native-plugin", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - config: { - command: "native-cli", - args: ["--print"], - systemPromptArg: "--system-prompt", - systemPromptWhen: "first", - output: "text", - input: "arg", - sessionMode: "existing", - }, - }, - ], - }); - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-loopback-prompt-tools-fallback", - config: createCliBackendConfig({ bundleMcp: true }), - }), - ).rejects.toThrow(/loopback unavailable/); + registerTestMemoryPromptBuilder(({ availableTools }) => + availableTools.has("memory_search") + ? ["## Memory Recall", `tools=${[...availableTools].toSorted().join(",")}`, ""] + : [], + ); + const getActiveMcpLoopbackRuntime = vi.fn(() => undefined); + const ensureMcpLoopbackServer = vi.fn(async () => { + throw new Error("loopback unavailable"); + }); + const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); + const resolveMcpLoopbackScopedTools = vi.fn(() => ({ + agentId: "main", + tools: [ + { + name: "memory_search", + label: "Memory Search", + description: "Search memory", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ], + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, + createMcpLoopbackServerConfig, + resolveMcpLoopbackScopedTools, + }); + setRawCliBackendForPrepareTest({ + id: "native-cli", + pluginId: "native-plugin", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + config: { + command: "native-cli", + args: ["--print"], + systemPromptArg: "--system-prompt", + systemPromptWhen: "first", + output: "text", + input: "arg", + sessionMode: "existing", + }, + }); + await expect( + fixture.prepare({ + sessionKey: "agent:main:test", + provider: "native-cli", + config: createCliBackendConfig({ bundleMcp: true }), + }), + ).rejects.toThrow(/loopback unavailable/); - expect(ensureMcpLoopbackServer).toHaveBeenCalledTimes(1); - expect(getActiveMcpLoopbackRuntime).toHaveBeenCalledTimes(1); - expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled(); - expect(resolveMcpLoopbackScopedTools).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(ensureMcpLoopbackServer).toHaveBeenCalledTimes(1); + expect(getActiveMcpLoopbackRuntime).toHaveBeenCalledTimes(1); + expect(createMcpLoopbackServerConfig).not.toHaveBeenCalled(); + expect(resolveMcpLoopbackScopedTools).not.toHaveBeenCalled(); }); it("binds current turn context into the bundle MCP client grant", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); - const activateMcpLoopbackClientGrantCapture = vi.fn(() => true); - const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true); - const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); - const revokeMcpLoopbackClientGrant = vi.fn(() => true); - const resolveMcpLoopbackScopedTools = vi.fn(() => ({ - agentId: "main", - tools: [ - { - name: "message", - label: "Message", - description: "Send a message", - parameters: { type: "object", properties: {} }, - execute: vi.fn(), - }, - ], - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - ensureMcpLoopbackServer, - createMcpLoopbackServerConfig, - activateMcpLoopbackClientGrantCapture, - deactivateMcpLoopbackClientGrantCapture, - mintMcpLoopbackClientGrant, - revokeMcpLoopbackClientGrant, - resolveMcpLoopbackScopedTools, - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "native-cli", - pluginId: "native-plugin", - bundleMcp: true, - bundleMcpMode: "codex-config-overrides", - config: { - command: "native-cli", - args: ["--print"], - input: "arg", - sessionMode: "existing", - }, - }, - ], - }); - const context = await prepareCliRunContext({ - sessionId: "session-test", + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); + const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig); + const activateMcpLoopbackClientGrantCapture = vi.fn(() => true); + const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true); + const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); + const revokeMcpLoopbackClientGrant = vi.fn(() => true); + const resolveMcpLoopbackScopedTools = vi.fn(() => ({ + agentId: "main", + tools: [ + { + name: "message", + label: "Message", + description: "Send a message", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ], + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, + createMcpLoopbackServerConfig, + activateMcpLoopbackClientGrantCapture, + deactivateMcpLoopbackClientGrantCapture, + mintMcpLoopbackClientGrant, + revokeMcpLoopbackClientGrant, + resolveMcpLoopbackScopedTools, + }); + setRawCliBackendForPrepareTest({ + id: "native-cli", + pluginId: "native-plugin", + bundleMcp: true, + bundleMcpMode: "codex-config-overrides", + config: { + command: "native-cli", + args: ["--print"], + input: "arg", + sessionMode: "existing", + }, + }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:group:chat123", + runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", + agentId: "worker", + provider: "native-cli", + modelProvider: "anthropic", + runId: "run-test-room-event-tools", + sessionEntry: { + execHost: "node", + execSecurity: "allowlist", + execAsk: "on-miss", + execNode: "mac-a", + } as never, + execOverrides: { + host: "node", + security: "allowlist", + ask: "always", + node: "mac-b", + }, + bashElevated: { + enabled: true, + allowed: true, + defaultLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }, + trigger: "user", + currentInboundEventKind: "room_event", + messageChannel: "telegram", + messageProvider: "discord", + clientCaps: ["tool-events", "inline-widgets"], + currentChannelId: "telegram:-100123:topic:42", + currentThreadTs: "42", + currentMessageId: "reply-message-1", + currentInboundAudio: true, + sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", + requireExplicitMessageTarget: true, + approvalReviewerDeviceId: "reviewer-device", + senderId: "canonical-sender", + senderName: "Canonical Name", + senderUsername: "canonical-user", + senderE164: "+15551234567", + groupId: "chat123", + groupChannel: "ops", + groupSpace: "workspace-a", + spawnedBy: "agent:main:telegram:group:parent", + channelContext: { + sender: { id: "sender-1", displayName: "not-forwarded" }, + chat: { id: "chat-1", title: "not-forwarded" }, + }, + }); + + expect(context.preparedBackend.env).toMatchObject({ + OPENCLAW_MCP_TOKEN: "loopback-token", + OPENCLAW_MCP_CLI_CAPTURE_KEY: "", + }); + expect(mintMcpLoopbackClientGrant).toHaveBeenCalledWith({ + context: { sessionKey: "agent:main:telegram:group:chat123", runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", agentId: "worker", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "native-cli", - modelProvider: "anthropic", - model: "test-model", - timeoutMs: 1_000, + sessionId: "session-test", runId: "run-test-room-event-tools", - config: createCliBackendConfig(), - sessionEntry: { + modelProvider: "anthropic", + modelId: "test-model", + messageProvider: "discord", + clientCaps: ["tool-events", "inline-widgets"], + currentChannelId: "telegram:-100123:topic:42", + currentThreadTs: "42", + currentMessageId: "reply-message-1", + currentInboundAudio: true, + accountId: undefined, + inboundEventKind: "room_event", + sourceReplyDeliveryMode: "message_tool_only", + taskSuggestionDeliveryMode: "gateway", + requireExplicitMessageTarget: true, + senderIsOwner: false, + nodeExecAllowed: true, + execSession: { execHost: "node", execSecurity: "allowlist", execAsk: "on-miss", execNode: "mac-a", - } as never, + }, execOverrides: { host: "node", security: "allowlist", @@ -3386,19 +2695,11 @@ describe("prepareCliRunContext", () => { fullAccessBlockedReason: "runtime", }, trigger: "user", - currentInboundEventKind: "room_event", - messageChannel: "telegram", - messageProvider: "discord", - clientCaps: ["tool-events", "inline-widgets"], - currentChannelId: "telegram:-100123:topic:42", - currentThreadTs: "42", - currentMessageId: "reply-message-1", - currentInboundAudio: true, - sourceReplyDeliveryMode: "message_tool_only", - taskSuggestionDeliveryMode: "gateway", - requireExplicitMessageTarget: true, approvalReviewerDeviceId: "reviewer-device", - senderId: "canonical-sender", + channelContext: { + sender: { id: "canonical-sender" }, + chat: { id: "chat-1" }, + }, senderName: "Canonical Name", senderUsername: "canonical-user", senderE164: "+15551234567", @@ -3406,223 +2707,124 @@ describe("prepareCliRunContext", () => { groupChannel: "ops", groupSpace: "workspace-a", spawnedBy: "agent:main:telegram:group:parent", + }, + runtimeOwnerToken: "loopback-owner-token", + }); + context.preparedBackend.mcpClientGrantCapture?.activate("capture-test"); + context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test"); + expect(activateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({ + token: "loopback-token", + runtimeOwnerToken: "loopback-owner-token", + captureKey: "capture-test", + }); + expect(deactivateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({ + token: "loopback-token", + runtimeOwnerToken: "loopback-owner-token", + captureKey: "capture-test", + }); + expect(context.mcpDeliveryCapture).toBe(true); + expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith( + expect.objectContaining({ + clientCaps: ["tool-events", "inline-widgets"], + taskSuggestionDeliveryMode: "gateway", + requireExplicitMessageTarget: true, + senderIsOwner: false, + runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", + agentId: "worker", + modelProvider: "anthropic", + modelId: "test-model", + execOverrides: { + host: "node", + security: "allowlist", + ask: "always", + node: "mac-b", + }, + bashElevated: { + enabled: true, + allowed: true, + defaultLevel: "full", + fullAccessAvailable: false, + fullAccessBlockedReason: "runtime", + }, channelContext: { - sender: { id: "sender-1", displayName: "not-forwarded" }, - chat: { id: "chat-1", title: "not-forwarded" }, + sender: { id: "canonical-sender" }, + chat: { id: "chat-1" }, }, - }); - - expect(context.preparedBackend.env).toMatchObject({ - OPENCLAW_MCP_TOKEN: "loopback-token", - OPENCLAW_MCP_CLI_CAPTURE_KEY: "", - }); - expect(mintMcpLoopbackClientGrant).toHaveBeenCalledWith({ - context: { - sessionKey: "agent:main:telegram:group:chat123", - runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", - agentId: "worker", - sessionId: "session-test", - runId: "run-test-room-event-tools", - modelProvider: "anthropic", - modelId: "test-model", - messageProvider: "discord", - clientCaps: ["tool-events", "inline-widgets"], - currentChannelId: "telegram:-100123:topic:42", - currentThreadTs: "42", - currentMessageId: "reply-message-1", - currentInboundAudio: true, - accountId: undefined, - inboundEventKind: "room_event", - sourceReplyDeliveryMode: "message_tool_only", - taskSuggestionDeliveryMode: "gateway", - requireExplicitMessageTarget: true, - senderIsOwner: false, - nodeExecAllowed: true, - execSession: { - execHost: "node", - execSecurity: "allowlist", - execAsk: "on-miss", - execNode: "mac-a", - }, - execOverrides: { - host: "node", - security: "allowlist", - ask: "always", - node: "mac-b", - }, - bashElevated: { - enabled: true, - allowed: true, - defaultLevel: "full", - fullAccessAvailable: false, - fullAccessBlockedReason: "runtime", - }, - trigger: "user", - approvalReviewerDeviceId: "reviewer-device", - channelContext: { - sender: { id: "canonical-sender" }, - chat: { id: "chat-1" }, - }, - senderName: "Canonical Name", - senderUsername: "canonical-user", - senderE164: "+15551234567", - groupId: "chat123", - groupChannel: "ops", - groupSpace: "workspace-a", - spawnedBy: "agent:main:telegram:group:parent", - }, - runtimeOwnerToken: "loopback-owner-token", - }); - context.preparedBackend.mcpClientGrantCapture?.activate("capture-test"); - context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test"); - expect(activateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({ - token: "loopback-token", - runtimeOwnerToken: "loopback-owner-token", - captureKey: "capture-test", - }); - expect(deactivateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({ - token: "loopback-token", - runtimeOwnerToken: "loopback-owner-token", - captureKey: "capture-test", - }); - expect(context.mcpDeliveryCapture).toBe(true); - expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith( - expect.objectContaining({ - clientCaps: ["tool-events", "inline-widgets"], - taskSuggestionDeliveryMode: "gateway", - requireExplicitMessageTarget: true, - senderIsOwner: false, - runtimePolicySessionKey: "agent:worker:discord:default:direct:canonical-sender", - agentId: "worker", - modelProvider: "anthropic", - modelId: "test-model", - execOverrides: { - host: "node", - security: "allowlist", - ask: "always", - node: "mac-b", - }, - bashElevated: { - enabled: true, - allowed: true, - defaultLevel: "full", - fullAccessAvailable: false, - fullAccessBlockedReason: "runtime", - }, - channelContext: { - sender: { id: "canonical-sender" }, - chat: { id: "chat-1" }, - }, - senderName: "Canonical Name", - senderUsername: "canonical-user", - senderE164: "+15551234567", - messageProvider: "discord", - groupId: "chat123", - groupChannel: "ops", - groupSpace: "workspace-a", - spawnedBy: "agent:main:telegram:group:parent", - }), - ); - expect(context.systemPrompt).toContain( - "`send`: `target` + `message`; target required this turn", - ); - expect(context.systemPrompt).not.toContain("current source is default target"); - await context.preparedBackend.cleanup?.(); - expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + senderName: "Canonical Name", + senderUsername: "canonical-user", + senderE164: "+15551234567", + messageProvider: "discord", + groupId: "chat123", + groupChannel: "ops", + groupSpace: "workspace-a", + spawnedBy: "agent:main:telegram:group:parent", + }), + ); + expect(context.systemPrompt).toContain( + "`send`: `target` + `message`; target required this turn", + ); + expect(context.systemPrompt).not.toContain("current source is default target"); + await context.preparedBackend.cleanup?.(); + expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token"); }); it("enables gateway delivery capture for Claude-style JSONL bundle MCP", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime: vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })), - createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-claude-delivery-capture", - config: createCliBackendConfig(), - }); - - expect(context.mcpDeliveryCapture).toBe(true); - expect(context.preparedBackend.env).toMatchObject({ - OPENCLAW_MCP_CLI_CAPTURE_KEY: "", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime: vi.fn(() => ({ port: 31783, ownerToken: "loopback-owner-token", nonOwnerToken: "loopback-non-owner-token", - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - }); + })), + createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), + }); + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, + }); - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-tools-allow", - config: createCliBackendConfig({ bundleMcp: true }), - toolsAllow: ["read", "web_search"], - }), - ).rejects.toThrow( - "CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy", - ); + const context = await fixture.prepare({ + provider: "claude-cli", + }); - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.mcpDeliveryCapture).toBe(true); + expect(context.preparedBackend.env).toMatchObject({ + OPENCLAW_MCP_CLI_CAPTURE_KEY: "", + }); + }); + + it("fails closed when a runtime toolsAllow is requested for CLI backends", async () => { + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + }); + + await expect( + fixture.prepare({ + config: createCliBackendConfig({ bundleMcp: true }), + toolsAllow: ["read", "web_search"], + }), + ).rejects.toThrow( + "CLI backend test-cli cannot enforce runtime toolsAllow; use an embedded runtime for restricted tool policy", + ); + + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); }); it("translates runtime toolsAllow through a selectable backend and bounds its MCP grant", async () => { - const { dir, sessionFile } = createSessionFile(); const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ ...context.baseArgs, ]); @@ -3638,27 +2840,22 @@ describe("prepareCliRunContext", () => { ], })); const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "selectable", - resolveExecutionArgs, - resolveRuntimeToolAvailability, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs, + resolveRuntimeToolAvailability, + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime: vi.fn(() => ({ @@ -3674,17 +2871,9 @@ describe("prepareCliRunContext", () => { let cleanup: (() => Promise) | undefined; try { - const context = await prepareCliRunContext({ - sessionId: "session-test", + const context = await fixture.prepare({ sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", provider: "claude-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-runtime-tools-allow", - config: createCliBackendConfig(), toolsAllow: ["group:fs", "exec", "browser", "image"], }); cleanup = context.preparedBackend.cleanup; @@ -3716,95 +2905,70 @@ describe("prepareCliRunContext", () => { ]); } finally { await cleanup?.(); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("rejects a backend that expands runtime toolsAllow beyond the requested grant", async () => { - const { dir, sessionFile } = createSessionFile(); const getActiveMcpLoopbackRuntime = vi.fn(() => ({ port: 31783, ownerToken: "loopback-owner-token", nonOwnerToken: "loopback-non-owner-token", })); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "selectable", - resolveExecutionArgs: ({ baseArgs }) => [...baseArgs], - resolveRuntimeToolAvailability: () => ({ - mcp: ["mcp__openclaw__read", "mcp__openclaw__exec"], - }), - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs: ({ baseArgs }) => [...baseArgs], + resolveRuntimeToolAvailability: () => ({ + mcp: ["mcp__openclaw__read", "mcp__openclaw__exec"], + }), + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime, }); - try { - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-runtime-tools-expansion", - config: createCliBackendConfig(), - toolsAllow: ["read"], - }), - ).rejects.toThrow( - "CLI backend claude-cli expanded runtime toolsAllow outside the requested OpenClaw MCP grant: mcp__openclaw__exec", - ); - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + await expect( + fixture.prepare({ + sessionKey: "agent:main:main", + provider: "claude-cli", + toolsAllow: ["read"], + }), + ).rejects.toThrow( + "CLI backend claude-cli expanded runtime toolsAllow outside the requested OpenClaw MCP grant: mcp__openclaw__exec", + ); + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); }); it("bounds the loopback grant to the selectable MCP tool allowlist", async () => { - const { dir, sessionFile } = createSessionFile(); const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ ...context.baseArgs, ]); const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "selectable", - resolveExecutionArgs, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }, - ], + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs, + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, }); setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime: vi.fn(() => ({ @@ -3820,16 +2984,9 @@ describe("prepareCliRunContext", () => { let cleanup: (() => Promise) | undefined; try { - const context = await prepareCliRunContext({ - sessionId: "session-test", + const context = await fixture.prepare({ sessionKey: "agent:main:main", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", provider: "claude-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-loopback-tools-allow", config: { ...createCliBackendConfig(), mcp: { @@ -3860,200 +3017,211 @@ describe("prepareCliRunContext", () => { expect(Object.keys(rawBundle.mcpServers ?? {})).toEqual(["openclaw"]); } finally { await cleanup?.(); - fs.rmSync(dir, { recursive: true, force: true }); } }); it("serves only the openclaw MCP server for ring-zero runs", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => undefined); - const resolveExecutionArgs = vi.fn( - (context: { - baseArgs: readonly string[]; - toolAvailability?: { native: readonly string[]; mcp: readonly string[] }; - }) => [ - ...context.baseArgs, - "--tools", - context.toolAvailability?.native.join(",") ?? "default", - "--allowedTools", - context.toolAvailability?.mcp.join(",") ?? "", - ], - ); - setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: true, - bundleMcpMode: "claude-config-file", - nativeToolMode: "selectable", - resolveExecutionArgs, - config: { - command: "claude", - args: ["--print"], - resumeArgs: ["--print", "--resume", "{sessionId}"], - output: "jsonl", - jsonlDialect: "claude-stream-json", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); + const { dir, sessionFile } = fixture.session; + const getActiveMcpLoopbackRuntime = vi.fn(() => undefined); + const resolveExecutionArgs = vi.fn( + (context: { + baseArgs: readonly string[]; + toolAvailability?: { native: readonly string[]; mcp: readonly string[] }; + }) => [ + ...context.baseArgs, + "--tools", + context.toolAvailability?.native.join(",") ?? "default", + "--allowedTools", + context.toolAvailability?.mcp.join(",") ?? "", + ], + ); + setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime }); + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: true, + bundleMcpMode: "claude-config-file", + nativeToolMode: "selectable", + resolveExecutionArgs, + config: { + command: "claude", + args: ["--print"], + resumeArgs: ["--print", "--resume", "{sessionId}"], + output: "jsonl", + jsonlDialect: "claude-stream-json", + input: "stdin", + sessionMode: "existing", + }, + }); - const params: RunCliAgentParams & { systemAgentTool: SystemAgentToolOptions } = { - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-openclaw-mcp", - config: createCliBackendConfig(), - systemAgentTool: { surface: "cli" }, - cliToolAvailability: { - native: [], - mcp: ["mcp__openclaw__openclaw"], - }, - }; - const context = await prepareCliRunContext(params); - - // Ring-zero runs never touch the loopback surface (no message tools). - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - expect(context.mcpDeliveryCapture).toBeUndefined(); - const args = context.preparedBackend.backend.args ?? []; - expect(args).toContain("--strict-mcp-config"); - expect(args).not.toContain("--tools"); - expect(args).not.toContain("--allowedTools"); - expect(context.preparedBackend.backend.resumeArgs).toEqual( - expect.arrayContaining(["--strict-mcp-config"]), - ); - expect(resolveExecutionArgs).not.toHaveBeenCalled(); - expect(context.params.cliToolAvailability).toEqual({ + const params: RunCliAgentParams & { systemAgentTool: SystemAgentToolOptions } = { + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "claude-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-openclaw-mcp", + config: createCliBackendConfig(), + systemAgentTool: { surface: "cli" }, + cliToolAvailability: { native: [], mcp: ["mcp__openclaw__openclaw"], - }); - const mcpConfigPath = expectDefined( - args[args.indexOf("--mcp-config") + 1], - 'args[args.indexOf("--mcp-config") + 1] test invariant', - ); - const raw = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8")) as { - mcpServers?: Record }>; - }; - expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]); - expect(raw.mcpServers?.openclaw?.env).toMatchObject({ - OPENCLAW_TOOLS_MCP_TOOLS: "openclaw", - OPENCLAW_TOOLS_MCP_SYSTEM_AGENT_SURFACE: "cli", - }); + }, + }; + const context = await prepareCliRunContext(params); - await context.preparedBackend.cleanup?.(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + // Ring-zero runs never touch the loopback surface (no message tools). + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); + expect(context.mcpDeliveryCapture).toBeUndefined(); + const args = context.preparedBackend.backend.args ?? []; + expect(args).toContain("--strict-mcp-config"); + expect(args).not.toContain("--tools"); + expect(args).not.toContain("--allowedTools"); + expect(context.preparedBackend.backend.resumeArgs).toEqual( + expect.arrayContaining(["--strict-mcp-config"]), + ); + expect(resolveExecutionArgs).not.toHaveBeenCalled(); + expect(context.params.cliToolAvailability).toEqual({ + native: [], + mcp: ["mcp__openclaw__openclaw"], + }); + const mcpConfigPath = expectDefined( + args[args.indexOf("--mcp-config") + 1], + 'args[args.indexOf("--mcp-config") + 1] test invariant', + ); + const raw = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8")) as { + mcpServers?: Record }>; + }; + expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]); + expect(raw.mcpServers?.openclaw?.env).toMatchObject({ + OPENCLAW_TOOLS_MCP_TOOLS: "openclaw", + OPENCLAW_TOOLS_MCP_SYSTEM_AGENT_SURFACE: "cli", + }); + + await context.preparedBackend.cleanup?.(); }); it("fails closed for native tool-capable CLI backends when tools are disabled", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const getActiveMcpLoopbackRuntime = vi.fn(() => ({ - port: 31783, - ownerToken: "loopback-owner-token", - nonOwnerToken: "loopback-non-owner-token", - })); - setCliRunnerPrepareTestDeps({ - getActiveMcpLoopbackRuntime, - }); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "native-cli", - pluginId: "native-plugin", - bundleMcp: true, - bundleMcpMode: "codex-config-overrides", - nativeToolMode: "always-on", - config: { - command: "native-cli", - args: ["exec", "--sandbox", "workspace-write"], - resumeArgs: ["exec", "resume", "{sessionId}"], - output: "jsonl", - input: "arg", - sessionMode: "existing", - }, - }, - ], - }); + const getActiveMcpLoopbackRuntime = vi.fn(() => ({ + port: 31783, + ownerToken: "loopback-owner-token", + nonOwnerToken: "loopback-non-owner-token", + })); + setCliRunnerPrepareTestDeps({ + getActiveMcpLoopbackRuntime, + }); + setRawCliBackendForPrepareTest({ + id: "native-cli", + pluginId: "native-plugin", + bundleMcp: true, + bundleMcpMode: "codex-config-overrides", + nativeToolMode: "always-on", + config: { + command: "native-cli", + args: ["exec", "--sandbox", "workspace-write"], + resumeArgs: ["exec", "resume", "{sessionId}"], + output: "jsonl", + input: "arg", + sessionMode: "existing", + }, + }); - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "native-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-test-disable-native-tools", - config: createCliBackendConfig(), - disableTools: true, - }), - ).rejects.toThrow( - "CLI backend native-cli cannot run with tools disabled because it exposes native tools", - ); + await expect( + fixture.prepare({ + provider: "native-cli", + disableTools: true, + }), + ).rejects.toThrow( + "CLI backend native-cli cannot run with tools disabled because it exposes native tools", + ); - expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); }); - it("drops the claude-cli sessionId when the on-disk transcript is missing (#77011)", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - setCliBackendForPrepareTest(); - const transcriptCheck = vi.fn(async () => false); - const orphanCheck = vi.fn(async () => true); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); + it.each([ + { + name: "drops the claude-cli sessionId when the on-disk transcript is missing (#77011)", + sessionId: "stale-claude-sid", + hasContent: false, + hasOrphan: true, + withCwdHash: false, + checksTranscript: true, + checksOrphan: false, + expected: { mode: "invalidate", invalidatedReason: "missing-transcript" }, + }, + { + name: "invalidates orphaned claude-cli transcripts during run preparation", + sessionId: "orphaned-claude-sid", + hasContent: true, + hasOrphan: true, + withCwdHash: true, + checksTranscript: true, + checksOrphan: true, + expected: { mode: "invalidate", invalidatedReason: "orphaned-tool-use" }, + }, + { + name: "keeps auth-boundary invalidation ahead of orphaned transcript checks", + sessionId: "orphaned-claude-sid", + authProfileId: "anthropic:old-profile", + hasContent: true, + hasOrphan: true, + withCwdHash: true, + checksTranscript: false, + checksOrphan: false, + expected: { mode: "invalidate", invalidatedReason: "auth-profile" }, + }, + { + name: "keeps the claude-cli sessionId when the on-disk transcript is present", + sessionId: "live-claude-sid", + hasContent: true, + hasOrphan: false, + withCwdHash: true, + checksTranscript: true, + checksOrphan: true, + expected: { mode: "reuse", sessionId: "live-claude-sid" }, + }, + ])("$name", async (testCase) => { + const { dir } = fixture.session; + setCliBackendForPrepareTest(); + const transcriptCheck = vi.fn(async () => testCase.hasContent); + const orphanCheck = vi.fn(async () => testCase.hasOrphan); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, + }); + const cliSessionBinding = { + sessionId: testCase.sessionId, + ...(testCase.withCwdHash ? { cwdHash: hashCliSessionText(dir) } : {}), + ...(testCase.authProfileId ? { authProfileId: testCase.authProfileId } : {}), + }; - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-77011-missing", - cliSessionBinding: { sessionId: "stale-claude-sid" }, - cliSessionId: "stale-claude-sid", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:direct:peer", + prompt: "follow-up", + provider: "claude-cli", + model: "opus", + cliSessionBinding, + cliSessionId: testCase.sessionId, + }); - expect(transcriptCheck).toHaveBeenCalledWith({ - sessionId: "stale-claude-sid", - workspaceDir: dir, - }); - expect(orphanCheck).not.toHaveBeenCalled(); - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "missing-transcript", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); + const transcriptArgs = { sessionId: testCase.sessionId, workspaceDir: dir }; + if (testCase.checksTranscript) { + expect(transcriptCheck).toHaveBeenCalledWith(transcriptArgs); + } else { + expect(transcriptCheck).not.toHaveBeenCalled(); } + if (testCase.checksOrphan) { + expect(orphanCheck).toHaveBeenCalledWith(transcriptArgs); + } else { + expect(orphanCheck).not.toHaveBeenCalled(); + } + expect(context.reusableCliSession).toEqual(testCase.expected); }); it("arms raw-transcript reseed for a missing claude-cli transcript so prior conversation is redelivered", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -4063,160 +3231,130 @@ describe("prepareCliRunContext", () => { timestamp: 1, }, }); - try { - setCliBackendForPrepareTest({ - reseedFromRawTranscriptWhenUncompacted: true, - }); - const transcriptCheck = vi.fn(async () => false); - const orphanCheck = vi.fn(async () => false); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); + setCliBackendForPrepareTest({ + reseedFromRawTranscriptWhenUncompacted: true, + }); + const transcriptCheck = vi.fn(async () => false); + const orphanCheck = vi.fn(async () => false); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-missing-transcript-reseed", - cliSessionBinding: { sessionId: "stale-claude-sid" }, - cliSessionId: "stale-claude-sid", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:direct:peer", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "stale-claude-sid" }, + cliSessionId: "stale-claude-sid", + }); - // Candidate is invalidated (no native --resume) yet reseed still fires: - // prepare hands the prior OpenClaw conversation forward as history. - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "missing-transcript", - }); - expect(context.openClawHistoryPrompt).toContain("prior claude-cli ask"); - expect(context.openClawHistoryPrompt).toContain("latest ask"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + // Candidate is invalidated (no native --resume) yet reseed still fires: + // prepare hands the prior OpenClaw conversation forward as history. + expect(context.reusableCliSession).toEqual({ + mode: "invalidate", + invalidatedReason: "missing-transcript", + }); + expect(context.openClawHistoryPrompt).toContain("prior claude-cli ask"); + expect(context.openClawHistoryPrompt).toContain("latest ask"); }); it("prepares node-placed Claude resumes without Gateway MCP, skills, or transcript checks", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-node-1", parentId: null, timestamp: new Date(1).toISOString(), message: { role: "user", content: "gateway-only history", timestamp: 1 }, }); - try { - const prepareExecution = vi.fn(async () => ({ - env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, - secretInput: { - fd: 3, - fingerprint: "selected-node-token-fingerprint", - createData: () => Buffer.from("selected-node-token"), - }, - clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], - })); - setCliBackendForPrepareTest({ - bundleMcp: true, - liveSession: true, - prepareExecution, - reseedFromRawTranscriptWhenUncompacted: true, - }); - const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); - const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({ - args: ["--plugin-dir", "/tmp/gateway-skills"], - cleanup: vi.fn(async () => undefined), - })); - const transcriptCheck = vi.fn(async () => false); - const orphanCheck = vi.fn(async () => false); - setCliRunnerPrepareTestDeps({ - ensureMcpLoopbackServer, - prepareClaudeCliSkillsPlugin, - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); + const prepareExecution = vi.fn(async () => ({ + env: { CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" }, + secretInput: { + fd: 3, + fingerprint: "selected-node-token-fingerprint", + createData: () => Buffer.from("selected-node-token"), + }, + clearEnv: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + })); + setCliBackendForPrepareTest({ + bundleMcp: true, + liveSession: true, + prepareExecution, + reseedFromRawTranscriptWhenUncompacted: true, + }); + const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer); + const prepareClaudeCliSkillsPlugin = vi.fn(async () => ({ + args: ["--plugin-dir", "/tmp/gateway-skills"], + cleanup: vi.fn(async () => undefined), + })); + const transcriptCheck = vi.fn(async () => false); + const orphanCheck = vi.fn(async () => false); + setCliRunnerPrepareTestDeps({ + ensureMcpLoopbackServer, + prepareClaudeCliSkillsPlugin, + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, + }); - await expect( - prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-node-claude-missing-placement", - sessionEntry: { execHost: "node" } as never, - config: createCliBackendConfig(), - }), - ).rejects.toThrow("node-placed Claude CLI session is missing execNode"); - expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:catalog-adopt:claude:node", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", + await expect( + fixture.prepare({ provider: "claude-cli", model: "opus", - timeoutMs: 1_000, - runId: "run-node-claude-prepare", - cliSessionBinding: { - sessionId: "node-source-session", - forceReuse: true, - forkNextResume: true, - }, - cliSessionId: "node-source-session", - sessionEntry: { - execHost: "node", - execNode: "node-a", - execCwd: "/work/on-node", - } as never, - skillsSnapshot: { - prompt: "GATEWAY_ONLY_SKILL_PATH=/tmp/gateway-skill/SKILL.md", - skills: [], - resolvedSkills: [], - }, - config: createCliBackendConfig(), - }); + sessionEntry: { execHost: "node" } as never, + }), + ).rejects.toThrow("node-placed Claude CLI session is missing execNode"); + expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", + const context = await fixture.prepare({ + sessionKey: "agent:main:catalog-adopt:claude:node", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "node-source-session", - }); - // The reseed prompt is gateway-built text, so node placement keeps the - // backend's raw-transcript reseed semantics for fresh-retry paths. - expect(context.openClawHistoryPrompt).toContain("gateway-only history"); - expect(context.claudeSkillsPluginArgs).toEqual([]); - expect(context.systemPrompt).not.toContain("GATEWAY_ONLY_SKILL_PATH"); - expect(context.mcpDeliveryCapture).toBeUndefined(); - expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); - expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled(); - expect(transcriptCheck).not.toHaveBeenCalled(); - expect(orphanCheck).not.toHaveBeenCalled(); - expect(prepareExecution).toHaveBeenCalledOnce(); - expect(context.preparedBackend.env).toMatchObject({ - CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", - }); - expect(context.preparedBackend.secretInput?.fingerprint).toBe( - "selected-node-token-fingerprint", - ); - expect(context.preparedBackend.backend.clearEnv).toEqual( - expect.arrayContaining(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + forceReuse: true, + forkNextResume: true, + }, + cliSessionId: "node-source-session", + sessionEntry: { + execHost: "node", + execNode: "node-a", + execCwd: "/work/on-node", + } as never, + skillsSnapshot: { + prompt: "GATEWAY_ONLY_SKILL_PATH=/tmp/gateway-skill/SKILL.md", + skills: [], + resolvedSkills: [], + }, + }); + + expect(context.reusableCliSession).toEqual({ + mode: "reuse", + sessionId: "node-source-session", + }); + // The reseed prompt is gateway-built text, so node placement keeps the + // backend's raw-transcript reseed semantics for fresh-retry paths. + expect(context.openClawHistoryPrompt).toContain("gateway-only history"); + expect(context.claudeSkillsPluginArgs).toEqual([]); + expect(context.systemPrompt).not.toContain("GATEWAY_ONLY_SKILL_PATH"); + expect(context.mcpDeliveryCapture).toBeUndefined(); + expect(ensureMcpLoopbackServer).not.toHaveBeenCalled(); + expect(prepareClaudeCliSkillsPlugin).not.toHaveBeenCalled(); + expect(transcriptCheck).not.toHaveBeenCalled(); + expect(orphanCheck).not.toHaveBeenCalled(); + expect(prepareExecution).toHaveBeenCalledOnce(); + expect(context.preparedBackend.env).toMatchObject({ + CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3", + }); + expect(context.preparedBackend.secretInput?.fingerprint).toBe( + "selected-node-token-fingerprint", + ); + expect(context.preparedBackend.backend.clearEnv).toEqual( + expect.arrayContaining(["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]), + ); }); it("keeps a warm claude-cli binding when its managed stdio child is still live", async () => { - const { dir, sessionFile } = createSessionFile(); - appendTranscriptEntry(sessionFile, { + const { dir } = fixture.session; + fixture.appendTranscript({ id: "msg-warm-1", parentId: null, timestamp: new Date(1).toISOString(), @@ -4226,324 +3364,145 @@ describe("prepareCliRunContext", () => { timestamp: 1, }, }); - try { - setCliBackendForPrepareTest({ - liveSession: true, - reseedFromRawTranscriptWhenUncompacted: true, - }); - const transcriptCheck = vi.fn(async () => false); - const orphanCheck = vi.fn(async () => true); - const getLiveSessionGeneration = vi.fn(() => "warm-live-generation"); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - getClaudeLiveSessionGenerationForOwner: getLiveSessionGeneration, - }); + setCliBackendForPrepareTest({ + liveSession: true, + reseedFromRawTranscriptWhenUncompacted: true, + }); + const transcriptCheck = vi.fn(async () => false); + const orphanCheck = vi.fn(async () => true); + const getLiveSessionGeneration = vi.fn(() => "warm-live-generation"); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, + getClaudeLiveSessionGenerationForOwner: getLiveSessionGeneration, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "warm follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-warm-live-follow-up", - cliSessionBinding: { sessionId: "warm-claude-sid" }, - cliSessionId: "warm-claude-sid", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:direct:peer", + prompt: "warm follow-up", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "warm-claude-sid" }, + cliSessionId: "warm-claude-sid", + }); - expect(getLiveSessionGeneration).toHaveBeenCalledWith({ - backendId: "claude-cli", - agentAccountId: undefined, - agentId: undefined, - authProfileId: undefined, - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - }); - expect(transcriptCheck).toHaveBeenCalledWith({ - sessionId: "warm-claude-sid", - workspaceDir: dir, - }); - expect(orphanCheck).not.toHaveBeenCalled(); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", - sessionId: "warm-claude-sid", - }); - expect(context.requiredClaudeLiveSessionGeneration).toBe("warm-live-generation"); - expect(context.openClawHistoryPrompt).toContain("earlier warm context"); - expect(context.openClawHistoryPrompt).toContain("warm follow-up"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(getLiveSessionGeneration).toHaveBeenCalledWith({ + backendId: "claude-cli", + agentAccountId: undefined, + agentId: undefined, + authProfileId: undefined, + sessionId: "session-test", + sessionKey: "agent:main:telegram:direct:peer", + }); + expect(transcriptCheck).toHaveBeenCalledWith({ + sessionId: "warm-claude-sid", + workspaceDir: dir, + }); + expect(orphanCheck).not.toHaveBeenCalled(); + expect(context.reusableCliSession).toEqual({ + mode: "reuse", + sessionId: "warm-claude-sid", + }); + expect(context.requiredClaudeLiveSessionGeneration).toBe("warm-live-generation"); + expect(context.openClawHistoryPrompt).toContain("earlier warm context"); + expect(context.openClawHistoryPrompt).toContain("warm follow-up"); }); it("disables Claude live transport while preserving native transcript resume", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - setCliBackendForPrepareTest({ liveSession: true }); - const transcriptCheck = vi.fn(async () => true); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: vi.fn(async () => false), - }); + setCliBackendForPrepareTest({ liveSession: true }); + const transcriptCheck = vi.fn(async () => true); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: vi.fn(async () => false), + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:openclaw:main", - sessionFile, - workspaceDir: dir, - prompt: "approve the proposal", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-openclaw-process-per-turn", - cliSessionBinding: { sessionId: "native-claude-sid" }, - config: createCliBackendConfig(), - disableCliLiveSession: true, - }); + const context = await fixture.prepare({ + sessionKey: "agent:openclaw:main", + prompt: "approve the proposal", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "native-claude-sid" }, + disableCliLiveSession: true, + }); - expect(context.preparedBackend.backend.liveSession).toBeUndefined(); - expect(context.preparedBackend.backend.sessionMode).toBe("existing"); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", - sessionId: "native-claude-sid", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.preparedBackend.backend.liveSession).toBeUndefined(); + expect(context.preparedBackend.backend.sessionMode).toBe("existing"); + expect(context.reusableCliSession).toEqual({ + mode: "reuse", + sessionId: "native-claude-sid", + }); }); it("ignores stored CLI session candidates when the backend disables sessions", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - setCliBackendForPrepareTest({ - sessionMode: "none", - reseedFromRawTranscriptWhenUncompacted: true, - }); - const transcriptCheck = vi.fn(async () => false); - const orphanCheck = vi.fn(async () => false); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); + setCliBackendForPrepareTest({ + sessionMode: "none", + reseedFromRawTranscriptWhenUncompacted: true, + }); + const transcriptCheck = vi.fn(async () => false); + const orphanCheck = vi.fn(async () => false); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "stateless ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-stateless-cli", - cliSessionBinding: { sessionId: "stale-claude-sid" }, - cliSessionId: "stale-claude-sid", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:direct:peer", + prompt: "stateless ask", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "stale-claude-sid" }, + cliSessionId: "stale-claude-sid", + }); - expect(context.reusableCliSession).toEqual({ mode: "none" }); - expect(transcriptCheck).not.toHaveBeenCalled(); - expect(orphanCheck).not.toHaveBeenCalled(); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("invalidates orphaned claude-cli transcripts during run preparation", async () => { - const { dir, sessionFile } = createSessionFile(); - - try { - setCliBackendForPrepareTest(); - const transcriptCheck = vi.fn(async () => true); - const orphanCheck = vi.fn(async () => true); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-orphan-tool-use", - cliSessionBinding: { - sessionId: "orphaned-claude-sid", - cwdHash: hashCliSessionText(dir), - }, - cliSessionId: "orphaned-claude-sid", - config: createCliBackendConfig(), - }); - - expect(transcriptCheck).toHaveBeenCalledWith({ - sessionId: "orphaned-claude-sid", - workspaceDir: dir, - }); - expect(orphanCheck).toHaveBeenCalledWith({ - sessionId: "orphaned-claude-sid", - workspaceDir: dir, - }); - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "orphaned-tool-use", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("keeps auth-boundary invalidation ahead of orphaned transcript checks", async () => { - const { dir, sessionFile } = createSessionFile(); - - try { - setCliBackendForPrepareTest(); - const transcriptCheck = vi.fn(async () => true); - const orphanCheck = vi.fn(async () => true); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-orphan-auth-boundary", - cliSessionBinding: { - sessionId: "orphaned-claude-sid", - authProfileId: "anthropic:old-profile", - cwdHash: hashCliSessionText(dir), - }, - cliSessionId: "orphaned-claude-sid", - config: createCliBackendConfig(), - }); - - expect(transcriptCheck).not.toHaveBeenCalled(); - expect(orphanCheck).not.toHaveBeenCalled(); - expect(context.reusableCliSession).toEqual({ - mode: "invalidate", - invalidatedReason: "auth-profile", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("keeps the claude-cli sessionId when the on-disk transcript is present", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - setCliBackendForPrepareTest(); - const transcriptCheck = vi.fn(async () => true); - const orphanCheck = vi.fn(async () => false); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - prompt: "follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-77011-present", - cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(dir) }, - cliSessionId: "live-claude-sid", - config: createCliBackendConfig(), - }); - - expect(transcriptCheck).toHaveBeenCalledWith({ - sessionId: "live-claude-sid", - workspaceDir: dir, - }); - expect(orphanCheck).toHaveBeenCalledWith({ - sessionId: "live-claude-sid", - workspaceDir: dir, - }); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", - sessionId: "live-claude-sid", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.reusableCliSession).toEqual({ mode: "none" }); + expect(transcriptCheck).not.toHaveBeenCalled(); + expect(orphanCheck).not.toHaveBeenCalled(); }); it("checks claude-cli transcript content under the resolved cwd", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const taskDir = path.join(dir, "task"); fs.mkdirSync(taskDir, { recursive: true }); - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - resumeArgs: ["--resume", "{sessionId}"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); - const transcriptCheck = vi.fn(async () => true); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - }); + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: false, + config: { + command: "claude", + args: ["--print"], + resumeArgs: ["--resume", "{sessionId}"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + }, + }); + const transcriptCheck = vi.fn(async () => true); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:telegram:direct:peer", - sessionFile, - workspaceDir: dir, - cwd: taskDir, - prompt: "follow-up", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-77011-cwd", - cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(taskDir) }, - cliSessionId: "live-claude-sid", - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + sessionKey: "agent:main:telegram:direct:peer", + cwd: taskDir, + prompt: "follow-up", + provider: "claude-cli", + model: "opus", + cliSessionBinding: { sessionId: "live-claude-sid", cwdHash: hashCliSessionText(taskDir) }, + cliSessionId: "live-claude-sid", + }); - expect(transcriptCheck).toHaveBeenCalledWith({ - sessionId: "live-claude-sid", - workspaceDir: taskDir, - }); - expect(context.reusableCliSession).toEqual({ - mode: "reuse", - sessionId: "live-claude-sid", - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(transcriptCheck).toHaveBeenCalledWith({ + sessionId: "live-claude-sid", + workspaceDir: taskDir, + }); + expect(context.reusableCliSession).toEqual({ + mode: "reuse", + sessionId: "live-claude-sid", + }); }); it("renders CLI skills from sandbox-readable paths instead of persisted host snapshots", async () => { - const { dir, sessionFile } = createSessionFile(); + const { dir } = fixture.session; const hostSkillDir = "/home/tzdai/.npm-global/lib/node_modules/openclaw/skills/gog"; const hostSkillPath = `${hostSkillDir}/SKILL.md`; const materializedWorkspace = path.join(dir, "state", "sandbox-skills"); @@ -4569,153 +3528,102 @@ describe("prepareCliRunContext", () => { workspaceAccess: "rw", }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionKey: "agent:main:sandboxed-user", - agentId: "main", - sessionFile, - workspaceDir: dir, - prompt: "are there any unread emails", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-sandbox-cli-skill-prompt", - config: createCliBackendConfig(), - skillsSnapshot: { - prompt: [ - "", - " ", - " gog", - " Read Gmail safely.", - ` ${hostSkillPath}`, - " ", - "", - ].join("\n"), - skills: [{ name: "gog" }], - resolvedSkills: [ - { - name: "gog", - description: "Read Gmail safely.", - filePath: hostSkillPath, - baseDir: hostSkillDir, - source: "openclaw-bundled", - sourceInfo: { - path: hostSkillPath, - source: "openclaw-bundled", - scope: "project", - origin: "top-level", - baseDir: hostSkillDir, - }, - disableModelInvocation: false, - }, - ], - }, - }); - - expect(ensureSandboxWorkspaceForSessionMock).toHaveBeenCalledWith({ - config: createCliBackendConfig(), - sessionKey: "agent:main:sandboxed-user", - workspaceDir: dir, - }); - expect(context.systemPrompt).toContain( - "/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md", - ); - expect(context.systemPrompt).not.toContain(hostSkillPath); - expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); - expect(context.systemPromptReport.skills.entries).toEqual([ - { name: "gog", blockChars: expect.any(Number) }, - ]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("omits Claude CLI prompt skills when the native skills plugin can carry them", async () => { - const { dir, sessionFile } = createSessionFile(); - const skillDir = path.join(dir, "skills", "weather"); - fs.mkdirSync(skillDir, { recursive: true }); - const skillFilePath = path.join(skillDir, "SKILL.md"); - fs.writeFileSync( - skillFilePath, - [ - "---", - "name: weather", - "description: Use weather tools for forecasts.", - "---", - "", - "Read forecast data before replying.", - ].join("\n"), - "utf-8", - ); - - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ + const context = await fixture.prepare({ + sessionKey: "agent:main:sandboxed-user", + agentId: "main", + prompt: "are there any unread emails", + skillsSnapshot: { + prompt: [ + "", + " ", + " gog", + " Read Gmail safely.", + ` ${hostSkillPath}`, + " ", + "", + ].join("\n"), + skills: [{ name: "gog" }], + resolvedSkills: [ { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", + name: "gog", + description: "Read Gmail safely.", + filePath: hostSkillPath, + baseDir: hostSkillDir, + source: "openclaw-bundled", + sourceInfo: { + path: hostSkillPath, + source: "openclaw-bundled", + scope: "project", + origin: "top-level", + baseDir: hostSkillDir, }, + disableModelInvocation: false, }, ], - }); + }, + }); + + expect(ensureSandboxWorkspaceForSessionMock).toHaveBeenCalledWith({ + config: createCliBackendConfig(), + sessionKey: "agent:main:sandboxed-user", + workspaceDir: dir, + }); + expect(context.systemPrompt).toContain( + "/workspace/.openclaw/sandbox-skills/skills/gog/SKILL.md", + ); + expect(context.systemPrompt).not.toContain(hostSkillPath); + expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); + expect(context.systemPromptReport.skills.entries).toEqual([ + { name: "gog", blockChars: expect.any(Number) }, + ]); + }); + + it.each([ + { + name: "omits prompt skills when the native skills plugin can carry them", + materialized: true, + pluginResult: "args", + expectsPromptSkills: false, + }, + { + name: "keeps prompt skills when the snapshot has no materialized plugin skills", + materialized: false, + pluginResult: "default", + expectsPromptSkills: true, + }, + { + name: "keeps prompt skills when plugin materialization produces no args", + materialized: true, + pluginResult: "empty", + expectsPromptSkills: true, + }, + ])("handles Claude CLI skills: $name", async (testCase) => { + const { dir } = fixture.session; + const skill = createWeatherSkillFixture(dir, testCase.materialized); + setCliBackendForPrepareTest({ id: "claude-cli", pluginId: "anthropic" }); + if (testCase.pluginResult !== "default") { + const pluginDir = path.join(dir, "openclaw-skills"); setCliRunnerPrepareTestDeps({ prepareClaudeCliSkillsPlugin: vi.fn(async () => ({ - args: ["--plugin-dir", path.join(dir, "openclaw-skills")], + args: testCase.pluginResult === "args" ? ["--plugin-dir", pluginDir] : [], cleanup: vi.fn(async () => undefined), - pluginDir: path.join(dir, "openclaw-skills"), + ...(testCase.pluginResult === "args" ? { pluginDir } : {}), })), }); + } - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-claude-plugin-skills-prompt", - config: createCliBackendConfig(), - skillsSnapshot: { - prompt: [ - "", - " ", - " weather", - " Use weather tools for forecasts.", - ` ${skillFilePath}`, - " ", - "", - ].join("\n"), - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Use weather tools for forecasts.", - filePath: skillFilePath, - baseDir: skillDir, - source: "test", - sourceInfo: { - path: skillDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: skillDir, - }, - disableModelInvocation: false, - }, - ], - }, - }); + const context = await fixture.prepare({ + provider: "claude-cli", + model: "opus", + skillsSnapshot: skill.snapshot, + }); + if (testCase.expectsPromptSkills) { + expect(context.systemPrompt).toContain(""); + expect(context.systemPrompt).toContain("weather"); + expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); + expect(context.claudeSkillsPluginArgs).toEqual([]); + } else { expect(context.systemPrompt).not.toContain(""); expect(context.systemPrompt).not.toContain("weather"); expect(context.systemPromptReport.skills.promptChars).toBe(0); @@ -4723,378 +3631,105 @@ describe("prepareCliRunContext", () => { "--plugin-dir", path.join(dir, "openclaw-skills"), ]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("keeps Claude CLI prompt skills when the snapshot has no materialized plugin skills", async () => { - const { dir, sessionFile } = createSessionFile(); - const missingSkillDir = path.join(dir, "skills", "missing"); - const missingSkillFilePath = path.join(missingSkillDir, "SKILL.md"); - - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-claude-plugin-skills-prompt-fallback", - config: createCliBackendConfig(), - skillsSnapshot: { - prompt: [ - "", - " ", - " weather", - " Use weather tools for forecasts.", - ` ${missingSkillFilePath}`, - " ", - "", - ].join("\n"), - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Use weather tools for forecasts.", - filePath: missingSkillFilePath, - baseDir: missingSkillDir, - source: "test", - sourceInfo: { - path: missingSkillDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: missingSkillDir, - }, - disableModelInvocation: false, - }, - ], - }, - }); - - expect(context.systemPrompt).toContain(""); - expect(context.systemPrompt).toContain("weather"); - expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); - expect(context.claudeSkillsPluginArgs).toEqual([]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("keeps Claude CLI prompt skills when plugin materialization produces no args", async () => { - const { dir, sessionFile } = createSessionFile(); - const skillDir = path.join(dir, "skills", "weather"); - fs.mkdirSync(skillDir, { recursive: true }); - const skillFilePath = path.join(skillDir, "SKILL.md"); - fs.writeFileSync( - skillFilePath, - [ - "---", - "name: weather", - "description: Use weather tools for forecasts.", - "---", - "", - "Read forecast data before replying.", - ].join("\n"), - "utf-8", - ); - - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); - setCliRunnerPrepareTestDeps({ - prepareClaudeCliSkillsPlugin: vi.fn(async () => ({ - args: [], - cleanup: vi.fn(async () => undefined), - })), - }); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "opus", - timeoutMs: 1_000, - runId: "run-claude-plugin-skills-prompt-materialization-fallback", - config: createCliBackendConfig(), - skillsSnapshot: { - prompt: [ - "", - " ", - " weather", - " Use weather tools for forecasts.", - ` ${skillFilePath}`, - " ", - "", - ].join("\n"), - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Use weather tools for forecasts.", - filePath: skillFilePath, - baseDir: skillDir, - source: "test", - sourceInfo: { - path: skillDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: skillDir, - }, - disableModelInvocation: false, - }, - ], - }, - }); - - expect(context.systemPrompt).toContain(""); - expect(context.systemPrompt).toContain("weather"); - expect(context.systemPromptReport.skills.promptChars).toBeGreaterThan(0); - expect(context.claudeSkillsPluginArgs).toEqual([]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); } }); it("does not probe the transcript for non-claude-cli providers", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const transcriptCheck = vi.fn(async () => false); - setCliRunnerPrepareTestDeps({ - claudeCliSessionTranscriptHasContent: transcriptCheck, - }); + const { dir } = fixture.session; + const transcriptCheck = vi.fn(async () => false); + setCliRunnerPrepareTestDeps({ + claudeCliSessionTranscriptHasContent: transcriptCheck, + }); - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-77011-other-provider", - cliSessionBinding: { sessionId: "test-cli-sid", cwdHash: hashCliSessionText(dir) }, - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + cliSessionBinding: { sessionId: "test-cli-sid", cwdHash: hashCliSessionText(dir) }, + }); - expect(transcriptCheck).not.toHaveBeenCalled(); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "test-cli-sid" }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(transcriptCheck).not.toHaveBeenCalled(); + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "test-cli-sid" }); }); - it("uses a larger automatic reseed history cap for Claude CLI", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - }, - }, - ], - }); - - const summaryMarker = "RESEED_SUMMARY_MARKER_KEEP"; - const padding = "x".repeat(40_000); - fs.appendFileSync( - sessionFile, - `${JSON.stringify({ - type: "compaction", - summary: `${summaryMarker} ${padding}`, - })}\n`, - "utf-8", - ); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "claude-haiku-3-5", - timeoutMs: 1_000, - runId: "run-auto-claude-reseed-history-chars", - config: createCliBackendConfig(), - }); - - expect(context.openClawHistoryPrompt).toBeDefined(); - expect(context.openClawHistoryPrompt).toContain(summaryMarker); - expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); + it.each([ + { + name: "uses a larger automatic reseed history cap for Claude CLI", + provider: "claude-cli", + model: "claude-haiku-3-5", + marker: "RESEED_SUMMARY_MARKER_KEEP", + padding: 40_000, + expectsTruncation: false, + }, + { + name: "uses the plan-safe Claude CLI cap before mapping canonical models to CLI aliases", + provider: "claude-cli", + model: "claude-opus-4-8", + modelAliases: { "claude-opus-4-8": "opus" }, + marker: "RESEED_ALIAS_SUMMARY_MARKER_KEEP", + padding: 40_000, + expectsTruncation: false, + }, + { + name: "keeps the default reseed history cap for non-Claude CLI backends", + provider: "test-cli", + model: "test-model", + marker: "RESEED_SUMMARY_MARKER_DEFAULT", + padding: 20_000, + expectsTruncation: true, + }, + ])("$name", async (testCase) => { + const { sessionFile } = fixture.session; + if (testCase.provider === "claude-cli") { + setCliBackendForPrepareTest({ modelAliases: testCase.modelAliases }); } - }); + fs.appendFileSync( + sessionFile, + `${JSON.stringify({ + type: "compaction", + summary: `${testCase.marker} ${"x".repeat(testCase.padding)}`, + })}\n`, + "utf-8", + ); - it("uses the plan-safe Claude CLI cap before mapping canonical models to CLI aliases", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - modelAliases: { - "claude-opus-4-8": "opus", - }, - }, - }, - ], - }); + const context = await fixture.prepare({ + provider: testCase.provider, + model: testCase.model, + }); - const summaryMarker = "RESEED_ALIAS_SUMMARY_MARKER_KEEP"; - const padding = "x".repeat(40_000); - fs.appendFileSync( - sessionFile, - `${JSON.stringify({ - type: "compaction", - summary: `${summaryMarker} ${padding}`, - })}\n`, - "utf-8", - ); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "claude-opus-4-8", - timeoutMs: 1_000, - runId: "run-auto-claude-alias-reseed-history-chars", - config: createCliBackendConfig(), - }); - - expect(context.openClawHistoryPrompt).toBeDefined(); - expect(context.openClawHistoryPrompt).toContain(summaryMarker); - expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it("keeps the default reseed history cap for non-Claude CLI backends", async () => { - const { dir, sessionFile } = createSessionFile(); - try { - const summaryMarker = "RESEED_SUMMARY_MARKER_DEFAULT"; - const padding = "x".repeat(20_000); - fs.appendFileSync( - sessionFile, - `${JSON.stringify({ - type: "compaction", - summary: `${summaryMarker} ${padding}`, - })}\n`, - "utf-8", - ); - - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "test-cli", - model: "test-model", - timeoutMs: 1_000, - runId: "run-default-reseed-history-chars", - config: createCliBackendConfig(), - }); - - expect(context.openClawHistoryPrompt).toBeDefined(); + expect(context.openClawHistoryPrompt).toBeDefined(); + if (testCase.expectsTruncation) { expect(context.openClawHistoryPrompt).toContain("OpenClaw reseed history truncated"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); + } else { + expect(context.openClawHistoryPrompt).toContain(testCase.marker); + expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated"); } }); it("uses the automatic Claude CLI cap through the raw-tail reseed path", async () => { - const { dir, sessionFile } = createSessionFile(); - cliBackendsTesting.setDepsForTest({ - resolvePluginSetupCliBackend: () => undefined, - resolveRuntimeCliBackends: () => [ - { - id: "claude-cli", - pluginId: "anthropic", - bundleMcp: false, - config: { - command: "claude", - args: ["--print"], - output: "jsonl", - input: "stdin", - sessionMode: "existing", - reseedFromRawTranscriptWhenUncompacted: true, - }, - }, - ], + const { dir } = fixture.session; + setRawCliBackendForPrepareTest({ + id: "claude-cli", + pluginId: "anthropic", + bundleMcp: false, + config: { + command: "claude", + args: ["--print"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + reseedFromRawTranscriptWhenUncompacted: true, + }, }); setCliRunnerPrepareTestDeps({ claudeCliSessionTranscriptHasContent: vi.fn(async () => true), }); const recentMarker = "RAW_RESEED_RECENT_MARKER_KEEP"; const padding = "x".repeat(8_000); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-1", parentId: null, timestamp: new Date(1).toISOString(), message: { role: "user", content: `EARLIEST_USER ${padding}`, timestamp: 1 }, }); - appendTranscriptEntry(sessionFile, { + fixture.appendTranscript({ id: "msg-2", parentId: "msg-1", timestamp: new Date(2).toISOString(), @@ -5117,28 +3752,17 @@ describe("prepareCliRunContext", () => { }, }); - try { - const context = await prepareCliRunContext({ - sessionId: "session-test", - sessionFile, - workspaceDir: dir, - prompt: "latest ask", - provider: "claude-cli", - model: "claude-haiku-3-5", - timeoutMs: 1_000, - runId: "run-raw-reseed-cap-override", - cliSessionBinding: { sessionId: "cli-session", cwdHash: hashCliSessionText(dir) }, - config: createCliBackendConfig(), - }); + const context = await fixture.prepare({ + provider: "claude-cli", + model: "claude-haiku-3-5", + cliSessionBinding: { sessionId: "cli-session", cwdHash: hashCliSessionText(dir) }, + }); - expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); - expect(context.openClawHistoryPrompt).toBeDefined(); - expect(context.openClawHistoryPrompt).toContain(recentMarker); - expect(context.openClawHistoryPrompt).toContain("EARLIEST_USER"); - expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated"); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } + expect(context.reusableCliSession).toEqual({ mode: "reuse", sessionId: "cli-session" }); + expect(context.openClawHistoryPrompt).toBeDefined(); + expect(context.openClawHistoryPrompt).toContain(recentMarker); + expect(context.openClawHistoryPrompt).toContain("EARLIEST_USER"); + expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated"); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */