From f5aba5443734a160d90e034acac31097203791d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 19:39:16 -0700 Subject: [PATCH] refactor(agents): split Claude live sessions by concept (#121566) * refactor(agents): split claude-live-session into concept modules * refactor(agents): delete duplicated live-session helpers * fix(agents): fence live-session close during pending spawn * chore(lint): ratchet max-lines baseline after live-session split * style(agents): satisfy lint on live-session split modules * fix(agents): fence live session close state * refactor(agents): extract Claude live turn timeouts * fix(agents): preserve exec policy after Claude live split * test(agents): complete Claude live policy session fixture --- config/max-lines-baseline.txt | 1 - scripts/lib/ci-node-test-plan.mts | 8 +- ...cli-runner.before-agent-reply-cron.test.ts | 30 +- src/agents/cli-runner.helpers.test.ts | 30 - src/agents/cli-runner.spawn.test.ts | 3126 +---------------- src/agents/cli-runner.test-helpers.ts | 15 - src/agents/cli-runner.test-support.ts | 4 +- src/agents/cli-runner.ts | 9 +- ...s => claude-live-background-tasks.test.ts} | 464 +-- .../claude-live-process-approval.test.ts | 449 +++ .../cli-runner/claude-live-process.test.ts | 932 +++++ src/agents/cli-runner/claude-live-process.ts | 644 ++++ .../cli-runner/claude-live-registry.test.ts | 970 +++++ src/agents/cli-runner/claude-live-registry.ts | 183 + .../claude-live-session-policy.test.ts | 32 +- .../cli-runner/claude-live-session-policy.ts | 12 + .../claude-live-session.capability.test.ts | 188 - .../claude-live-session.test-support.ts | 35 +- .../cli-runner/claude-live-session.test.ts | 536 +++ src/agents/cli-runner/claude-live-session.ts | 1922 +--------- .../claude-live-turn-diagnostics.test.ts | 495 +++ .../cli-runner/claude-live-turn-timeouts.ts | 136 + .../cli-runner/claude-live-turn.test.ts | 975 +++++ src/agents/cli-runner/claude-live-turn.ts | 635 ++++ src/agents/cli-runner/execute-process.ts | 4 +- .../cli-runner/execute-tool-tracking.ts | 4 +- src/agents/cli-runner/execute.ts | 12 +- .../helpers.system-prompt-resume.test.ts | 54 +- src/agents/cli-runner/helpers.ts | 28 - src/agents/cli-runner/prepare.test.ts | 4 +- src/agents/cli-runner/prepare.ts | 6 +- .../command/attempt-execution.cli.test.ts | 16 +- src/agents/command/attempt-execution.ts | 4 +- src/gateway/gateway-cli-backend.live.test.ts | 12 +- 34 files changed, 6225 insertions(+), 5750 deletions(-) rename src/agents/cli-runner/{claude-live-session.background-tasks.test.ts => claude-live-background-tasks.test.ts} (56%) create mode 100644 src/agents/cli-runner/claude-live-process-approval.test.ts create mode 100644 src/agents/cli-runner/claude-live-process.test.ts create mode 100644 src/agents/cli-runner/claude-live-process.ts create mode 100644 src/agents/cli-runner/claude-live-registry.test.ts create mode 100644 src/agents/cli-runner/claude-live-registry.ts delete mode 100644 src/agents/cli-runner/claude-live-session.capability.test.ts create mode 100644 src/agents/cli-runner/claude-live-session.test.ts create mode 100644 src/agents/cli-runner/claude-live-turn-diagnostics.test.ts create mode 100644 src/agents/cli-runner/claude-live-turn-timeouts.ts create mode 100644 src/agents/cli-runner/claude-live-turn.test.ts create mode 100644 src/agents/cli-runner/claude-live-turn.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 5374fdc5afbc..7cfa518e7199 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -352,7 +352,6 @@ src/agents/cli-auth-epoch.test.ts src/agents/cli-runner.reliability.test.ts src/agents/cli-runner.spawn.test.ts src/agents/cli-runner.ts -src/agents/cli-runner/claude-live-session.ts src/agents/cli-runner/execute.supervisor-capture.test.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/prepare.ts diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index 56c42f2f08a7..427d3f8247b5 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -125,8 +125,10 @@ const COMPACT_GROUP_SECONDS_HINTS = new Map([ // Reliability's runtime-free provider check dropped its wall time from // ~245s to ~5s; the narrow anthropic cli-api artifact removes the same // full-barrel evaluation for the remaining facade importers (spawn). + // The live-session extraction rebalanced these stripes without changing the + // fleet-scale import wall that dominates each compact group. ["agentic-agents-core-runner-cli-1", 8], - ["agentic-agents-core-runner-cli-2", 9], + ["agentic-agents-core-runner-cli-2", 8], ["agentic-agents-core-runner-cli-3", 8], ["agentic-agents-core-runner-commands", 27], ["agentic-agents-core-runner-embedded", 20], @@ -143,7 +145,7 @@ const COMPACT_GROUP_SECONDS_HINTS = new Map([ ["agentic-agents-embedded-incomplete-turn", 146], ["agentic-agents-embedded-overflow-compaction", 150], ["agentic-agents-embedded-run", 30], - ["agentic-agents-support", 105], + ["agentic-agents-support", 110], ["agentic-agents-tools", 42], ["agentic-cli", 72], ["agentic-command-support", 41], @@ -228,7 +230,7 @@ const STRIPE_FILE_SECONDS_HINTS = new Map([ ["src/agents/cli-runner.context-engine.test.ts", 6], // Fresh profile: 5.1s total, 3.8s import; retain a conservative packing hint. ["src/agents/cli-runner.reliability.test.ts", 8], - ["src/agents/cli-runner.spawn.test.ts", 18], + ["src/agents/cli-runner.spawn.test.ts", 45], ["src/auto-reply/reply/commands-export-session.test.ts", 8], ["src/auto-reply/reply/commands-gating.test.ts", 6], ["src/auto-reply/reply/commands-learn.test.ts", 8], diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index 6c1decf37584..d314b1e67659 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -38,7 +38,7 @@ const { runBeforeAgentRunMock, executePreparedCliRunMock, prepareCliRunContextMock, - closeClaudeLiveSessionForContextMock, + closeClaudeSessionMock, closeMcpLoopbackServerMock, retireSessionMcpRuntimeForSessionKeyMock, retireSessionMcpRuntimeMock, @@ -55,7 +55,7 @@ const { (_context: unknown, _cliSessionIdToUse?: string) => Promise >(async () => ({ text: "" })), prepareCliRunContextMock: vi.fn(), - closeClaudeLiveSessionForContextMock: vi.fn(), + closeClaudeSessionMock: vi.fn(), closeMcpLoopbackServerMock: vi.fn(), retireSessionMcpRuntimeForSessionKeyMock: vi.fn(), retireSessionMcpRuntimeMock: vi.fn(), @@ -80,11 +80,14 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({ executePreparedCliRun: executePreparedCliRunMock, })); -vi.mock("./cli-runner/claude-live-session.js", () => ({ - closeClaudeLiveSessionForContext: closeClaudeLiveSessionForContextMock, - getClaudeLiveSessionGenerationForOwner: vi.fn(() => undefined), - hasClaudeLiveSessionForOwner: vi.fn(() => false), - shouldUseClaudeLiveSession: vi.fn(() => false), +vi.mock("./cli-runner/claude-live-registry.js", () => ({ + closeClaudeSession: closeClaudeSessionMock, + getClaudeGeneration: vi.fn(() => undefined), + hasClaudeSession: vi.fn(() => false), +})); + +vi.mock("./cli-runner/claude-live-session-policy.js", () => ({ + acceptsClaudeLive: vi.fn(() => false), })); vi.mock("../gateway/mcp-http.js", () => ({ @@ -167,7 +170,7 @@ beforeEach(() => { prepareCliRunContextMock.mockImplementation(async (params) => makeStubContext(params as typeof baseRunParams & { trigger?: string }), ); - closeClaudeLiveSessionForContextMock.mockReset(); + closeClaudeSessionMock.mockReset(); closeMcpLoopbackServerMock.mockReset(); retireSessionMcpRuntimeForSessionKeyMock.mockReset(); retireSessionMcpRuntimeForSessionKeyMock.mockResolvedValue(true); @@ -493,7 +496,7 @@ describe("runCliAgent before_agent_reply seam", () => { }); expect(error).toMatchObject({ message: "CLI process failed" }); - expect(closeClaudeLiveSessionForContextMock).toHaveBeenCalledTimes(1); + expect(closeClaudeSessionMock).toHaveBeenCalledTimes(1); expect(events.find((event) => event.type === "harness.run.error")).toMatchObject({ type: "harness.run.error", phase: "send", @@ -520,9 +523,7 @@ describe("runCliAgent before_agent_reply seam", () => { it("classifies a surfaced outer cleanup failure as cleanup", async () => { executePreparedCliRunMock.mockResolvedValueOnce({ text: "real Claude reply" }); - closeClaudeLiveSessionForContextMock.mockRejectedValueOnce( - new Error("managed session cleanup failed"), - ); + closeClaudeSessionMock.mockRejectedValueOnce(new Error("managed session cleanup failed")); const { error, events } = await captureRejectedClaudeRun({ ...baseRunParams, @@ -822,12 +823,13 @@ describe("runCliAgent before_agent_reply seam", () => { await runCliAgent({ ...baseRunParams, cleanupCliLiveSessionOnRunEnd: true }); expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1); - expect(closeClaudeLiveSessionForContextMock).toHaveBeenCalledTimes(1); - expect(closeClaudeLiveSessionForContextMock).toHaveBeenCalledWith( + expect(closeClaudeSessionMock).toHaveBeenCalledTimes(1); + expect(closeClaudeSessionMock).toHaveBeenCalledWith( await expectDefined( prepareCliRunContextMock.mock.results[0], "prepareCliRunContextMock.mock.results[0] test invariant", ).value, + "restart", ); }); diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index 51787f7adee8..cea6ec6299ba 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -13,7 +13,6 @@ import { escapeRegExp } from "../shared/regexp.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { buildCliArgs, - buildClaudeOwnerKey, prepareCliPromptImagePayload, resolveCliRunQueueKey, writeCliSystemPromptFile, @@ -740,32 +739,3 @@ describe("resolveCliRunQueueKey", () => { ).toBe("claude-cli:owner:abcd1234"); }); }); - -describe("buildClaudeOwnerKey", () => { - it("is deterministic and distinguishes session keys", () => { - const base = { - agentAccountId: "acct-1", - agentId: "agent-main", - authProfileId: "profile-a", - sessionId: "sess-1", - sessionKey: "key-a", - }; - const a1 = buildClaudeOwnerKey(base); - const a2 = buildClaudeOwnerKey(base); - expect(a1).toBe(a2); - const b = buildClaudeOwnerKey({ ...base, sessionKey: "key-b" }); - expect(a1).not.toBe(b); - }); - - it("matches the legacy buildClaudeLiveKey hash for a frozen fixture (DO NOT EDIT — splits queue from live-session map)", () => { - expect( - buildClaudeOwnerKey({ - agentAccountId: "acct-1", - agentId: "agent-main", - authProfileId: "profile-a", - sessionId: "sess-1", - sessionKey: "key-a", - }), - ).toBe("718b9a6cf473526c3c357883dfc8f1da1cf90b709d9ed38d675b52314abe6800"); - }); -}); diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 92ea249ce0fc..c08c016370a6 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -2,12 +2,9 @@ import fs from "node:fs/promises"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js"; -import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import { markMcpLoopbackToolCallFinished, markMcpLoopbackToolCallStarted, @@ -16,19 +13,15 @@ import { import { invokeNodeClaudeCliRun } from "../gateway/node-agent-cli-runtime.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { - onInternalDiagnosticEvent, onTrustedToolExecutionEvent, setDiagnosticsEnabledForProcess, waitForDiagnosticEventsDrained, } from "../infra/diagnostic-events.js"; -import { PLUGIN_APPROVAL_DETAIL_MAX_LENGTH } from "../infra/plugin-approvals.js"; import { - getDiagnosticSessionActivitySnapshot, resetDiagnosticRunActivityForTest, startDiagnosticRunActivityTracking, } from "../logging/diagnostic-run-activity.js"; import type { getProcessSupervisor } from "../process/supervisor/index.js"; -import type { RunExit } from "../process/supervisor/types.js"; import { registerExecApprovalRequestForHostOrThrow, resolveRegisteredExecApprovalDecision, @@ -38,25 +31,18 @@ import { resolveBootstrapContextForRun as realResolveBootstrapContextForRun, } from "./bootstrap-files.js"; import { - buildClaudeControlRequestEvents, - buildClaudeLiveBackend, buildClaudeLiveRunContext, buildPreparedCliRunContext, captureModelCallDiagnostics, createClaudeInputStartedEvent, - createCancelableLiveRunLifecycle, expectPathMissing, expectRejectsWithFields, - expectClaudeControlDecision, expectModelCallTypes, mockCallArg, mockClaudeLiveRun, requireArgAfter, requireRecord, requireRegexMatch, - withTempExecApprovalsState, - withTempOpenClawHome, - type PreparedCliRunContextOverrides, } from "./cli-runner.test-helpers.js"; import { createManagedRun, @@ -64,14 +50,7 @@ import { restoreCliRunnerPrepareTestDeps, supervisorSpawnMock, } from "./cli-runner.test-support.js"; -import { - getClaudeLiveSessionGenerationForOwner, - runClaudeLiveSessionTurn, -} from "./cli-runner/claude-live-session.js"; -import { - buildClaudeLiveArgs, - resetClaudeLiveSessionsForTest, -} from "./cli-runner/claude-live-session.test-support.js"; +import { resetClaudeLiveSessionsForTest } from "./cli-runner/claude-live-session.test-support.js"; import { attachCliMessagingDeliveryEvidence, getCliMessagingDeliveryEvidence, @@ -86,8 +65,6 @@ import { buildCliAgentSystemPrompt, writeCliSystemPromptFile } from "./cli-runne import { cliBackendLog, formatCliBackendOutputDigest } from "./cli-runner/log.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.test-support.js"; import type { PreparedCliRunContext } from "./cli-runner/types.js"; -import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js"; -import { callGatewayTool } from "./tools/gateway.js"; // Gateway unit coverage owns quiet-admission timing. These spawn cases only // need to drain calls already in flight, so skip the repeated 250 ms quiet window. @@ -111,15 +88,6 @@ vi.mock("../plugin-sdk/anthropic-cli.js", () => ({ isClaudeCliProvider: (providerId: string) => providerId === "claude-cli", })); -vi.mock("./tools/gateway.js", () => ({ - callGatewayTool: vi.fn(), -})); - -const mockCallGatewayTool = vi.mocked(callGatewayTool); - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { const event = createClaudeInputStartedEvent(data); if (event) { @@ -127,28 +95,12 @@ function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, d } } -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); resetAgentEventsForTest(); resetDiagnosticRunActivityForTest(); startDiagnosticRunActivityTracking(); resetClaudeLiveSessionsForTest(); - replyRunTesting.resetReplyRunRegistry(); restoreCliRunnerPrepareTestDeps(); setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile, @@ -157,8 +109,6 @@ beforeEach(() => { resolveRegisteredExecApprovalDecision, }); supervisorSpawnMock.mockClear(); - mockCallGatewayTool.mockReset(); - mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" }); }); afterEach(() => { @@ -166,7 +116,6 @@ afterEach(() => { vi.useRealTimers(); resetDiagnosticRunActivityForTest(); resetClaudeLiveSessionsForTest(); - replyRunTesting.resetReplyRunRegistry(); }); const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`; @@ -2165,234 +2114,6 @@ describe("runCliAgent spawn path", () => { } }); - it("reuses a Claude live session process across turns", async () => { - const logInfoSpy = vi.spyOn(cliBackendLog, "info").mockImplementation(() => undefined); - const agentEvents: unknown[] = []; - const stop = onAgentEvent((evt) => { - if (evt.stream === "assistant") { - agentEvents.push(evt.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"; - 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 = buildClaudeLiveRunContext({ - prompt: "first", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-one.json"], - resumeArgs: [ - "-p", - "--resume", - "{sessionId}", - "--strict-mcp-config", - "--mcp-config", - "/tmp/mcp-one.json", - ], - }, - mcpConfigHash: "same-mcp-config", - }); - const first = await executePreparedCliRun(firstContext); - const liveGeneration = getClaudeLiveSessionGenerationForOwner({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(liveGeneration).toBeDefined(); - const secondContext = buildClaudeLiveRunContext({ - prompt: "second", - backend: { - args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-two.json"], - resumeArgs: [ - "-p", - "--resume", - "{sessionId}", - "--strict-mcp-config", - "--mcp-config", - "/tmp/mcp-two.json", - ], - }, - mcpConfigHash: "same-mcp-config", - }); - secondContext.requiredClaudeLiveSessionGeneration = liveGeneration; - const second = await executePreparedCliRun(secondContext, "live-session-1"); - - const changedContext = buildClaudeLiveRunContext({ - model: "opus", - prompt: "changed", - backend: { - args: ["-p"], - resumeArgs: ["-p", "--resume", "{sessionId}"], - }, - mcpConfigHash: "same-mcp-config", - }); - changedContext.requiredClaudeLiveSessionGeneration = liveGeneration; - await expect(executePreparedCliRun(changedContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_changed", - }); - - const spawnInput = mockCallArg(supervisorSpawnMock) as { - argv?: string[]; - stdinMode?: string; - }; - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(spawnInput.stdinMode).toBe("pipe-open"); - expect(spawnInput.argv).toContain("--input-format"); - expect(spawnInput.argv).toContain("--output-format"); - expect(spawnInput.argv).toContain("stream-json"); - expect(spawnInput.argv).toContain("--replay-user-messages"); - expect(spawnInput.argv).not.toContain("--session-id"); - expect(spawnInput.argv).toContain("/tmp/mcp-one.json"); - expect( - live.writes.map( - (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, - ), - ).toEqual(["first", "second"]); - expect(agentEvents).toEqual([ - { text: "one", delta: "one" }, - { text: "two", delta: "two" }, - ]); - const turnLogs = logInfoSpy.mock.calls - .map(([message]) => message) - .filter((message) => message.startsWith("claude live session turn:")); - expect(turnLogs).toHaveLength(2); - expect(turnLogs[0]).toContain("outBytes=3 outHash=7692c3ad3540"); - expect(turnLogs[1]).toContain("outBytes=3 outHash=3fc4ccfe7458"); - expect(turnLogs.join("\n")).not.toContain("one"); - expect(turnLogs.join("\n")).not.toContain("two"); - } finally { - logInfoSpy.mockRestore(); - stop(); - } - }); - - it("requires the exact warm Claude process even without native resume args", async () => { - const liveRuns = Array.from({ length: 3 }, () => - mockClaudeLiveRun(supervisorSpawnMock, { - pid: 2346, - events: [ - { type: "system", subtype: "init", session_id: "live-session-1" }, - { type: "result", session_id: "live-session-1", result: "one" }, - ], - }), - ); - - const firstContext = buildPreparedCliRunContext({ - prompt: "first", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - expect((await executePreparedCliRun(firstContext)).text).toBe("one"); - const liveGeneration = getClaudeLiveSessionGenerationForOwner({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(liveGeneration).toBeDefined(); - - resetClaudeLiveSessionsForTest(); - const missingContext = buildPreparedCliRunContext({ - prompt: "second", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - missingContext.requiredClaudeLiveSessionGeneration = liveGeneration; - - await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_missing", - }); - - const replacementContext = buildPreparedCliRunContext({ - prompt: "replacement", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - expect((await executePreparedCliRun(replacementContext)).text).toBe("one"); - await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_changed", - }); - missingContext.openClawHistoryPrompt = "bounded OpenClaw history\n\nsecond"; - expect((await executePreparedCliRun(missingContext)).text).toBe("one"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expect( - (JSON.parse(liveRuns[2]?.writes.at(-1) ?? "") as { message: { content: string } }).message - .content, - ).toBe("bounded OpenClaw history\n\nsecond"); - }); - - it("keeps pre-tool commentary out of an empty-result Claude live reply", async () => { - const agentEvents: Array<{ stream: string; data: unknown }> = []; - const stop = onAgentEvent((event) => { - agentEvents.push({ stream: event.stream, data: event.data }); - }); - 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( - buildClaudeLiveRunContext({ - emitCommentaryText: true, - }), - ); - - expect(result.text).toBe("Final answer."); - expect(agentEvents).toContainEqual({ - stream: "item", - data: expect.objectContaining({ - kind: "preamble", - progressText: "Let me check.", - }), - }); - expect(agentEvents).toContainEqual({ - stream: "assistant", - data: { text: "Final answer.", delta: "Final answer." }, - }); - } finally { - stop(); - } - }); - it("extends the live no-output watchdog to the blocked-tool floor while a tool is outstanding", async () => { const toolErrorEvents: Array> = []; const stopDiagnostics = onTrustedToolExecutionEvent((event) => { @@ -2665,2851 +2386,6 @@ describe("runCliAgent spawn path", () => { expect(getCliMessagingDeliveryEvidence(frozen)?.didSendViaMessagingTool).toBe(true); }); - it("accepts Claude live stream-json lines larger than 256 KiB", async () => { - const largeText = "x".repeat(270 * 1024); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [{ type: "result", session_id: "live-session-large", result: largeText }], - }); - - const result = await executePreparedCliRun(buildClaudeLiveRunContext()); - - expect(result.text).toHaveLength(largeText.length); - expect(result.text).toBe(largeText); - }); - - it("frames coalesced Claude live image and PDF records before omitting retained bytes", async () => { - const toolResults: unknown[] = []; - const stop = onAgentEvent((event) => { - if (event.stream === "tool" && event.data.phase === "result") { - toolResults.push(event.data.result); - } - }); - const base64 = "a".repeat(4_300_000); - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit }) => { - const events: Record[] = [ - { type: "system", subtype: "init", session_id: "live-binary-results" }, - ]; - for (const [type, mediaType] of [ - ["image", "image/png"], - ["document", "application/pdf"], - ] as const) { - events.push( - { - type: "assistant", - session_id: "live-binary-results", - message: { - role: "assistant", - content: [{ type: "tool_use", id: `read-${type}`, name: "Read", input: {} }], - }, - }, - { - type: "user", - session_id: "live-binary-results", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: `read-${type}`, - content: [ - { type: "text", text: `Read ${type}` }, - { type, source: { type: "base64", media_type: mediaType, data: base64 } }, - ], - }, - ], - }, - }, - ); - } - events.push({ - type: "result", - session_id: "live-binary-results", - result: "both files read", - }); - emit(events); - }, - }); - - try { - const result = await executePreparedCliRun(buildClaudeLiveRunContext()); - - expect(result.text).toBe("both files read"); - expect(toolResults).toEqual([ - [ - { type: "text", text: "Read image" }, - { - type: "image", - source: { type: "base64", media_type: "image/png" }, - omitted: true, - bytes: 3_225_000, - }, - ], - [ - { type: "text", text: "Read document" }, - { - type: "document", - source: { type: "base64", media_type: "application/pdf" }, - omitted: true, - bytes: 3_225_000, - }, - ], - ]); - } finally { - stop(); - } - }); - - it.each([ - { - name: "an oversized complete line", - chunks: () => [`${"a".repeat(8 * 1024 * 1024 + 1)}\n`], - }, - { - name: "an oversized growing unterminated line", - chunks: () => ["a".repeat(4_300_000), "a".repeat(4_300_000)], - }, - ])("rejects $name from Claude live stdout", async ({ chunks }) => { - const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => { - for (const chunk of chunks()) { - live.spawnInput.onStdout?.(chunk); - } - }, - }); - - await expect(executePreparedCliRun(buildClaudeLiveRunContext())).rejects.toThrow( - "Claude CLI JSONL line exceeded output limit.", - ); - }); - - it.each([ - { - name: "a coalesced blank-frame flood", - createChunk: () => "\n".repeat(20_001), - }, - { - name: "whitespace-only records exceeding the raw budget", - createChunk: () => `${" ".repeat(4_300_000)}\n${" ".repeat(4_300_000)}\n`, - }, - { - name: "valid JSON padded beyond the raw budget", - createChunk: () => `${" ".repeat(4_300_000)}{}\n${" ".repeat(4_300_000)}{}\n`, - }, - { - name: "internal formatting around compacted Claude media", - createChunk: () => { - const line = JSON.stringify({ - type: "user", - message: { - content: [ - { - type: "tool_result", - tool_use_id: "padded-live-image", - content: [ - { - type: "image", - source: { type: "base64", media_type: "image/png", data: "YQ==" }, - }, - ], - }, - ], - }, - }).replace('"message":', `"message":${" ".repeat(4_300_000)}`); - return `${line}\n${line}\n`; - }, - }, - ])("rejects $name from the managed Claude live session", async ({ createChunk }) => { - const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => { - live.spawnInput.onStdout?.(createChunk()); - }, - }); - - await expect(executePreparedCliRun(buildClaudeLiveRunContext())).rejects.toThrow( - "Claude CLI turn output exceeded limit.", - ); - }); - - it("reports Claude live session reply backends as streaming until the turn finishes", async () => { - let markWriteReady: (() => void) | undefined; - const writeReady = new Promise((resolve) => { - markWriteReady = resolve; - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: () => { - markWriteReady?.(); - }, - }); - const operation = createReplyOperation({ - sessionKey: "agent:main:main", - sessionId: "live-session-reply", - resetTriggered: false, - }); - operation.setPhase("running"); - const context = buildClaudeLiveRunContext({ - sessionId: "live-session-reply", - sessionKey: "agent:main:main", - prompt: "hello", - }); - - const run = executePreparedCliRun({ - ...context, - params: { - ...context.params, - replyOperation: operation, - }, - }); - - await writeReady; - 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"); - operation.complete(); - }); - - it("reuses a Claude live session when resumed turns omit the system prompt arg", async () => { - let turn = 0; - mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ emit }) => { - turn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-system" }, - { type: "result", session_id: "live-system", result: turn === 1 ? "one" : "two" }, - ]); - }, - }); - - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - }; - const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "first", - backend, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "second", - backend, - }), - "live-system", - ); - - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("refreshes a reused Claude live session when only dynamic prompt context changes", async () => { - let userTurn = 0; - let controlRequest = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { - type: string; - request_id?: string; - request?: { - subtype?: string; - model?: string; - system_prompt?: string; - }; - }; - if (parsed.type === "control_request") { - controlRequest += 1; - if (controlRequest === 1) { - expect(parsed.request).toEqual({ - subtype: "set_model", - model: "sonnet", - system_prompt: "", - }); - emit([ - { - type: "control_response", - response: { - subtype: "error", - request_id: parsed.request_id, - error: "set_model: system_prompt must be a non-empty string when present", - }, - }, - ]); - return; - } - expect(parsed.request).toEqual({ - subtype: "set_model", - model: "sonnet", - system_prompt: - "# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.\nSecond-turn metadata", - }); - emit([ - { - type: "control_response", - response: { - subtype: "success", - request_id: parsed.request_id, - }, - }, - ]); - return; - } - userTurn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-dynamic-prompt" }, - { - type: "result", - session_id: "live-dynamic-prompt", - result: userTurn === 1 ? "one" : "two", - }, - ]); - }, - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - const first = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - prompt: "first", - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}First-turn metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - prompt: "second", - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}Second-turn metadata`, - }), - "live-dynamic-prompt", - ); - - expect(first.text).toBe("one"); - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ - "user", - "control_request", - "control_request", - "user", - ]); - }); - - it("serializes direct live turns before refreshing their system prompts", async () => { - let userTurn = 0; - let releaseCapabilityProbe: (() => void) | undefined; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { - type: string; - request_id?: string; - request?: { system_prompt?: string }; - }; - if (parsed.type === "control_request") { - if (parsed.request?.system_prompt === "") { - releaseCapabilityProbe = () => { - emit([ - { - type: "control_response", - response: { - subtype: "error", - request_id: parsed.request_id, - error: "set_model: system_prompt must be a non-empty string when present", - }, - }, - ]); - }; - return; - } - emit([ - { - type: "control_response", - response: { - subtype: "success", - request_id: parsed.request_id, - }, - }, - ]); - return; - } - userTurn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-serialized-refresh" }, - { - type: "result", - session_id: "live-serialized-refresh", - result: `turn-${userTurn}`, - }, - ]); - }, - }); - const backend = { - args: ["-p", "--output-format", "stream-json"], - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - const getProcessSupervisorForTest = () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }); - const runTurn = ( - systemPrompt: string, - prompt: string, - useResume: boolean, - abortSignal?: AbortSignal, - cleanup: () => Promise = async () => {}, - ) => { - const context = buildPreparedCliRunContext({ backend, prompt, systemPrompt }); - context.params.abortSignal = abortSignal; - return runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt, - useResume, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup, - }); - }; - - await expect( - runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, "first", false), - ).resolves.toMatchObject({ output: { text: "turn-1" } }); - - const second = runTurn( - `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - "second", - true, - ); - await vi.waitFor(() => expect(releaseCapabilityProbe).toBeTypeOf("function")); - const queuedAbort = new AbortController(); - const abortedCleanup = vi.fn(async () => {}); - const third = runTurn( - `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Third metadata`, - "third", - true, - queuedAbort.signal, - abortedCleanup, - ); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); - queuedAbort.abort(); - await expect(third).rejects.toMatchObject({ name: "AbortError" }); - expect(abortedCleanup).toHaveBeenCalledOnce(); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); - releaseCapabilityProbe?.(); - - await expect(second).resolves.toMatchObject({ output: { text: "turn-2" } }); - await expect( - runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Fourth metadata`, "fourth", true), - ).resolves.toMatchObject({ output: { text: "turn-3" } }); - expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ - "user", - "control_request", - "control_request", - "user", - "control_request", - "user", - ]); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("restarts Claude live sessions when a multi-section stable prompt changes", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-stable-prompt" }, - { type: "result", session_id: "live-stable-prompt", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-stable-prompt" }, - { type: "result", session_id: "live-stable-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nFirst instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `# OpenClaw\n\n## Stable Instructions\nSecond instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, - }), - "live-stable-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it.each([ - { - name: "ignores the system_prompt field", - responses: [{ subtype: "success" }], - }, - { - name: "rejects the live refresh", - responses: [ - { - subtype: "error", - error: "set_model: system_prompt must be a non-empty string when present", - }, - { subtype: "error", error: "unsupported" }, - ], - }, - ])("restarts when Claude $name", async ({ responses }) => { - let controlRequest = 0; - mockClaudeLiveRun(supervisorSpawnMock, { - cancelable: true, - onWrite: ({ data, emit }) => { - const parsed = JSON.parse(data) as { type: string; request_id?: string }; - if (parsed.type === "control_request") { - const response = responses[controlRequest]; - controlRequest += 1; - emit([ - { - type: "control_response", - response: { - request_id: parsed.request_id, - ...response, - }, - }, - ]); - return; - } - emit([ - { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, - { type: "result", session_id: "live-rejected-prompt", result: "one" }, - ]); - }, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, - { type: "result", session_id: "live-rejected-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - }), - "live-rejected-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(controlRequest).toBe(responses.length); - }); - - it("restarts on marker-free prompt changes instead of weakening prompt identity", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-marker-free" }, - { type: "result", session_id: "live-marker-free", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-marker-free" }, - { type: "result", session_id: "live-marker-free", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "always" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ backend, systemPrompt: "First complete prompt" }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ backend, systemPrompt: "Second complete prompt" }), - "live-marker-free", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("keeps legacy first-only system prompts on full-prompt restart identity", async () => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, - { type: "result", session_id: "live-first-only-prompt", result: "one" }, - ], - cancelable: true, - }); - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, - { type: "result", session_id: "live-first-only-prompt", result: "two" }, - ], - }); - const backend = { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], - liveSession: "claude-stdio" as const, - systemPromptWhen: "first" as const, - }; - - await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, - }), - ); - const second = await executePreparedCliRun( - buildPreparedCliRunContext({ - backend, - systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, - }), - "live-first-only-prompt", - ); - - expect(second.text).toBe("two"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("serializes concurrent Claude live session creation for the same key", async () => { - let releaseSpawn: (() => void) | undefined; - let turn = 0; - const spawnReady = new Promise((resolve) => { - releaseSpawn = resolve; - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - beforeSpawn: () => spawnReady, - onWrite: ({ emit }) => { - turn += 1; - emit([ - { type: "system", subtype: "init", session_id: "live-concurrent" }, - { - type: "result", - session_id: "live-concurrent", - result: turn === 1 ? "one" : "two", - }, - ]); - }, - }); - - const backend = { - liveSession: "claude-stdio" as const, - }; - const first = executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "first", - backend, - }), - ); - const second = executePreparedCliRun( - buildPreparedCliRunContext({ - prompt: "second", - backend, - }), - ); - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); - releaseSpawn?.(); - - const results = await Promise.all([first, second]); - expect(results.map((result) => result.text).toSorted()).toEqual(["one", "two"]); - expect(live.stdin.write).toHaveBeenCalledTimes(2); - expect(supervisorSpawnMock).toHaveBeenCalledOnce(); - }); - - it("recovers when a required warm Claude process exits during reuse cleanup", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - let resolveExit: ((exit: RunExit) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let turn = 0; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - turn += 1; - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-race" }), - JSON.stringify({ type: "result", session_id: "live-race", result: `turn-${turn}` }), - ].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 { - pid: 2350, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => exited), - cancel: vi.fn(), - }; - }); - const context = buildPreparedCliRunContext({ - prompt: "first", - backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, - }); - const getProcessSupervisorForTest = () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }); - const first = await runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "first", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - expect(first.output.text).toBe("turn-1"); - const generation = getClaudeLiveSessionGenerationForOwner({ - backendId: "claude-cli", - sessionId: "s1", - }); - expect(generation).toBeDefined(); - - let markCleanupStarted: (() => void) | undefined; - const cleanupStarted = new Promise((resolve) => { - markCleanupStarted = resolve; - }); - let releaseCleanup: (() => void) | undefined; - const cleanupReleased = new Promise((resolve) => { - releaseCleanup = resolve; - }); - const reuse = runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "second", - useResume: false, - requiredSessionGeneration: generation, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - cleanup: async () => { - markCleanupStarted?.(); - await cleanupReleased; - }, - }); - await cleanupStarted; - resolveExit?.({ - reason: "exit", - exitCode: 0, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - await vi.waitFor(() => - expect( - getClaudeLiveSessionGenerationForOwner({ backendId: "claude-cli", sessionId: "s1" }), - ).toBeUndefined(), - ); - releaseCleanup?.(); - - await expect(reuse).rejects.toMatchObject({ - reason: "session_expired", - code: "cli_live_session_missing", - }); - expect(stdin.write).toHaveBeenCalledOnce(); - }); - - it("counts pending Claude live session creates against the session cap", async () => { - let releaseSpawn: (() => void) | undefined; - const spawnReady = new Promise((resolve) => { - releaseSpawn = resolve; - }); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const spawnIndex = supervisorSpawnMock.mock.calls.length; - await spawnReady; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - input.onStdout?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: `live-cap-${spawnIndex}`, - }), - JSON.stringify({ - type: "result", - session_id: `live-cap-${spawnIndex}`, - result: `ok-${spawnIndex}`, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2300 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - const backend = { - liveSession: "claude-stdio" as const, - }; - const runs = Array.from({ length: 17 }, (_, index) => - (() => { - const context = buildPreparedCliRunContext({ - runId: `run-live-cap-${index}`, - prompt: `prompt ${index}`, - sessionId: `session-${index}`, - backend, - }); - return runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: `prompt ${index}`, - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - })(), - ); - const rejectedRun = runs[16]; - const rejectedRunExpectation = expect(rejectedRun).rejects.toThrow( - "Too many Claude CLI live sessions are active.", - ); - - await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(16)); - await rejectedRunExpectation; - releaseSpawn?.(); - await expect(Promise.all(runs.slice(0, 16))).resolves.toHaveLength(16); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(16); - }); - - it("preserves Claude resume args when building live session argv", () => { - const backend = buildClaudeLiveBackend(); - - const args = buildClaudeLiveArgs({ - args: [ - "-p", - "--output-format", - "stream-json", - "--resume", - "claude-session", - "--session-id", - "openclaw-session", - "--append-system-prompt", - "old prompt", - "--append-system-prompt-file", - "/tmp/system-prompt.md", - ], - backend, - systemPrompt: "current prompt", - useResume: true, - }); - - expect(args).toContain("--resume"); - expect(args).toContain("claude-session"); - expect(args).not.toContain("--session-id"); - expect(args).not.toContain("openclaw-session"); - expect(args).not.toContain("--append-system-prompt-file"); - expect(args).not.toContain("/tmp/system-prompt.md"); - expect(args).not.toContain("--append-system-prompt"); - expect(args).not.toContain("old prompt"); - expect(args).not.toContain("current prompt"); - }); - - it("adds Claude stream-json output format when building live session argv", () => { - const backend = buildClaudeLiveBackend({ args: ["-p"] }); - - const args = buildClaudeLiveArgs({ - args: ["-p"], - backend, - systemPrompt: "current prompt", - useResume: false, - }); - - expect(requireArgAfter(args, "--input-format")).toBe("stream-json"); - expect(requireArgAfter(args, "--output-format")).toBe("stream-json"); - expect(requireArgAfter(args, "--permission-prompt-tool")).toBe("stdio"); - }); - - it("answers Claude live control_request can_use_tool with allow when exec policy is full/no-ask", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow", - toolUseId: "tool-allow-1", - input: { command: "ls" }, - sessionId: "live-control-allow", - }), - pid: 3001, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "full", ask: "off" } } }, - }), - ); - expect(result.text).toBe("ok"); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow", - toolUseId: "tool-allow-1", - updatedInput: { command: "ls" }, - }); - }); - - it("preserves image and PDF bytes inside approved Claude live control inputs", async () => { - const input = { - command: "process media", - image: { - type: "image", - source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" }, - }, - document: { - type: "document", - source: { type: "base64", media_type: "application/pdf", data: "JVBERi0=" }, - }, - }; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow-media", - toolUseId: "tool-allow-media", - input, - sessionId: "live-control-allow-media", - }), - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "full", ask: "off" } } }, - }), - ); - - expect(result.text).toBe("ok"); - const response = expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow-media", - toolUseId: "tool-allow-media", - updatedInput: input, - }); - expect(JSON.stringify(response.response.response.updatedInput)).toBe(JSON.stringify(input)); - }); - - it("honors allow-once from a Claude native tool Gateway approval", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-allow-once", - decision: "allow-once", - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-allow-once", - toolUseId: "tool-allow-once-1", - input: { command: "ls" }, - sessionId: "live-control-allow-once", - }), - pid: 3011, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-allow-once", - toolUseId: "tool-allow-once-1", - updatedInput: { command: "ls" }, - }); - expect(mockCallGatewayTool).toHaveBeenCalledWith( - "plugin.approval.request", - expect.any(Object), - expect.objectContaining({ - pluginId: "claude-cli", - toolName: "Bash", - toolCallId: "tool-allow-once-1", - }), - { expectFinal: false }, - ); - }); - - it("sends full reviewer detail for oversized non-Bash tool input", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-bounded-detail", - decision: "allow-once", - }); - const content = `line one ${"x".repeat(500)} line end`; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-write-bounded-detail", - toolUseId: "tool-write-bounded-detail-1", - toolName: "Write", - input: { file_path: "/tmp/out.txt", content }, - sessionId: "live-control-write-bounded-detail", - }), - pid: 3012, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-write-bounded-detail", - toolUseId: "tool-write-bounded-detail-1", - updatedInput: { file_path: "/tmp/out.txt", content }, - }); - expect(mockCallGatewayTool).toHaveBeenCalledWith( - "plugin.approval.request", - expect.any(Object), - expect.objectContaining({ - detail: JSON.stringify({ file_path: "/tmp/out.txt", content }), - allowedDecisions: ["allow-once", "deny"], - }), - { expectFinal: false }, - ); - }); - - it("fails closed when a Claude native tool Gateway approval is unavailable", async () => { - mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-approval-unavailable", - toolUseId: "tool-approval-unavailable-1", - input: { command: "ls" }, - sessionId: "live-control-approval-unavailable", - }), - pid: 3013, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-approval-unavailable", - messageIncludes: "OpenClaw approval was not granted", - }); - }); - - it("denies oversized Claude Bash approval requests before calling the Gateway", async () => { - const live = mockClaudeLiveRun(supervisorSpawnMock, { - events: buildClaudeControlRequestEvents({ - requestId: "req-bash-oversized", - toolUseId: "tool-bash-oversized-1", - input: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) }, - sessionId: "live-control-bash-oversized", - }), - pid: 3014, - }); - - const result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - - expect(result.text).toBe("ok"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-bash-oversized", - messageIncludes: "too large to display", - }); - expect(mockCallGatewayTool).not.toHaveBeenCalled(); - }); - - it("reports Claude live stream progress without timer heartbeats", async () => { - vi.useFakeTimers({ - toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], - }); - vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); - const diagnosticEvents: string[] = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if (event.type === "run.progress" || event.type.startsWith("tool.execution.")) { - diagnosticEvents.push(event.type); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ - type: "system", - subtype: "init", - session_id: "live-diagnostics", - }), - JSON.stringify({ - type: "assistant", - session_id: "live-diagnostics", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-1", - name: "mcp__team__lookup", - input: { query: "status" }, - }, - { - type: "server_tool_use", - id: "tool-live-2", - name: "web_search", - input: { query: "release status" }, - }, - ], - }, - }), - ].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 { - pid: 3060, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - try { - const context = buildClaudeLiveRunContext({ - sessionId: "session-live-diagnostics", - sessionKey: "agent:main:diagnostics", - prompt: "hello", - timeoutMs: 120_000, - }); - const resultPromise = runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hello", - useResume: false, - noOutputTimeoutMs: 120_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - - await waitForDiagnosticEventsDrained(); - await vi.waitFor(() => - expect( - getDiagnosticSessionActivitySnapshot({ - sessionKey: "agent:main:diagnostics", - }).activeToolName, - ).toBe("mcp__team__lookup"), - ); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:tool_started"); - - await vi.advanceTimersByTimeAsync(10_000); - await waitForDiagnosticEventsDrained(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:tool_started"); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressAgeMs, - ).toBeGreaterThanOrEqual(10_000); - - stdoutListener?.( - [ - JSON.stringify({ - type: "user", - session_id: "live-diagnostics", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-live-1", - content: "lookup failed", - is_error: true, - }, - { - type: "tool_result", - tool_use_id: "tool-live-2", - content: "done", - }, - ], - }, - }), - JSON.stringify({ - type: "assistant", - session_id: "live-diagnostics", - message: { - role: "assistant", - content: [{ type: "text", text: "ok" }], - }, - }), - JSON.stringify({ - type: "result", - session_id: "live-diagnostics", - result: "ok", - }), - ].join("\n") + "\n", - ); - - await expect(resultPromise).resolves.toMatchObject({ output: { text: "ok" } }); - await waitForDiagnosticEventsDrained(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .activeToolName, - ).toBeUndefined(); - expect( - getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) - .lastProgressReason, - ).toBe("cli_live:result"); - expect(diagnosticEvents.filter((event) => event === "tool.execution.started")).toHaveLength( - 2, - ); - expect(diagnosticEvents).toContain("tool.execution.completed"); - expect(diagnosticEvents).toContain("tool.execution.error"); - } finally { - stopDiagnostics(); - } - }); - - it("preserves loopback policy blocks for Claude live tools", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - event.toolCallId === "tool-live-blocked" - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - let captureKey = ""; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - const captureHandle = markMcpLoopbackToolCallStarted({ - captureKey, - toolName: "message", - args: { action: "react" }, - }); - if (!captureHandle) { - throw new Error("Expected live tool capture"); - } - recordMcpLoopbackToolCallResult({ - captureHandle, - toolName: "message", - args: { action: "react" }, - outcome: "blocked", - deniedReason: "plugin-approval", - }); - markMcpLoopbackToolCallFinished(captureHandle); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-blocked" }), - JSON.stringify({ - type: "assistant", - session_id: "live-blocked", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-blocked", - name: "mcp__openclaw__message", - input: { action: "react" }, - }, - ], - }, - }), - JSON.stringify({ - type: "user", - session_id: "live-blocked", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-live-blocked", - content: "blocked", - is_error: true, - }, - ], - }, - }), - JSON.stringify({ type: "result", session_id: "live-blocked", result: "ok" }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - const liveRunLifecycle = createCancelableLiveRunLifecycle(); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { - env?: Record; - onStdout?: (chunk: string) => void; - }; - stdoutListener = input.onStdout; - captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; - return { - pid: 3061, - startedAtMs: Date.now(), - stdin, - ...liveRunLifecycle, - }; - }); - const context = buildClaudeLiveRunContext({ - sessionId: "session-live-blocked", - sessionKey: "agent:main:blocked", - prompt: "hello", - }); - context.mcpDeliveryCapture = true; - - try { - await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - - expect(diagnosticEvents).toMatchObject([ - { type: "tool.execution.started", toolCallId: "tool-live-blocked" }, - { - type: "tool.execution.blocked", - toolCallId: "tool-live-blocked", - deniedReason: "plugin-approval", - }, - ]); - expect(liveRunLifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("keeps identical parallel Claude live tool outcomes explicitly unknown", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - typeof event.toolCallId === "string" && - event.toolCallId.startsWith("tool-live-identical-") - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - let captureKey = ""; - const toolArgs = { action: "react", emoji: "same" }; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-identical" }), - JSON.stringify({ - type: "assistant", - session_id: "live-identical", - message: { - role: "assistant", - content: [ - { - type: "mcp_tool_use", - id: "tool-live-identical-a", - name: "mcp__openclaw__message", - input: toolArgs, - }, - { - type: "mcp_tool_use", - id: "tool-live-identical-b", - name: "mcp__openclaw__message", - input: toolArgs, - }, - ], - }, - }), - ].join("\n") + "\n", - ); - const captureHandle = markMcpLoopbackToolCallStarted({ - captureKey, - toolName: "message", - args: toolArgs, - }); - if (!captureHandle) { - throw new Error("Expected live tool capture"); - } - recordMcpLoopbackToolCallResult({ - captureHandle, - toolName: "message", - args: toolArgs, - outcome: "failed", - }); - markMcpLoopbackToolCallFinished(captureHandle); - stdoutListener?.( - [ - JSON.stringify({ - type: "user", - session_id: "live-identical", - message: { - role: "user", - content: [ - { type: "tool_result", tool_use_id: "tool-live-identical-a", content: "ok" }, - { type: "tool_result", tool_use_id: "tool-live-identical-b", content: "ok" }, - ], - }, - }), - JSON.stringify({ type: "result", session_id: "live-identical", result: "ok" }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - const liveRunLifecycle = createCancelableLiveRunLifecycle(); - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { - env?: Record; - onStdout?: (chunk: string) => void; - }; - stdoutListener = input.onStdout; - captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; - return { - pid: 3062, - startedAtMs: Date.now(), - stdin, - ...liveRunLifecycle, - }; - }); - const context = buildClaudeLiveRunContext({ - sessionId: "session-live-identical", - sessionKey: "agent:main:live-identical", - prompt: "hello", - }); - context.mcpDeliveryCapture = true; - - try { - await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - - expect(diagnosticEvents).toMatchObject([ - { type: "tool.execution.started", toolCallId: "tool-live-identical-a" }, - { type: "tool.execution.started", toolCallId: "tool-live-identical-b" }, - { - type: "tool.execution.error", - toolCallId: "tool-live-identical-a", - errorCode: "tool_outcome_unknown", - }, - { - type: "tool.execution.error", - toolCallId: "tool-live-identical-b", - errorCode: "tool_outcome_unknown", - }, - ]); - expect(liveRunLifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it.each([ - [ - "client timeout", - "tool_use", - "Bash", - Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), - "TimeoutError", - { terminalReason: "timed_out" }, - ], - [ - "client cancellation", - "tool_use", - "Bash", - new Error("operator cancelled"), - "AbortError", - { terminalReason: "cancelled" }, - ], - [ - "server-native timeout", - "server_tool_use", - "web_search", - Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), - "TimeoutError", - { errorCode: "tool_outcome_unknown" }, - ], - [ - "server-native cancellation", - "server_tool_use", - "web_search", - new Error("operator cancelled"), - "AbortError", - { errorCode: "tool_outcome_unknown" }, - ], - ] as const)( - "classifies active Claude live tools on %s", - async (_, toolType, toolName, abortReason, expectedErrorName, expectedOutcome) => { - const abortController = new AbortController(); - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if (event.type === "tool.execution.error") { - diagnosticEvents.push(event as unknown as Record); - } - }); - let stdoutListener: ((chunk: string) => void) | undefined; - const stdin = { - write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, data); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-timeout" }), - JSON.stringify({ - type: "assistant", - session_id: "live-timeout", - message: { - role: "assistant", - content: [ - { - type: toolType, - id: "tool-live-timeout", - name: toolName, - input: { query: "status" }, - }, - ], - }, - }), - ].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 { - pid: 3061, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn(), - }; - }); - - try { - const context = buildClaudeLiveRunContext({ - sessionId: "session-live-timeout", - sessionKey: "agent:main:timeout", - }); - context.params.abortSignal = abortController.signal; - const resultPromise = runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hello", - useResume: false, - noOutputTimeoutMs: 120_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - - await vi.waitFor(() => expect(stdoutListener).toBeDefined()); - abortController.abort(abortReason); - await expectRejectsWithFields(resultPromise, { name: expectedErrorName }); - await waitForDiagnosticEventsDrained(); - expect(diagnosticEvents).toContainEqual( - expect.objectContaining({ - toolCallId: "tool-live-timeout", - ...expectedOutcome, - }), - ); - if (toolType === "server_tool_use") { - const terminal = diagnosticEvents.find( - (event) => event.toolCallId === "tool-live-timeout", - ); - expect(terminal).not.toHaveProperty("terminalReason"); - } - } finally { - stopDiagnostics(); - } - }, - ); - - it("answers Claude live control_request can_use_tool with deny when the user rejects approval", async () => { - const diagnosticEvents: Array> = []; - const stopDiagnostics = onInternalDiagnosticEvent((event) => { - if ( - event.type.startsWith("tool.execution.") && - "toolCallId" in event && - event.toolCallId === "tool-deny-1" - ) { - diagnosticEvents.push(event as unknown as Record); - } - }); - const controlEvents = buildClaudeControlRequestEvents({ - requestId: "req-deny", - toolUseId: "tool-deny-1", - input: { command: "rm -rf /" }, - sessionId: "live-control-deny", - }); - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit, writeIndex }) => { - if (writeIndex === 0) { - emit(controlEvents.slice(0, 2)); - return; - } - if (!data.includes('"control_response"')) { - return; - } - emit([ - { - type: "assistant", - session_id: "live-control-deny", - message: { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-deny-1", - name: "Bash", - input: { command: "rm -rf /" }, - }, - ], - }, - }, - { - 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, - }); - - let result; - try { - result = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }), - ); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - await waitForDiagnosticEventsDrained(); - } finally { - stopDiagnostics(); - } - expect(result.text).toBe("ok"); - expectClaudeControlDecision(live, { - behavior: "deny", - requestId: "req-deny", - messageIncludes: "OpenClaw user denied Claude native tool use (Bash).", - }); - expect(diagnosticEvents).toMatchObject([ - { - type: "tool.execution.started", - toolCallId: "tool-deny-1", - toolName: "Bash", - paramsSummary: { kind: "object" }, - }, - { - type: "tool.execution.blocked", - toolCallId: "tool-deny-1", - toolName: "Bash", - deniedReason: "cli_live_exec_policy", - }, - ]); - expect(diagnosticEvents).toHaveLength(2); - expect(JSON.stringify(diagnosticEvents)).not.toContain("rm -rf"); - expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); - }); - - it("reuses a Claude native tool allow-always grant within the live process", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-allow-always", - decision: "allow-always", - }); - let promptCount = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - if (data.includes('"control_response"')) { - return; - } - promptCount += 1; - emit( - buildClaudeControlRequestEvents({ - requestId: `req-grant-${promptCount}`, - toolUseId: `tool-grant-${promptCount}`, - toolName: "Write", - input: { - file_path: `/tmp/grant-${promptCount}.txt`, - content: `content ${promptCount}`, - }, - sessionId: "live-control-allow-always", - }), - ); - }, - pid: 3012, - }); - const buildContext = (runId: string, prompt: string) => - buildClaudeLiveRunContext({ - runId, - prompt, - sessionId: "session-allow-always", - sessionKey: "agent:main:allow-always", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, - }); - - await expect( - executePreparedCliRun(buildContext("run-grant-1", "first")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), - ); - await expect( - executePreparedCliRun(buildContext("run-grant-2", "second")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), - ); - - expect(mockCallGatewayTool).toHaveBeenCalledTimes(1); - expectClaudeControlDecision(live, { - behavior: "allow", - requestId: "req-grant-1", - toolUseId: "tool-grant-1", - updatedInput: { file_path: "/tmp/grant-1.txt", content: "content 1" }, - }); - const secondResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-grant-2"), - ); - expect(secondResponse).toContain('"behavior":"allow"'); - }); - - it("prompts on every Claude native tool request when exec ask is always", async () => { - mockCallGatewayTool.mockResolvedValueOnce({ - id: "claude-native-always-seed", - decision: "allow-always", - }); - let promptCount = 0; - const live = mockClaudeLiveRun(supervisorSpawnMock, { - onWrite: ({ data, emit }) => { - if (data.includes('"control_response"')) { - return; - } - promptCount += 1; - emit( - buildClaudeControlRequestEvents({ - requestId: `req-always-${promptCount}`, - toolUseId: `tool-always-${promptCount}`, - toolName: "Write", - input: { - file_path: `/tmp/always-${promptCount}.txt`, - content: `content ${promptCount}`, - }, - sessionId: "live-control-ask-always", - }), - ); - }, - pid: 3015, - }); - const buildContext = (runId: string, prompt: string, ask: "always" | "on-miss") => - buildClaudeLiveRunContext({ - runId, - prompt, - sessionId: "session-ask-always", - sessionKey: "agent:main:ask-always", - sessionEntry: { execAsk: ask } as PreparedCliRunContext["params"]["sessionEntry"], - config: { tools: { exec: { security: "full", ask: "on-miss" } } }, - }); - - await expect( - executePreparedCliRun(buildContext("run-always-seed", "seed", "on-miss")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), - ); - mockCallGatewayTool.mockClear(); - mockCallGatewayTool - .mockResolvedValueOnce({ - id: "claude-native-always-1", - decision: "allow-once", - }) - .mockResolvedValueOnce({ - id: "claude-native-always-2", - decision: "allow-once", - }); - - await expect( - executePreparedCliRun(buildContext("run-always-1", "first", "always")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), - ); - await expect( - executePreparedCliRun(buildContext("run-always-2", "second", "always")), - ).resolves.toMatchObject({ text: "ok" }); - await vi.waitFor(() => - expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(3), - ); - - expect(mockCallGatewayTool).toHaveBeenCalledTimes(2); - for (const call of mockCallGatewayTool.mock.calls) { - expect(call[2]).toMatchObject({ allowedDecisions: ["allow-once", "deny"] }); - } - const firstResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-always-2"), - ); - const secondResponse = live.writes.find( - (entry) => entry.includes('"control_response"') && entry.includes("req-always-3"), - ); - expect(firstResponse).toContain('"behavior":"allow"'); - expect(secondResponse).toContain('"behavior":"allow"'); - }); - - 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"); - 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( - buildClaudeLiveRunContext({ - prompt: "hello", - config: { - tools: { exec: { security: "allowlist", ask: "on-miss" } }, - } as PreparedCliRunContext["params"]["config"], - }), - ); - - expect(result.text).toBe("ok"); - expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); - await expectPathMissing(approvalsPath); - }); - }); - - 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: "OpenClaw user denied" }, - approvals: { - version: 1, - defaults: { security: "allowlist", ask: "on-miss" }, - agents: {}, - }, - context: { - backend: { - liveSession: "claude-stdio", - args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], - }, - }, - expectedPermissionMode: "default", - }, - { - name: "denies tools when session exec security overrides broader config", - requestId: "req-session-security-deny", - toolUseId: "tool-session-security-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "security=deny" }, - context: { - sessionKey: "agent:main:main", - sessionEntry: { execSecurity: "deny" } as PreparedCliRunContext["params"]["sessionEntry"], - config: { - tools: { exec: { security: "full", ask: "off" } }, - agents: { - entries: { - main: { default: true, tools: { exec: { security: "full", ask: "off" } } }, - }, - }, - }, - }, - expectedPermissionMode: "default", - }, - { - name: "denies tools when a partial agent exec block inherits restrictive global security", - requestId: "req-partial-agent-global-deny", - toolUseId: "tool-partial-agent-global-deny-1", - input: { command: "ls" }, - expected: { behavior: "deny", messageIncludes: "security=deny" }, - context: { - sessionKey: "agent:main:main", - config: { - tools: { exec: { security: "deny", ask: "off" } }, - agents: { - entries: { - main: { default: true, tools: { exec: { ask: "off" } } }, - }, - }, - }, - }, - 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: "OpenClaw user denied" }, - 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" } } }, - }, - 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" } } }, - }, - 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" } } }, - }, - 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" } } }, - }, - }, - ])("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"); - await vi.waitFor(() => - expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), - ); - 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, - ); - } - }; - - if (testCase.approvals) { - await withTempExecApprovalsState(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({ - mcpDeliveryCapture: true, - }); - - await expect( - runClaudeLiveSessionTurn({ - context, - args: [], - env: {}, - prompt: "hi", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - onMcpCaptureReady: () => { - throw new Error("grant activation failed"); - }, - cleanup, - }), - ).rejects.toThrow("grant activation failed"); - - expect(cleanup).toHaveBeenCalledOnce(); - expect(supervisorSpawnMock).not.toHaveBeenCalled(); - }); - - it("uses a fresh Claude live process and capture key for every captured turn", async () => { - const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); - const cancels: Array> = []; - const captureKeys: string[] = []; - const turnResults = ["first-ok", "resume-ok", "env-ok", "fresh-ok"]; - let turnIndex = 0; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const cancel = vi.fn(); - cancels.push(cancel); - let resolveExit: (() => void) | undefined; - const exited = new Promise<{ - reason: "manual-cancel"; - exitCode: null; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }>((resolve) => { - resolveExit = () => - resolve({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }); - cancel.mockImplementation(() => resolveExit?.()); - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin: { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - const result = turnResults[turnIndex] ?? "ok"; - turnIndex += 1; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-session" }), - JSON.stringify({ - type: "result", - session_id: "live-session", - result, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }, - wait: vi.fn(() => exited), - cancel, - }; - }); - const runTurn = async (runId: string, args: string[], env: Record) => { - const context = buildClaudeLiveRunContext({ - runId, - backend: { - resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], - }, - mcpDeliveryCapture: true, - }); - const result = await runClaudeLiveSessionTurn({ - context, - args, - env, - prompt: "hi", - useResume: args.some((entry) => entry.startsWith("--resume")), - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - onMcpCaptureReady: (captureKey) => captureKeys.push(captureKey), - cleanup: async () => { - if (runId === "run-live-resume") { - throw new Error("captured cleanup failed"); - } - }, - }); - return result.output.text; - }; - const freshArgs = ["-p", "--output-format", "stream-json"]; - const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; - - await expect( - runTurn("run-live-fresh", freshArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), - ).resolves.toBe("first-ok"); - await expect( - runTurn("run-live-resume", resumeArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), - ).resolves.toBe("resume-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - expect(cancels[1]).toHaveBeenCalledWith("manual-cancel"); - expect(captureKeys[1]).not.toBe(captureKeys[0]); - - await expect( - runTurn("run-live-env-change", resumeArgs, { ANTHROPIC_BASE_URL: "https://two.example" }), - ).resolves.toBe("env-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); - expect(cancels[2]).toHaveBeenCalledWith("manual-cancel"); - expect(captureKeys[2]).not.toBe(captureKeys[1]); - - await expect( - runTurn("run-live-fresh-retry", freshArgs, { - ANTHROPIC_BASE_URL: "https://two.example", - }), - ).resolves.toBe("fresh-ok"); - - expect(supervisorSpawnMock).toHaveBeenCalledTimes(4); - expect(cancels[3]).toHaveBeenCalledWith("manual-cancel"); - expect(captureKeys[3]).not.toBe(captureKeys[2]); - expect(logWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("Claude live session cleanup failed: captured cleanup failed"), - ); - }); - - it("ignores non-JSON stdout lines from Claude live sessions", async () => { - 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({ backend: { liveSession: "claude-stdio" } }), - ); - expect(result.text).toBe("mixed-ok"); - }); - - it("fails Claude live turns on is_error results", async () => { - 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({ backend: { liveSession: "claude-stdio" } }), - ), - { name: "FailoverError", message: "Credit balance is too low" }, - ); - }); - - it("surfaces Claude live max-turn results with run and session recovery context", async () => { - 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( - buildClaudeLiveRunContext({ - runId: "run-live-max-turns", - }), - ), - { - name: "FailoverError", - message: - "Claude CLI stopped after reaching the maximum number of turns (limit: 1). " + - "OpenClaw run: run-live-max-turns. OpenClaw session: s1. " + - "Claude session: live-max-turns. Tool actions may already have run; verify their effects before retrying. " + - "Retry with a higher --max-turns value or a narrower task.", - sessionId: "s1", - reason: "unknown", - code: "cli_max_turns", - rawError: "Reached maximum number of turns (1)", - }, - ); - }); - - 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, - }, - }, - { - 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", - }, - }, - { - 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, - inputLifecycle: testCase.events.length > 0, - 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 () => { - mockClaudeLiveRun(supervisorSpawnMock, { - exitImmediately: { - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 1, - stdout: "", - stderr: "startup failed", - timedOut: false, - noOutputTimedOut: false, - }, - }); - - 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 () => { - const abortController = new AbortController(); - let stdoutListener: ((chunk: string) => void) | undefined; - const cancels: Array> = []; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - stdoutListener = input.onStdout; - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const cancel = vi.fn(); - cancels.push(cancel); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, dataValue); - if (spawnIndex === 2) { - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort-2" }), - JSON.stringify({ - type: "result", - session_id: "live-abort-2", - result: "second-ok", - }), - ].join("\n") + "\n", - ); - } - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn( - () => - new Promise((resolve) => { - if (spawnIndex === 1) { - cancel.mockImplementationOnce(() => { - resolve({ - reason: "manual-cancel", - exitCode: null, - exitSignal: null, - durationMs: 50, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }); - } - }), - ), - cancel, - }; - }); - - const firstContext = buildClaudeLiveRunContext({}); - firstContext.params.abortSignal = abortController.signal; - const first = executePreparedCliRun(firstContext); - - await vi.waitFor(() => { - expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); - }); - abortController.abort(); - - await expectRejectsWithFields(first, { name: "AbortError" }); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort" }), - JSON.stringify({ - type: "result", - session_id: "live-abort", - result: "discarded", - }), - ].join("\n") + "\n", - ); - - const second = await executePreparedCliRun(buildClaudeLiveRunContext({})); - - expect(second.text).toBe("second-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - }); - - it("fails Claude live turns without unhandled rejection when stdin write is stuck", async () => { - vi.useFakeTimers(); - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - process.on("unhandledRejection", onUnhandledRejection); - const cancel = vi.fn(); - let pendingWriteCallback: ((err?: Error | null) => void) | undefined; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - pendingWriteCallback = cb; - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async () => ({ - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel: vi.fn((reason: string) => { - cancel(reason); - pendingWriteCallback?.(new Error("stdin closed")); - }), - })); - - try { - const context = buildClaudeLiveRunContext({ - timeoutMs: 10_000, - }); - const run = runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "stuck write", - useResume: false, - noOutputTimeoutMs: 1_000, - getProcessSupervisor: () => ({ - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }), - onAssistantDelta: () => {}, - cleanup: async () => {}, - }); - const runExpectation = expectRejectsWithFields(run, { - name: "FailoverError", - message: "CLI produced no output for 1s and was terminated.", - }); - - await vi.advanceTimersByTimeAsync(1_000); - - await runExpectation; - await Promise.resolve(); - expect(unhandledRejections).toEqual([]); - expect(cancel).toHaveBeenCalledWith("manual-cancel"); - expect(stdin.write).toHaveBeenCalledOnce(); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } - }); - - it("restarts Claude live sessions when selected skills change", async () => { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-skills-")); - const weatherDir = path.join(workspaceDir, "skills", "weather"); - const gitDir = path.join(workspaceDir, "skills", "git"); - await fs.mkdir(weatherDir, { recursive: true }); - await fs.mkdir(gitDir, { recursive: true }); - await fs.writeFile(path.join(weatherDir, "SKILL.md"), "weather instructions\n", "utf-8"); - await fs.writeFile(path.join(gitDir, "SKILL.md"), "git instructions\n", "utf-8"); - - const cancels: Array> = []; - supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { - const spawnIndex = supervisorSpawnMock.mock.calls.length; - const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; - const cancel = vi.fn(); - cancels.push(cancel); - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(input.onStdout, dataValue); - const text = spawnIndex === 1 ? "weather-ok" : "git-ok"; - input.onStdout?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: `live-${spawnIndex}` }), - JSON.stringify({ - type: "result", - session_id: `live-${spawnIndex}`, - result: text, - }), - ].join("\n") + "\n", - ); - cb?.(); - }), - end: vi.fn(), - }; - return { - runId: `live-run-${spawnIndex}`, - pid: 2345 + spawnIndex, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => new Promise(() => {})), - cancel, - }; - }); - - try { - const first = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "first", - workspaceDir, - skillsSnapshot: { - prompt: "weather", - skills: [{ name: "weather" }], - resolvedSkills: [ - { - name: "weather", - description: "Weather instructions.", - filePath: path.join(weatherDir, "SKILL.md"), - baseDir: weatherDir, - source: "test", - sourceInfo: { - path: weatherDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: weatherDir, - }, - disableModelInvocation: false, - }, - ], - }, - }), - ); - const second = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "second", - workspaceDir, - skillsSnapshot: { - prompt: "git", - skills: [{ name: "git" }], - resolvedSkills: [ - { - name: "git", - description: "Git instructions.", - filePath: path.join(gitDir, "SKILL.md"), - baseDir: gitDir, - source: "test", - sourceInfo: { - path: gitDir, - source: "test", - scope: "project", - origin: "top-level", - baseDir: gitDir, - }, - disableModelInvocation: false, - }, - ], - }, - }), - ); - - expect(first.text).toBe("weather-ok"); - expect(second.text).toBe("git-ok"); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); - expect(cancels[1]).not.toHaveBeenCalled(); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } - }); - - it("closes idle Claude live sessions after ten minutes", async () => { - vi.useFakeTimers(); - 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( - buildClaudeLiveRunContext({ - prompt: "idle", - }), - ); - - expect(result.text).toBe("idle-ok"); - expect(live.lifecycle.cancel).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1); - expect(live.lifecycle.cancel).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(live.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); - expect( - live.writes.map( - (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, - ), - ).toEqual(["idle"]); - } finally { - vi.useRealTimers(); - } - }); - - it("does not surface stale stderr after a later Claude live exit", async () => { - let stdoutListener: ((chunk: string) => void) | undefined; - let stderrListener: ((chunk: string) => void) | undefined; - let resolveExit: - | ((value: { - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }) => void) - | undefined; - const wait = new Promise<{ - reason: "exit"; - exitCode: number; - exitSignal: null; - durationMs: number; - stdout: string; - stderr: string; - timedOut: false; - noOutputTimedOut: false; - }>((resolve) => { - resolveExit = resolve; - }); - let writeCount = 0; - const stdin = { - write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { - emitClaudeInputStarted(stdoutListener, dataValue); - writeCount += 1; - if (writeCount === 1) { - stderrListener?.("stale stderr from first turn"); - stdoutListener?.( - [ - JSON.stringify({ type: "system", subtype: "init", session_id: "live-stderr" }), - JSON.stringify({ - type: "result", - session_id: "live-stderr", - result: "first-ok", - }), - ].join("\n") + "\n", - ); - cb?.(); - return; - } - cb?.(); - if (!resolveExit) { - throw new Error("Expected Claude live exit resolver to be initialized"); - } - resolveExit({ - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 50, - stdout: "", - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }); - }), - end: vi.fn(), - }; - supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { - const input = (args[0] ?? {}) as { - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - }; - stdoutListener = input.onStdout; - stderrListener = input.onStderr; - return { - runId: "live-run", - pid: 2345, - startedAtMs: Date.now(), - stdin, - wait: vi.fn(() => wait), - cancel: vi.fn(), - }; - }); - - const first = await executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "first", - }), - ); - const second = executePreparedCliRun( - buildClaudeLiveRunContext({ - prompt: "second", - }), - ); - - expect(first.text).toBe("first-ok"); - await expectRejectsWithFields(second, { - name: "FailoverError", - message: "Claude CLI failed.", - }); - }); - - it("surfaces nested Claude stream-json API errors instead of raw event output", async () => { - const { message, jsonl } = createClaudeApiErrorFixture(); - - supervisorSpawnMock.mockResolvedValueOnce( - createManagedRun({ - reason: "exit", - exitCode: 1, - exitSignal: null, - durationMs: 50, - stdout: jsonl, - stderr: "", - timedOut: false, - noOutputTimedOut: false, - }), - ); - - const run = executePreparedCliRun(buildPreparedCliRunContext({})); - - await expectRejectsWithFields(run, { - name: "FailoverError", - message, - reason: "billing", - status: 402, - }); - }); - it("sanitizes dangerous backend env overrides before spawn", async () => { mockSuccessfulCliRun(); await executePreparedCliRun( diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts index 72b7c4a76e35..917af20ca6c9 100644 --- a/src/agents/cli-runner.test-helpers.ts +++ b/src/agents/cli-runner.test-helpers.ts @@ -266,21 +266,6 @@ export function buildClaudeLiveRunContext(overrides: PreparedCliRunContextOverri }); } -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) => { diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index b6fdc8bab6e7..8ad7d17b3526 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -1,7 +1,7 @@ /** Shared CLI runner test doubles for supervisor, bootstrap, and heartbeat seams. */ import type { Mock } from "vitest"; import { beforeEach, vi } from "vitest"; -import { getClaudeLiveSessionGenerationForOwner } from "./cli-runner/claude-live-session.js"; +import { getClaudeGeneration } from "./cli-runner/claude-live-registry.js"; import { createManagedRun, supervisorSpawnMock } from "./cli-runner/execute.test-support.js"; import { setCliRunnerPrepareTestDeps } from "./cli-runner/prepare.test-support.js"; import type { EmbeddedContextFile } from "./embedded-agent-helpers.js"; @@ -63,7 +63,7 @@ export function restoreCliRunnerPrepareTestDeps() { makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, resolveOpenClawReferencePaths: async () => ({ docsPath: null, sourcePath: null }), - getClaudeLiveSessionGenerationForOwner, + getClaudeGeneration, }); } diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index a3342f18d78b..75dba7502804 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -42,7 +42,7 @@ import { import { resolveCliBackendConfig } from "./cli-backends.js"; import type { CliOutput } from "./cli-output-contracts.js"; import { CliAuthProfilePreparationError } from "./cli-runner/auth-profile-preparation-error.js"; -import { shouldUseClaudeLiveSession } from "./cli-runner/claude-live-session.js"; +import { acceptsClaudeLive } from "./cli-runner/claude-live-session-policy.js"; import { attachCliMessagingDeliveryEvidence, getCliMessagingDeliveryEvidence, @@ -730,9 +730,8 @@ async function runCliAgentInternal( }; if (params.cleanupCliLiveSessionOnRunEnd === true) { try { - const { closeClaudeLiveSessionForContext } = - await import("./cli-runner/claude-live-session.js"); - await closeClaudeLiveSessionForContext(context); + const { closeClaudeSession } = await import("./cli-runner/claude-live-registry.js"); + await closeClaudeSession(context, "restart"); } catch (error) { recordCleanupError(error); } @@ -1546,7 +1545,7 @@ export async function runPreparedCliAgent( effectiveCliSessionId, params.provider, context.cwd ?? context.workspaceDir, - { skipTranscriptProbe: shouldUseClaudeLiveSession(context) }, + { skipTranscriptProbe: acceptsClaudeLive(context) }, ); await runCliAgentEndHook(params, { event: { diff --git a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts b/src/agents/cli-runner/claude-live-background-tasks.test.ts similarity index 56% rename from src/agents/cli-runner/claude-live-session.background-tasks.test.ts rename to src/agents/cli-runner/claude-live-background-tasks.test.ts index 14bac6eb5ff0..154182feea58 100644 --- a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts +++ b/src/agents/cli-runner/claude-live-background-tasks.test.ts @@ -1,4 +1,4 @@ -/** Claude live session: provisional results while native or queued work continues. */ +/** Claude live turns: provisional results while native or queued work continues. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setDiagnosticsEnabledForProcess, @@ -15,7 +15,7 @@ import { restoreCliRunnerPrepareTestDeps, supervisorSpawnMock, } from "../cli-runner.test-support.js"; -import { runClaudeLiveSessionTurn } from "./claude-live-session.js"; +import { runClaudeTurn } from "./claude-live-session.js"; import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; import { setCliRunnerExecuteTestDeps } from "./execute.test-support.js"; import { writeCliSystemPromptFile } from "./helpers.js"; @@ -209,7 +209,7 @@ function startLiveTurn(params: { timeoutMs: params.timeoutMs, credentialFingerprint: params.credentialFingerprint, }); - return runClaudeLiveSessionTurn({ + return runClaudeTurn({ context, args: context.preparedBackend.backend.args ?? [], env: {}, @@ -224,42 +224,6 @@ function startLiveTurn(params: { } describe("claude live session provisional results", () => { - it("reuses the same credential generation and restarts when it rotates", async () => { - const driver = installLiveStdoutDriver({ - onWrite: (stdout) => { - stdout( - jsonl([ - { type: "system", subtype: "init", session_id: "live-credential-rotation" }, - { - type: "result", - subtype: "success", - session_id: "live-credential-rotation", - result: "done", - }, - ]), - ); - }, - }); - - await startLiveTurn({ - runId: "run-credential-a-first", - credentialFingerprint: "credential-a", - }); - await startLiveTurn({ - runId: "run-credential-a-second", - credentialFingerprint: "credential-a", - }); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); - - await startLiveTurn({ - runId: "run-credential-b", - credentialFingerprint: "credential-b", - }); - expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); - expect(new Set(driver.userInputUuids).size).toBe(3); - expect(driver.cancel).toHaveBeenCalledOnce(); - }); - it.each([ { taskType: "local_agent", label: "subagent" }, { taskType: "local_workflow", label: "workflow" }, @@ -460,143 +424,6 @@ describe("claude live session provisional results", () => { expect(driver.cancel).not.toHaveBeenCalled(); }); - it("ignores exact synthetic replay until the matching input starts", async () => { - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn({ - runId: "run-synthetic-placeholder", - useResume: true, - }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic" }, - { - type: "assistant", - session_id: "live-synthetic", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic", - result: "", - }, - { - type: "command_lifecycle", - command_uuid: "prior-synthetic-input", - state: "completed", - }, - ]), - ); - - let settled = false; - void resultPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - expect(driver.cancel).not.toHaveBeenCalled(); - - driver.stdout.startCurrentInput(); - driver.stdout.emit( - jsonl([ - { - type: "assistant", - session_id: "live-synthetic", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "The background work is complete." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic", - result: "The background work is complete.", - }, - ]), - ); - - const result = await resultPromise; - expect(result.output.text).toBe("The background work is complete."); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - - it("ignores markerless prior results until the matching input starts", async () => { - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn({ runId: "run-markerless-prior-result", useResume: true }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { - type: "result", - subtype: "success", - session_id: "live-markerless", - result: "", - origin: { kind: "task-notification" }, - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-markerless", - result: "prior task failed", - }, - { - type: "command_lifecycle", - command_uuid: "prior-markerless-input", - state: "completed", - }, - ]), - ); - let settled = false; - void resultPromise.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await Promise.resolve(); - expect(settled).toBe(false); - - driver.stdout.startCurrentInput(); - driver.stdout.emit( - jsonl([ - { - type: "assistant", - session_id: "live-markerless", - message: { - role: "assistant", - content: [{ type: "text", text: "current answer" }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-markerless", - result: "current answer", - }, - ]), - ); - - await expect(resultPromise).resolves.toMatchObject({ output: { text: "current answer" } }); - expect(driver.cancel).not.toHaveBeenCalled(); - }); - it("keeps a synthetic result provisional while background work continues", async () => { const driver = installLiveStdoutDriver({ autoStart: false }); const resultPromise = startLiveTurn({ runId: "run-synthetic-background" }); @@ -639,291 +466,6 @@ describe("claude live session provisional results", () => { expect(driver.cancel).not.toHaveBeenCalled(); }); - it("fails a current-input synthetic placeholder on a fresh live process", async () => { - const driver = installLiveStdoutDriver({ - onWrite: (stdout) => { - stdout( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-fresh" }, - { - type: "assistant", - session_id: "live-synthetic-fresh", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "success", - session_id: "live-synthetic-fresh", - result: "", - }, - ]), - ); - }, - }); - - await expect(startLiveTurn({ runId: "run-synthetic-fresh" })).rejects.toMatchObject({ - name: "FailoverError", - reason: "format", - code: "cli_synthetic_no_response", - }); - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("times out and cleans up when lifecycle records never start the current input", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver({ autoStart: false }); - const resultPromise = startLiveTurn({ - runId: "run-missing-input-lifecycle", - timeoutMs: 60_000, - noOutputTimeoutMs: 1_000, - useResume: true, - }); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { - type: "command_lifecycle", - command_uuid: "unrelated-input", - state: "started", - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-missing-lifecycle", - result: "unrelated failure", - }, - ]), - ); - - const rejection = expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - code: undefined, - cliTimeout: { - mode: "no-output", - timeoutSeconds: 1, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - await vi.advanceTimersByTimeAsync(1_000); - await rejection; - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it.each([ - { - label: "does not replay after current-turn synthetic output", - useResume: true, - expectedCode: undefined, - chunk: jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-no-result" }, - { - type: "assistant", - session_id: "live-synthetic-no-result", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - ]), - }, - { - label: "marks a resumed init-only stall as safe for recovery", - useResume: true, - expectedCode: "cli_no_output_timeout", - chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-init-no-result" }]), - }, - { - label: "does not mark a fresh init-only stall as safe to replay", - useResume: false, - expectedCode: undefined, - chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-fresh-init-no-result" }]), - }, - { - label: "does not mark a stall as retryable after substantive assistant output", - useResume: true, - expectedCode: undefined, - chunk: jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-substantive" }, - { - type: "assistant", - session_id: "live-synthetic-substantive", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "assistant", - session_id: "live-synthetic-substantive", - message: { - model: "claude-fable-5", - role: "assistant", - content: [{ type: "text", text: "Partial real answer" }], - }, - }, - ]), - }, - { - label: "does not mark an incomplete stdout record as safe to replay", - useResume: true, - expectedCode: undefined, - chunk: '{"type":"assistant","message":{"model":"claude-fable-5"', - }, - ])("$label", async ({ useResume, expectedCode, chunk }) => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn({ - runId: `run-replay-safe-stall-${useResume ? "resume" : "fresh"}`, - timeoutMs: 60_000, - noOutputTimeoutMs: 1_000, - useResume, - }); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - driver.stdout.emit(chunk); - - const errorPromise = resultPromise.catch((error: unknown) => error); - await vi.advanceTimersByTimeAsync(1_000); - const error = (await errorPromise) as { code?: string; cliTimeout?: unknown }; - expect(error).toMatchObject({ - name: "FailoverError", - cliTimeout: { - mode: "no-output", - timeoutSeconds: 1, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - expect(error.code).toBe(expectedCode); - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("still aborts on the turn timeout after input starts but never returns a result", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn({ - runId: "run-synthetic-timeout", - timeoutMs: 5_000, - noOutputTimeoutMs: 60_000, - useResume: true, - }); - await vi.advanceTimersByTimeAsync(0); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-timeout" }, - { - type: "assistant", - session_id: "live-synthetic-timeout", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "Continue from where you left off." }], - }, - }, - ]), - ); - - const rejection = expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - message: expect.stringMatching(/exceeded timeout/i), - code: "cli_overall_timeout", - cliTimeout: { - mode: "overall", - timeoutSeconds: 5, - observedActivity: true, - activeToolCount: 0, - backgroundTaskCount: 0, - }, - }); - await vi.advanceTimersByTimeAsync(5_000); - await rejection; - expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); - }); - - it("fails immediately when an error result follows a synthetic placeholder", async () => { - const driver = installLiveStdoutDriver(); - const resultPromise = startLiveTurn({ - runId: "run-synthetic-error", - useResume: true, - }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-synthetic-error" }, - { - type: "assistant", - session_id: "live-synthetic-error", - message: { - model: "", - role: "assistant", - content: [{ type: "text", text: "No response requested." }], - }, - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-synthetic-error", - result: "provider failed", - }, - ]), - ); - - await expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - rawError: expect.stringMatching(/provider failed/i), - }); - }); - - it("fails the turn on an error result even when background tasks are outstanding", async () => { - const driver = installLiveStdoutDriver(); - const phases: Array<"send" | "resolve"> = []; - const resultPromise = startLiveTurn({ - runId: "run-bg-error", - onPhase: (phase) => phases.push(phase), - }); - await driver.stdout.waitReady(); - - driver.stdout.emit( - jsonl([ - { type: "system", subtype: "init", session_id: "live-bg-err" }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-err", task_type: "local_agent", description: "stuck" }], - }, - { - type: "result", - subtype: "error_during_execution", - is_error: true, - session_id: "live-bg-err", - result: "agent crashed", - }, - ]), - ); - - await expect(resultPromise).rejects.toMatchObject({ - name: "FailoverError", - rawError: expect.stringMatching(/agent crashed/i), - }); - expect(phases).toEqual(["resolve"]); - }); - it("does not no-output-abort while a background task is outstanding within the blocked-tool floor", async () => { const driver = installLiveStdoutDriver(); // Spawn with real timers so async supervisor setup settles, then fake the diff --git a/src/agents/cli-runner/claude-live-process-approval.test.ts b/src/agents/cli-runner/claude-live-process-approval.test.ts new file mode 100644 index 000000000000..57f518476af9 --- /dev/null +++ b/src/agents/cli-runner/claude-live-process-approval.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + markMcpLoopbackToolCallFinished, + markMcpLoopbackToolCallStarted, + recordMcpLoopbackToolCallResult, +} from "../../gateway/mcp-http.loopback-runtime.js"; +import { + onInternalDiagnosticEvent, + waitForDiagnosticEventsDrained, +} from "../../infra/diagnostic-events.js"; +import { PLUGIN_APPROVAL_DETAIL_MAX_LENGTH } from "../../infra/plugin-approvals.js"; +import { + buildClaudeControlRequestEvents, + buildClaudeLiveRunContext, + createCancelableLiveRunLifecycle, + createClaudeInputStartedEvent, + expectClaudeControlDecision, + mockClaudeLiveRun, +} from "../cli-runner.test-helpers.js"; +import { + restoreCliRunnerPrepareTestDeps, + supervisorSpawnMock, +} from "../cli-runner.test-support.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; +import { executePreparedCliRun } from "./execute.js"; + +vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({ + CLAUDE_CLI_BACKEND_ID: "claude-cli", + isClaudeCliProvider: (providerId: string) => providerId === "claude-cli", +})); + +vi.mock("../tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +const mockCallGatewayTool = vi.mocked(callGatewayTool); + +function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { + const event = createClaudeInputStartedEvent(data); + if (event) { + stdout?.(`${JSON.stringify(event)}\n`); + } +} + +beforeEach(() => { + resetClaudeLiveSessionsForTest(); + restoreCliRunnerPrepareTestDeps(); + supervisorSpawnMock.mockClear(); + mockCallGatewayTool.mockReset(); + mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + resetClaudeLiveSessionsForTest(); +}); + +describe("Claude live process approvals", () => { + it("answers Claude live control_request can_use_tool with allow when exec policy is full/no-ask", async () => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-allow", + toolUseId: "tool-allow-1", + input: { command: "ls" }, + sessionId: "live-control-allow", + }), + pid: 3001, + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "full", ask: "off" } } }, + }), + ); + expect(result.text).toBe("ok"); + expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-allow", + toolUseId: "tool-allow-1", + updatedInput: { command: "ls" }, + }); + }); + + it.each([ + { + name: "session deny overrides broader global and agent full policy", + requestId: "req-session-security-deny", + toolUseId: "tool-session-security-deny-1", + context: () => + buildClaudeLiveRunContext({ + sessionKey: "agent:main:main", + sessionEntry: { + sessionId: "session-policy-test", + updatedAt: 1, + execSecurity: "deny", + }, + config: { + tools: { exec: { security: "full", ask: "off" } }, + agents: { + list: [ + { + id: "main", + default: true, + tools: { exec: { security: "full", ask: "off" } }, + }, + ], + }, + }, + }), + }, + { + name: "partial agent policy inherits restrictive global security", + requestId: "req-partial-agent-global-deny", + toolUseId: "tool-partial-agent-global-deny-1", + context: () => + buildClaudeLiveRunContext({ + sessionKey: "agent:main:main", + config: { + tools: { exec: { security: "deny", ask: "off" } }, + agents: { + list: [ + { + id: "main", + default: true, + tools: { exec: { ask: "off" } }, + }, + ], + }, + }, + }), + }, + ])("denies Claude live native tools when $name", async ({ requestId, toolUseId, context }) => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId, + toolUseId, + input: { command: "ls" }, + sessionId: requestId, + }), + }); + + const result = await executePreparedCliRun(context()); + + expect(result.text).toBe("ok"); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId, + messageIncludes: "security=deny", + }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("preserves image and PDF bytes inside approved Claude live control inputs", async () => { + const input = { + command: "process media", + image: { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" }, + }, + document: { + type: "document", + source: { type: "base64", media_type: "application/pdf", data: "JVBERi0=" }, + }, + }; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-allow-media", + toolUseId: "tool-allow-media", + input, + sessionId: "live-control-allow-media", + }), + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "full", ask: "off" } } }, + }), + ); + + expect(result.text).toBe("ok"); + const response = expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-allow-media", + toolUseId: "tool-allow-media", + updatedInput: input, + }); + expect(JSON.stringify(response.response.response.updatedInput)).toBe(JSON.stringify(input)); + }); + + it("honors allow-once from a Claude native tool Gateway approval", async () => { + mockCallGatewayTool.mockResolvedValueOnce({ + id: "claude-native-allow-once", + decision: "allow-once", + }); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-allow-once", + toolUseId: "tool-allow-once-1", + input: { command: "ls" }, + sessionId: "live-control-allow-once", + }), + pid: 3011, + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + + expect(result.text).toBe("ok"); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-allow-once", + toolUseId: "tool-allow-once-1", + updatedInput: { command: "ls" }, + }); + expect(mockCallGatewayTool).toHaveBeenCalledWith( + "plugin.approval.request", + expect.any(Object), + expect.objectContaining({ + pluginId: "claude-cli", + toolName: "Bash", + toolCallId: "tool-allow-once-1", + }), + { expectFinal: false }, + ); + }); + + it("sends full reviewer detail for oversized non-Bash tool input", async () => { + mockCallGatewayTool.mockResolvedValueOnce({ + id: "claude-native-bounded-detail", + decision: "allow-once", + }); + const content = `line one ${"x".repeat(500)} line end`; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-write-bounded-detail", + toolUseId: "tool-write-bounded-detail-1", + toolName: "Write", + input: { file_path: "/tmp/out.txt", content }, + sessionId: "live-control-write-bounded-detail", + }), + pid: 3012, + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + + expect(result.text).toBe("ok"); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-write-bounded-detail", + toolUseId: "tool-write-bounded-detail-1", + updatedInput: { file_path: "/tmp/out.txt", content }, + }); + expect(mockCallGatewayTool).toHaveBeenCalledWith( + "plugin.approval.request", + expect.any(Object), + expect.objectContaining({ + detail: JSON.stringify({ file_path: "/tmp/out.txt", content }), + allowedDecisions: ["allow-once", "deny"], + }), + { expectFinal: false }, + ); + }); + + it("fails closed when a Claude native tool Gateway approval is unavailable", async () => { + mockCallGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-approval-unavailable", + toolUseId: "tool-approval-unavailable-1", + input: { command: "ls" }, + sessionId: "live-control-approval-unavailable", + }), + pid: 3013, + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + + expect(result.text).toBe("ok"); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId: "req-approval-unavailable", + messageIncludes: "OpenClaw approval was not granted", + }); + }); + + it("denies oversized Claude Bash approval requests before calling the Gateway", async () => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + events: buildClaudeControlRequestEvents({ + requestId: "req-bash-oversized", + toolUseId: "tool-bash-oversized-1", + input: { command: "x".repeat(PLUGIN_APPROVAL_DETAIL_MAX_LENGTH) }, + sessionId: "live-control-bash-oversized", + }), + pid: 3014, + }); + + const result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + + expect(result.text).toBe("ok"); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId: "req-bash-oversized", + messageIncludes: "too large to display", + }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("preserves loopback policy blocks for Claude live tools", async () => { + const diagnosticEvents: Array> = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if ( + event.type.startsWith("tool.execution.") && + "toolCallId" in event && + event.toolCallId === "tool-live-blocked" + ) { + diagnosticEvents.push(event as unknown as Record); + } + }); + let stdoutListener: ((chunk: string) => void) | undefined; + let captureKey = ""; + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, data); + const captureHandle = markMcpLoopbackToolCallStarted({ + captureKey, + toolName: "message", + args: { action: "react" }, + }); + if (!captureHandle) { + throw new Error("Expected live tool capture"); + } + recordMcpLoopbackToolCallResult({ + captureHandle, + toolName: "message", + args: { action: "react" }, + outcome: "blocked", + deniedReason: "plugin-approval", + }); + markMcpLoopbackToolCallFinished(captureHandle); + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-blocked" }), + JSON.stringify({ + type: "assistant", + session_id: "live-blocked", + message: { + role: "assistant", + content: [ + { + type: "mcp_tool_use", + id: "tool-live-blocked", + name: "mcp__openclaw__message", + input: { action: "react" }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + session_id: "live-blocked", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-live-blocked", + content: "blocked", + is_error: true, + }, + ], + }, + }), + JSON.stringify({ type: "result", session_id: "live-blocked", result: "ok" }), + ].join("\n") + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }; + const liveRunLifecycle = createCancelableLiveRunLifecycle(); + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { + env?: Record; + onStdout?: (chunk: string) => void; + }; + stdoutListener = input.onStdout; + captureKey = input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? ""; + return { pid: 3061, startedAtMs: Date.now(), stdin, ...liveRunLifecycle }; + }); + const context = buildClaudeLiveRunContext({ + sessionId: "session-live-blocked", + sessionKey: "agent:main:blocked", + prompt: "hello", + }); + context.mcpDeliveryCapture = true; + + try { + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); + await waitForDiagnosticEventsDrained(); + } finally { + stopDiagnostics(); + } + + expect(diagnosticEvents).toMatchObject([ + { type: "tool.execution.started", toolCallId: "tool-live-blocked" }, + { + type: "tool.execution.blocked", + toolCallId: "tool-live-blocked", + deniedReason: "plugin-approval", + }, + ]); + expect(liveRunLifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); + }); +}); diff --git a/src/agents/cli-runner/claude-live-process.test.ts b/src/agents/cli-runner/claude-live-process.test.ts new file mode 100644 index 000000000000..667c48aa9b41 --- /dev/null +++ b/src/agents/cli-runner/claude-live-process.test.ts @@ -0,0 +1,932 @@ +import path from "node:path"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + onInternalDiagnosticEvent, + waitForDiagnosticEventsDrained, +} from "../../infra/diagnostic-events.js"; +import type { getProcessSupervisor } from "../../process/supervisor/index.js"; +import { + buildClaudeControlRequestEvents, + buildClaudeLiveRunContext, + buildPreparedCliRunContext, + createClaudeInputStartedEvent, + expectClaudeControlDecision, + expectPathMissing, + expectRejectsWithFields, + mockClaudeLiveRun, + requireArgAfter, + withTempExecApprovalsState, + withTempOpenClawHome, + type PreparedCliRunContextOverrides, +} from "../cli-runner.test-helpers.js"; +import { + restoreCliRunnerPrepareTestDeps, + supervisorSpawnMock, +} from "../cli-runner.test-support.js"; +import { callGatewayTool } from "../tools/gateway.js"; +import { runClaudeTurn } from "./claude-live-session.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; +import { executePreparedCliRun } from "./execute.js"; +import { cliBackendLog } from "./log.js"; +import type { PreparedCliRunContext } from "./types.js"; + +vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({ + CLAUDE_CLI_BACKEND_ID: "claude-cli", + isClaudeCliProvider: (providerId: string) => providerId === "claude-cli", +})); + +vi.mock("../tools/gateway.js", () => ({ + callGatewayTool: vi.fn(), +})); + +const mockCallGatewayTool = vi.mocked(callGatewayTool); + +type ProcessSupervisor = ReturnType; +type SupervisorSpawnFn = ProcessSupervisor["spawn"]; + +function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { + const event = createClaudeInputStartedEvent(data); + if (event) { + stdout?.(`${JSON.stringify(event)}\n`); + } +} + +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(() => { + resetClaudeLiveSessionsForTest(); + restoreCliRunnerPrepareTestDeps(); + supervisorSpawnMock.mockClear(); + mockCallGatewayTool.mockReset(); + mockCallGatewayTool.mockResolvedValue({ id: "claude-native-approval", decision: "deny" }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + resetClaudeLiveSessionsForTest(); +}); + +describe("Claude live configured exec policy", () => { + it("uses the configured default agent for an unscoped legacy session key", async () => { + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { + if (data.includes('"control_response"')) { + return; + } + emit( + buildClaudeControlRequestEvents({ + requestId: "req-default-agent", + toolUseId: "tool-default-agent", + toolName: "Bash", + input: { command: "pwd" }, + sessionId: "live-default-agent", + }), + ); + }, + }); + const context = buildClaudeLiveRunContext({ + sessionKey: "main", + config: { + tools: { exec: { security: "full", ask: "off" } }, + agents: { + entries: { + main: {}, + ops: { default: true, tools: { exec: { security: "deny", ask: "always" } } }, + }, + }, + } as unknown as PreparedCliRunContext["params"]["config"], + }); + + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "ok" }); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId: "req-default-agent", + messageIncludes: "security=deny", + }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); +}); + +describe("Claude live process", () => { + it("refreshes a reused Claude live session when only dynamic prompt context changes", async () => { + let userTurn = 0; + let controlRequest = 0; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { + const parsed = JSON.parse(data) as { + type: string; + request_id?: string; + request?: { subtype?: string; model?: string; system_prompt?: string }; + }; + if (parsed.type === "control_request") { + controlRequest += 1; + if (controlRequest === 1) { + expect(parsed.request).toEqual({ + subtype: "set_model", + model: "sonnet", + system_prompt: "", + }); + emit([ + { + type: "control_response", + response: { + subtype: "error", + request_id: parsed.request_id, + error: "set_model: system_prompt must be a non-empty string when present", + }, + }, + ]); + return; + } + expect(parsed.request).toEqual({ + subtype: "set_model", + model: "sonnet", + system_prompt: + "# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.\nSecond-turn metadata", + }); + emit([ + { + type: "control_response", + response: { subtype: "success", request_id: parsed.request_id }, + }, + ]); + return; + } + userTurn += 1; + emit([ + { type: "system", subtype: "init", session_id: "live-dynamic-prompt" }, + { + type: "result", + session_id: "live-dynamic-prompt", + result: userTurn === 1 ? "one" : "two", + }, + ]); + }, + }); + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "always" as const, + }; + + const first = await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + prompt: "first", + systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}First-turn metadata`, + }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + prompt: "second", + systemPrompt: `# OpenClaw\n\n## Stable Instructions\nKeep the operator informed.${SYSTEM_PROMPT_CACHE_BOUNDARY}Second-turn metadata`, + }), + "live-dynamic-prompt", + ); + + expect(first.text).toBe("one"); + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ + "user", + "control_request", + "control_request", + "user", + ]); + }); + + it("answers Claude live control_request can_use_tool with deny when the user rejects approval", async () => { + const diagnosticEvents: Array> = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if ( + event.type.startsWith("tool.execution.") && + "toolCallId" in event && + event.toolCallId === "tool-deny-1" + ) { + diagnosticEvents.push(event as unknown as Record); + } + }); + const controlEvents = buildClaudeControlRequestEvents({ + requestId: "req-deny", + toolUseId: "tool-deny-1", + input: { command: "rm -rf /" }, + sessionId: "live-control-deny", + }); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit, writeIndex }) => { + if (writeIndex === 0) { + emit(controlEvents.slice(0, 2)); + return; + } + if (!data.includes('"control_response"')) { + return; + } + emit([ + { + type: "assistant", + session_id: "live-control-deny", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-deny-1", + name: "Bash", + input: { command: "rm -rf /" }, + }, + ], + }, + }, + { + 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, + }); + + let result; + try { + result = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }), + ); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + await waitForDiagnosticEventsDrained(); + } finally { + stopDiagnostics(); + } + expect(result.text).toBe("ok"); + expectClaudeControlDecision(live, { + behavior: "deny", + requestId: "req-deny", + messageIncludes: "OpenClaw user denied Claude native tool use (Bash).", + }); + expect(diagnosticEvents).toMatchObject([ + { + type: "tool.execution.started", + toolCallId: "tool-deny-1", + toolName: "Bash", + paramsSummary: { kind: "object" }, + }, + { + type: "tool.execution.blocked", + toolCallId: "tool-deny-1", + toolName: "Bash", + deniedReason: "cli_live_exec_policy", + }, + ]); + expect(diagnosticEvents).toHaveLength(2); + expect(JSON.stringify(diagnosticEvents)).not.toContain("rm -rf"); + expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); + }); + + it("reuses a Claude native tool allow-always grant within the live process", async () => { + mockCallGatewayTool.mockResolvedValueOnce({ + id: "claude-native-allow-always", + decision: "allow-always", + }); + let promptCount = 0; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { + if (data.includes('"control_response"')) { + return; + } + promptCount += 1; + emit( + buildClaudeControlRequestEvents({ + requestId: `req-grant-${promptCount}`, + toolUseId: `tool-grant-${promptCount}`, + toolName: "Write", + input: { + file_path: `/tmp/grant-${promptCount}.txt`, + content: `content ${promptCount}`, + }, + sessionId: "live-control-allow-always", + }), + ); + }, + pid: 3012, + }); + const buildContext = (runId: string, prompt: string) => + buildClaudeLiveRunContext({ + runId, + prompt, + sessionId: "session-allow-always", + sessionKey: "agent:main:allow-always", + config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + }); + + await expect( + executePreparedCliRun(buildContext("run-grant-1", "first")), + ).resolves.toMatchObject({ text: "ok" }); + await vi.waitFor(() => + expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), + ); + await expect( + executePreparedCliRun(buildContext("run-grant-2", "second")), + ).resolves.toMatchObject({ text: "ok" }); + await vi.waitFor(() => + expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), + ); + + expect(mockCallGatewayTool).toHaveBeenCalledTimes(1); + expectClaudeControlDecision(live, { + behavior: "allow", + requestId: "req-grant-1", + toolUseId: "tool-grant-1", + updatedInput: { file_path: "/tmp/grant-1.txt", content: "content 1" }, + }); + const secondResponse = live.writes.find( + (entry) => entry.includes('"control_response"') && entry.includes("req-grant-2"), + ); + expect(secondResponse).toContain('"behavior":"allow"'); + }); + + it("prompts on every Claude native tool request when exec ask is always", async () => { + mockCallGatewayTool.mockResolvedValueOnce({ + id: "claude-native-always-seed", + decision: "allow-always", + }); + let promptCount = 0; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { + if (data.includes('"control_response"')) { + return; + } + promptCount += 1; + emit( + buildClaudeControlRequestEvents({ + requestId: `req-always-${promptCount}`, + toolUseId: `tool-always-${promptCount}`, + toolName: "Write", + input: { + file_path: `/tmp/always-${promptCount}.txt`, + content: `content ${promptCount}`, + }, + sessionId: "live-control-ask-always", + }), + ); + }, + pid: 3015, + }); + const buildContext = (runId: string, prompt: string, ask: "always" | "on-miss") => + buildClaudeLiveRunContext({ + runId, + prompt, + sessionId: "session-ask-always", + sessionKey: "agent:main:ask-always", + sessionEntry: { execAsk: ask } as PreparedCliRunContext["params"]["sessionEntry"], + config: { tools: { exec: { security: "full", ask: "on-miss" } } }, + }); + + await expect( + executePreparedCliRun(buildContext("run-always-seed", "seed", "on-miss")), + ).resolves.toMatchObject({ text: "ok" }); + await vi.waitFor(() => + expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(1), + ); + mockCallGatewayTool.mockClear(); + mockCallGatewayTool + .mockResolvedValueOnce({ id: "claude-native-always-1", decision: "allow-once" }) + .mockResolvedValueOnce({ id: "claude-native-always-2", decision: "allow-once" }); + + await expect( + executePreparedCliRun(buildContext("run-always-1", "first", "always")), + ).resolves.toMatchObject({ text: "ok" }); + await vi.waitFor(() => + expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(2), + ); + await expect( + executePreparedCliRun(buildContext("run-always-2", "second", "always")), + ).resolves.toMatchObject({ text: "ok" }); + await vi.waitFor(() => + expect(live.writes.filter((entry) => entry.includes('"control_response"'))).toHaveLength(3), + ); + + expect(mockCallGatewayTool).toHaveBeenCalledTimes(2); + for (const call of mockCallGatewayTool.mock.calls) { + expect(call[2]).toMatchObject({ allowedDecisions: ["allow-once", "deny"] }); + } + const firstResponse = live.writes.find( + (entry) => entry.includes('"control_response"') && entry.includes("req-always-2"), + ); + const secondResponse = live.writes.find( + (entry) => entry.includes('"control_response"') && entry.includes("req-always-3"), + ); + expect(firstResponse).toContain('"behavior":"allow"'); + expect(secondResponse).toContain('"behavior":"allow"'); + }); + + 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"); + 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( + buildClaudeLiveRunContext({ + prompt: "hello", + config: { + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + } as PreparedCliRunContext["params"]["config"], + }), + ); + + expect(result.text).toBe("ok"); + expect(requireArgAfter(live.spawnInput.argv, "--permission-mode")).toBe("default"); + await expectPathMissing(approvalsPath); + }); + }); + + 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: "OpenClaw user denied" }, + approvals: { + version: 1, + defaults: { security: "allowlist", ask: "on-miss" }, + agents: {}, + }, + context: { + backend: { + liveSession: "claude-stdio", + args: ["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"], + }, + }, + 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: "OpenClaw user denied" }, + 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" } } }, + }, + 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" } } }, + }, + 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" } } }, + }, + 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" } } }, + }, + }, + ])("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"); + await vi.waitFor(() => + expect(live.writes.some((entry) => entry.includes('"control_response"'))).toBe(true), + ); + 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, + ); + } + }; + + if (testCase.approvals) { + await withTempExecApprovalsState(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({ mcpDeliveryCapture: true }); + + await expect( + runClaudeTurn({ + context, + args: [], + env: {}, + prompt: "hi", + useResume: false, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: () => ({ + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }), + onAssistantDelta: () => {}, + onMcpCaptureReady: () => { + throw new Error("grant activation failed"); + }, + cleanup, + }), + ).rejects.toThrow("grant activation failed"); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(supervisorSpawnMock).not.toHaveBeenCalled(); + }); + + it("uses a fresh Claude live process and capture key for every captured turn", async () => { + const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); + const cancels: Array> = []; + const captureKeys: string[] = []; + const turnResults = ["first-ok", "resume-ok", "env-ok", "fresh-ok"]; + let turnIndex = 0; + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const spawnIndex = supervisorSpawnMock.mock.calls.length; + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + const cancel = vi.fn(); + cancels.push(cancel); + let resolveExit: (() => void) | undefined; + const exited = new Promise<{ + reason: "manual-cancel"; + exitCode: null; + exitSignal: null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: false; + noOutputTimedOut: false; + }>((resolve) => { + resolveExit = () => + resolve({ + reason: "manual-cancel", + exitCode: null, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }); + cancel.mockImplementation(() => resolveExit?.()); + return { + runId: `live-run-${spawnIndex}`, + pid: 2345 + spawnIndex, + startedAtMs: Date.now(), + stdin: { + write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(input.onStdout, dataValue); + const result = turnResults[turnIndex] ?? "ok"; + turnIndex += 1; + input.onStdout?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-session" }), + JSON.stringify({ type: "result", session_id: "live-session", result }), + ].join("\n") + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }, + wait: vi.fn(() => exited), + cancel, + }; + }); + const runTurn = async (runId: string, args: string[], env: Record) => { + const context = buildClaudeLiveRunContext({ + runId, + backend: { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume", "{sessionId}"], + }, + mcpDeliveryCapture: true, + }); + const result = await runClaudeTurn({ + context, + args, + env, + prompt: "hi", + useResume: args.some((entry) => entry.startsWith("--resume")), + noOutputTimeoutMs: 1_000, + getProcessSupervisor: () => ({ + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }), + onAssistantDelta: () => {}, + onMcpCaptureReady: (captureKey) => captureKeys.push(captureKey), + cleanup: async () => { + if (runId === "run-live-resume") { + throw new Error("captured cleanup failed"); + } + }, + }); + return result.output.text; + }; + const freshArgs = ["-p", "--output-format", "stream-json"]; + const resumeArgs = ["-p", "--output-format", "stream-json", "--resume", "live-session"]; + + await expect( + runTurn("run-live-fresh", freshArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), + ).resolves.toBe("first-ok"); + await expect( + runTurn("run-live-resume", resumeArgs, { ANTHROPIC_BASE_URL: "https://one.example" }), + ).resolves.toBe("resume-ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); + expect(cancels[1]).toHaveBeenCalledWith("manual-cancel"); + expect(captureKeys[1]).not.toBe(captureKeys[0]); + + await expect( + runTurn("run-live-env-change", resumeArgs, { ANTHROPIC_BASE_URL: "https://two.example" }), + ).resolves.toBe("env-ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); + expect(cancels[2]).toHaveBeenCalledWith("manual-cancel"); + expect(captureKeys[2]).not.toBe(captureKeys[1]); + + await expect( + runTurn("run-live-fresh-retry", freshArgs, { + ANTHROPIC_BASE_URL: "https://two.example", + }), + ).resolves.toBe("fresh-ok"); + + expect(supervisorSpawnMock).toHaveBeenCalledTimes(4); + expect(cancels[3]).toHaveBeenCalledWith("manual-cancel"); + expect(captureKeys[3]).not.toBe(captureKeys[2]); + expect(logWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Claude live session cleanup failed: captured cleanup failed"), + ); + }); + + 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, + }, + }, + { + 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", + }, + }, + { + 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, + inputLifecycle: testCase.events.length > 0, + 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 () => { + mockClaudeLiveRun(supervisorSpawnMock, { + exitImmediately: { + reason: "exit", + exitCode: 1, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "startup failed", + timedOut: false, + noOutputTimedOut: false, + }, + }); + + await expect(executePreparedCliRun(buildClaudeLiveRunContext())).rejects.toThrow( + "Claude CLI live session closed before handling the turn", + ); + }); + + it("does not surface stale stderr after a later Claude live exit", async () => { + let stdoutListener: ((chunk: string) => void) | undefined; + let stderrListener: ((chunk: string) => void) | undefined; + let resolveExit: + | ((value: { + reason: "exit"; + exitCode: number; + exitSignal: null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: false; + noOutputTimedOut: false; + }) => void) + | undefined; + const wait = new Promise<{ + reason: "exit"; + exitCode: number; + exitSignal: null; + durationMs: number; + stdout: string; + stderr: string; + timedOut: false; + noOutputTimedOut: false; + }>((resolve) => { + resolveExit = resolve; + }); + let writeCount = 0; + const stdin = { + write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, dataValue); + writeCount += 1; + if (writeCount === 1) { + stderrListener?.("stale stderr from first turn"); + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-stderr" }), + JSON.stringify({ + type: "result", + session_id: "live-stderr", + result: "first-ok", + }), + ].join("\n") + "\n", + ); + cb?.(); + return; + } + cb?.(); + if (!resolveExit) { + throw new Error("Expected Claude live exit resolver to be initialized"); + } + resolveExit({ + reason: "exit", + exitCode: 1, + exitSignal: null, + durationMs: 50, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }; + stdoutListener = input.onStdout; + stderrListener = input.onStderr; + return { + runId: "live-run", + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => wait), + cancel: vi.fn(), + }; + }); + + const first = await executePreparedCliRun(buildClaudeLiveRunContext({ prompt: "first" })); + const second = executePreparedCliRun(buildClaudeLiveRunContext({ prompt: "second" })); + + expect(first.text).toBe("first-ok"); + await expectRejectsWithFields(second, { + name: "FailoverError", + message: "Claude CLI failed.", + }); + }); +}); diff --git a/src/agents/cli-runner/claude-live-process.ts b/src/agents/cli-runner/claude-live-process.ts new file mode 100644 index 000000000000..08a86f449baa --- /dev/null +++ b/src/agents/cli-runner/claude-live-process.ts @@ -0,0 +1,644 @@ +import crypto from "node:crypto"; +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { formatErrorMessage } from "../../infra/errors.js"; +import type { + CliOutput, + CliStreamingDelta, + CliThinkingDelta, + CliThinkingProgress, + CliToolResultDelta, + CliToolUseStartDelta, + CliUsage, +} from "../cli-output-contracts.js"; +import { resolveExecDefaults } from "../exec-defaults.js"; +import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; +import { LIVE_SESSION_LIMITS, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; +import { + requestClaudeNativeToolApproval, + resolveClaudeNativeToolApprovalPlan, +} from "./claude-live-tool-approval.js"; +import { resetClaudeNoOutputTimer } from "./claude-live-turn-timeouts.js"; +import { + acceptClaudeExit, + acceptClaudeStdout, + createClaudeOutputLimitError, + createClaudeTurn, + failClaudeTurn, + markClaudeLiveToolDenied, + type ClaudeLiveExecPermission, + type ClaudeLiveToolTerminalOutcome, + type ClaudeLiveTurn, + type ClaudeLiveTurnHost, +} from "./claude-live-turn.js"; +import { cliBackendLog } from "./log.js"; +import type { PreparedCliRunContext } from "./types.js"; + +type ProcessSupervisor = ReturnType< + typeof import("../../process/supervisor/index.js").getProcessSupervisor +>; +type ManagedRun = Awaited>; + +type ClaudeLivePendingControlRequest = { + requestId: string; + timer: NodeJS.Timeout; + resolve: (response: ClaudeLiveControlResponse | null) => void; +}; +type ClaudeLiveControlResponse = { subtype: string; error?: string }; + +const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; +const CLAUDE_LIVE_CONTROL_TIMEOUT_MS = 3_000; +const CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS = 5_000; +const CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR = + "set_model: system_prompt must be a non-empty string when present"; + +export type ClaudeLiveProcess = ClaudeLiveTurnHost & { + key: string; + generation: string; + fingerprint: string; + systemPromptHash: string; + systemPromptSwitchCapability: "unknown" | "supported" | "unsupported"; + liveSessionRequirement?: import("../../plugins/cli-backend.types.js").CliBackendLiveSessionRequirement; + managedRun: ManagedRun; + sessionId?: string; + idleTimer: NodeJS.Timeout | null; + cleanup: () => Promise; + cleanupPromise: Promise | null; + pendingControlRequest: ClaudeLivePendingControlRequest | null; + mcpCaptureKey?: string; + nativeToolApprovalGrants: Set; + isIdle(): boolean; + waitForExit(): Promise; + cleanupResources(): Promise; +}; + +type BeginClaudeTurnParams = { + context: PreparedCliRunContext; + inputUuid: string; + useResume: boolean; + execPermission: ClaudeLiveExecPermission; + onAssistantDelta: (delta: CliStreamingDelta) => void; + onThinkingDelta?: (delta: CliThinkingDelta) => void; + onThinkingProgress?: (progress: CliThinkingProgress) => void; + onToolUseStart?: (delta: CliToolUseStartDelta) => void; + onToolResult?: (delta: CliToolResultDelta) => void; + resolveToolResultTerminalOutcome?: ( + delta: CliToolResultDelta, + ) => ClaudeLiveToolTerminalOutcome | undefined; + onCommentaryText?: (text: string) => void; + onSessionId?: (sessionId: string) => void; + onAssistantMessage?: (message: unknown) => void; + onUsage?: (usage: CliUsage, terminal: boolean) => void; + onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; + onPhase?: (phase: "send" | "resolve") => void; +}; + +function settlePendingControlRequest( + session: ClaudeLiveProcess, + response: ClaudeLiveControlResponse | null, +): void { + const pending = session.pendingControlRequest; + if (!pending) { + return; + } + clearTimeout(pending.timer); + session.pendingControlRequest = null; + pending.resolve(response); +} + +function cleanupProcess(session: ClaudeLiveProcess): Promise { + if (!session.cleanupPromise) { + session.cleanupPromise = session.cleanup().catch((error: unknown) => { + cliBackendLog.warn(`Claude live session cleanup failed: ${formatErrorMessage(error)}`); + }); + } + return session.cleanupPromise; +} + +async function waitForManagedRunExit(managedRun: ManagedRun): Promise { + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + managedRun.wait().then( + () => undefined, + () => undefined, + ), + new Promise((resolve) => { + timeout = setTimeout(resolve, CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +function writeControlResponse(session: ClaudeLiveProcess, response: unknown): void { + const stdin = session.managedRun.stdin; + if (!stdin) { + throw new Error("Claude CLI live session stdin is unavailable"); + } + stdin.write(`${JSON.stringify(response)}\n`); +} + +function acceptControlResponse( + session: ClaudeLiveProcess, + parsed: Record, +): boolean { + const pending = session.pendingControlRequest; + if (!pending || parsed.type !== "control_response" || !isRecord(parsed.response)) { + return false; + } + const response = parsed.response; + if (response.request_id !== pending.requestId) { + return false; + } + settlePendingControlRequest(session, { + subtype: typeof response.subtype === "string" ? response.subtype : "", + ...(typeof response.error === "string" ? { error: response.error } : {}), + }); + return true; +} + +function writeToolControlResponse(params: { + session: ClaudeLiveProcess; + requestId: string; + toolUseId?: string; + toolInput: Record; + decision: { behavior: "allow" } | { behavior: "deny"; message: string }; +}): void { + writeControlResponse(params.session, { + type: "control_response", + response: { + subtype: "success", + request_id: params.requestId, + response: + params.decision.behavior === "allow" + ? { + behavior: "allow", + updatedInput: params.toolInput, + ...(params.toolUseId ? { toolUseID: params.toolUseId } : {}), + } + : { + behavior: "deny", + decisionClassification: "user_reject", + message: params.decision.message, + }, + }, + }); +} + +function markControlToolDenied(params: { + turn: ClaudeLiveTurn; + toolUseId?: string; + toolName: string; + toolInput: Record; +}): void { + if (!params.toolUseId || !params.toolName) { + return; + } + markClaudeLiveToolDenied(params.turn, { + toolCallId: params.toolUseId, + name: params.toolName, + kind: "tool_use", + args: params.toolInput, + }); +} + +function acceptControlRequest( + session: ClaudeLiveProcess, + turn: ClaudeLiveTurn, + parsed: Record, +): void { + if (parsed.type !== "control_request" || !isRecord(parsed.request)) { + return; + } + const request = parsed.request; + if (request.subtype !== "can_use_tool") { + return; + } + const requestId = typeof parsed.request_id === "string" ? parsed.request_id : ""; + if (!requestId) { + return; + } + const toolUseId = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined; + const toolName = typeof request.tool_name === "string" ? request.tool_name.trim() : ""; + const toolInput = isRecord(request.input) ? request.input : {}; + const plan = resolveClaudeNativeToolApprovalPlan(turn.execPermission); + if ( + plan === "allow" || + (plan === "prompt" && + turn.execPermission.ask !== "always" && + session.nativeToolApprovalGrants.has(toolName)) + ) { + writeToolControlResponse({ + session, + requestId, + toolUseId, + toolInput, + decision: { behavior: "allow" }, + }); + return; + } + if (plan === "deny") { + markControlToolDenied({ turn, toolUseId, toolName, toolInput }); + writeToolControlResponse({ + session, + requestId, + toolUseId, + toolInput, + decision: { + behavior: "deny", + message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`, + }, + }); + return; + } + void (async () => { + const outcome = await requestClaudeNativeToolApproval({ + toolName, + toolInput, + pluginId: session.providerId, + sessionKey: turn.diagnosticRefs.sessionKey, + agentId: turn.diagnosticRefs.agentId, + toolCallId: toolUseId, + abortSignal: turn.abortSignal, + ask: turn.execPermission.ask, + }); + const runAborted = turn.abortSignal?.aborted === true; + const allowed = !runAborted && outcome.kind === "allow"; + if (!runAborted && outcome.kind === "allow" && outcome.grantAlways) { + session.nativeToolApprovalGrants.add(toolName); + } + if (!allowed) { + markControlToolDenied({ turn, toolUseId, toolName, toolInput }); + } + if (session.closing || !session.managedRun.stdin) { + return; + } + try { + writeToolControlResponse({ + session, + requestId, + toolUseId, + toolInput, + decision: allowed + ? { behavior: "allow" } + : { + behavior: "deny", + message: + outcome.kind === "deny" && outcome.reason === "policy-oversized" + ? "OpenClaw denied Claude native tool use (Bash): the command is too large to display for out-of-band approval. Split it into smaller commands and retry." + : outcome.kind === "deny" && outcome.reason === "user" && !runAborted + ? `OpenClaw user denied Claude native tool use (${toolName}).` + : `OpenClaw approval was not granted for Claude native tool use (${toolName}).`, + }, + }); + } catch { + // The live process may close while an out-of-band approval is pending. + } + })(); +} + +function acceptSessionRequirement( + session: ClaudeLiveProcess, + parsed: Record, +): boolean { + const requirement = session.liveSessionRequirement; + if (!requirement || parsed.type !== "system" || parsed.subtype !== "init") { + return true; + } + const capabilities = Array.isArray(parsed.capabilities) + ? parsed.capabilities.filter((value): value is string => typeof value === "string") + : []; + if (capabilities.includes(requirement.capability)) { + session.liveSessionCapabilityReady = true; + return true; + } + const version = + typeof parsed.claude_code_version === "string" + ? parsed.claude_code_version.trim() || undefined + : undefined; + const versionDetail = version ? ` (version ${version})` : ""; + session.close( + "abort", + new FailoverError( + `The running Claude Code build${versionDetail} did not advertise the required ${requirement.capability} capability. Claude Code ${requirement.minimumVersion} is the first known compatible release. Run \`${requirement.updateCommand}\`, restart OpenClaw, and retry.`, + { + reason: "format", + provider: session.providerId, + model: session.modelId, + status: resolveFailoverStatus("format"), + code: "cli_live_session_unsupported", + }, + ), + ); + return false; +} + +function requestModelUpdate(params: { + session: ClaudeLiveProcess; + model: string; + systemPrompt: string; +}): Promise { + if (params.session.pendingControlRequest) { + return Promise.resolve(null); + } + const requestId = crypto.randomUUID(); + const response = new Promise((resolve) => { + params.session.pendingControlRequest = { + requestId, + timer: setTimeout( + () => settlePendingControlRequest(params.session, null), + CLAUDE_LIVE_CONTROL_TIMEOUT_MS, + ), + resolve, + }; + }); + return writeClaudeInput( + params.session, + `${JSON.stringify({ + type: "control_request", + request_id: requestId, + request: { subtype: "set_model", model: params.model, system_prompt: params.systemPrompt }, + })}\n`, + ) + .catch(() => settlePendingControlRequest(params.session, null)) + .then(() => response); +} + +async function supportsSystemPromptSwitch( + session: ClaudeLiveProcess, + model: string, +): Promise { + if (session.systemPromptSwitchCapability !== "unknown") { + return session.systemPromptSwitchCapability === "supported"; + } + const response = await requestModelUpdate({ session, model, systemPrompt: "" }); + const supported = + response?.subtype === "error" && response.error === CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR; + session.systemPromptSwitchCapability = supported ? "supported" : "unsupported"; + return supported; +} + +export async function refreshClaudePrompt(params: { + session: ClaudeLiveProcess; + context: PreparedCliRunContext; + systemPromptHash: string; +}): Promise { + if (params.session.systemPromptHash === params.systemPromptHash) { + return true; + } + const systemPrompt = stripSystemPromptCacheBoundary(params.context.systemPrompt); + if ( + !systemPrompt.trim() || + !(await supportsSystemPromptSwitch(params.session, params.context.normalizedModel)) + ) { + params.session.close("restart"); + return false; + } + const response = await requestModelUpdate({ + session: params.session, + model: params.context.normalizedModel, + systemPrompt, + }); + if (response?.subtype === "success") { + params.session.systemPromptHash = params.systemPromptHash; + return true; + } + params.session.close("restart"); + return false; +} + +export function resolveClaudeLiveExecPermission( + context: PreparedCliRunContext, +): ClaudeLiveExecPermission { + const { security, ask } = resolveExecDefaults({ + cfg: context.params.config, + sessionEntry: context.params.sessionEntry, + execOverrides: context.params.execOverrides, + agentId: context.params.agentId, + sessionKey: context.params.runtimePolicySessionKey ?? context.params.sessionKey, + }); + return { + security, + ask, + permissionMode: resolveClaudeLiveMode(security, ask, process.getuid?.()), + }; +} + +export async function spawnClaudeProcess(params: { + context: PreparedCliRunContext; + argv: string[]; + env: Record; + generation: string; + fingerprint: string; + systemPromptHash: string; + key: string; + mcpCaptureKey?: string; + noOutputTimeoutMs: number; + supervisor: ProcessSupervisor; + cleanup: () => Promise; + onSpawned: (session: ClaudeLiveProcess) => void; + onClosed: (session: ClaudeLiveProcess) => void; +}): Promise { + let session: ClaudeLiveProcess | null = null; + const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({ + mode: params.context.backendResolved.bundleMcpMode, + backend: params.context.preparedBackend.backend, + env: params.env, + captureKey: params.mcpCaptureKey, + }); + let managedRun: ManagedRun; + try { + managedRun = await params.supervisor.spawn({ + sessionId: params.context.params.sessionId, + backendId: params.context.backendResolved.id, + scopeKey: `claude-live:${params.key}`, + replaceExistingScope: true, + mode: "child", + argv: params.argv, + cwd: params.context.cwd ?? params.context.workspaceDir, + env: mcpCaptureAttempt.env ?? params.env, + stdinMode: "pipe-open", + secretInput: params.context.preparedBackend.secretInput, + captureOutput: false, + onStdout: (chunk) => { + if (session) { + acceptClaudeStdout(session, chunk); + } + }, + onStderr: (chunk) => { + if (!session) { + return; + } + session.currentTurn?.onCliOutput?.(chunk, "stderr"); + if (session.currentTurn && chunk.trim()) { + session.currentTurn.hasReplayUnsafeActivity = true; + } + session.stderr += chunk; + if (session.stderr.length > LIVE_SESSION_LIMITS.maxStderrChars) { + session.close( + "abort", + createClaudeOutputLimitError(session, "Claude CLI stderr exceeded limit."), + ); + return; + } + resetClaudeNoOutputTimer(session, session.currentTurn); + }, + }); + } catch (error) { + await mcpCaptureAttempt.cleanup?.(); + throw error; + } + session = { + backend: params.context.preparedBackend.backend, + key: params.key, + generation: params.generation, + fingerprint: params.fingerprint, + systemPromptHash: params.systemPromptHash, + systemPromptSwitchCapability: "unknown", + liveSessionRequirement: params.context.backendResolved.liveSessionRequirement, + liveSessionCapabilityReady: !params.context.backendResolved.liveSessionRequirement, + managedRun, + providerId: params.context.params.provider, + modelId: params.context.modelId, + noOutputTimeoutMs: params.noOutputTimeoutMs, + stderr: "", + stdoutBuffer: { pending: "" }, + currentTurn: null, + idleTimer: null, + cleanup: async () => { + await mcpCaptureAttempt.cleanup?.(); + await params.cleanup(); + }, + cleanupPromise: null, + closing: false, + pendingControlRequest: null, + mcpCaptureKey: params.mcpCaptureKey, + nativeToolApprovalGrants: new Set(), + outstandingBackgroundTaskIds: new Set(), + isIdle() { + return this.currentTurn === null; + }, + close(reason, error) { + if (session?.closing) { + return; + } + cliBackendLog.info( + `claude live session close: provider=${this.providerId} model=${this.modelId} reason=${reason}`, + ); + this.closing = true; + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + params.onClosed(this); + settlePendingControlRequest(this, null); + if (error) { + failClaudeTurn(this, error); + } else { + this.outstandingBackgroundTaskIds.clear(); + } + this.managedRun.cancel("manual-cancel"); + void cleanupProcess(this); + }, + scheduleIdleClose() { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + } + this.idleTimer = setTimeout(() => { + if (!this.currentTurn) { + this.close("idle"); + } + }, CLAUDE_LIVE_IDLE_TIMEOUT_MS); + }, + acceptControlResponse(parsed) { + return acceptControlResponse(this, parsed); + }, + acceptControlRequest(turn, parsed) { + acceptControlRequest(this, turn, parsed); + }, + acceptSessionRequirement(parsed) { + return acceptSessionRequirement(this, parsed); + }, + acceptSessionId(sessionId) { + this.sessionId = sessionId; + }, + settleControlRequest() { + settlePendingControlRequest(this, null); + }, + cleanupAfterExit() { + if (this.idleTimer) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + params.onClosed(this); + void cleanupProcess(this); + }, + waitForExit() { + return waitForManagedRunExit(this.managedRun); + }, + cleanupResources() { + return cleanupProcess(this); + }, + }; + params.onSpawned(session); + void managedRun.wait().then( + (exit) => { + if (session) { + acceptClaudeExit(session, exit.exitCode); + } + }, + (error: unknown) => { + if (session) { + session.close("abort", error); + } + }, + ); + return session; +} + +export function beginClaudeTurn( + session: ClaudeLiveProcess, + params: BeginClaudeTurnParams, +): Promise { + return new Promise((resolve, reject) => { + session.currentTurn = createClaudeTurn({ ...params, host: session, resolve, reject }); + }); +} + +export function abortClaudeTurn(session: ClaudeLiveProcess, error: Error): void { + if (session.currentTurn) { + session.close("abort", error); + } +} + +export function createClaudeUserInputMessage(content: string, uuid: string): string { + return `${JSON.stringify({ + type: "user", + uuid, + session_id: "", + parent_tool_use_id: null, + message: { role: "user", content }, + })}\n`; +} + +export async function writeClaudeInput(session: ClaudeLiveProcess, payload: string): Promise { + const stdin = session.managedRun.stdin; + if (!stdin) { + throw new Error("Claude CLI live session stdin is unavailable"); + } + await new Promise((resolve, reject) => { + stdin.write(payload, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} diff --git a/src/agents/cli-runner/claude-live-registry.test.ts b/src/agents/cli-runner/claude-live-registry.test.ts new file mode 100644 index 000000000000..6097f6280d17 --- /dev/null +++ b/src/agents/cli-runner/claude-live-registry.test.ts @@ -0,0 +1,970 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; +import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js"; +import { + resetDiagnosticRunActivityForTest, + startDiagnosticRunActivityTracking, +} from "../../logging/diagnostic-run-activity.js"; +import type { getProcessSupervisor } from "../../process/supervisor/index.js"; +import type { RunExit } from "../../process/supervisor/types.js"; +import { + buildClaudeLiveRunContext, + buildPreparedCliRunContext, + createClaudeInputStartedEvent, + mockCallArg, + mockClaudeLiveRun, +} from "../cli-runner.test-helpers.js"; +import { + restoreCliRunnerPrepareTestDeps, + supervisorSpawnMock, +} from "../cli-runner.test-support.js"; +import { + buildClaudeOwnerKey, + closeClaudeSession, + getClaudeGeneration, +} from "./claude-live-registry.js"; +import { runClaudeTurn } from "./claude-live-session.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; +import { executePreparedCliRun } from "./execute.js"; +import { setCliRunnerExecuteTestDeps } from "./execute.test-support.js"; +import { writeCliSystemPromptFile } from "./helpers.js"; +import { cliBackendLog } from "./log.js"; + +vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({ + CLAUDE_CLI_BACKEND_ID: "claude-cli", + isClaudeCliProvider: (providerId: string) => providerId === "claude-cli", +})); + +type ProcessSupervisor = ReturnType; +type SupervisorSpawnFn = ProcessSupervisor["spawn"]; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +beforeEach(() => { + setDiagnosticsEnabledForProcess(true); + resetAgentEventsForTest(); + resetDiagnosticRunActivityForTest(); + startDiagnosticRunActivityTracking(); + resetClaudeLiveSessionsForTest(); + restoreCliRunnerPrepareTestDeps(); + setCliRunnerExecuteTestDeps({ writeCliSystemPromptFile }); + supervisorSpawnMock.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + resetDiagnosticRunActivityForTest(); + resetClaudeLiveSessionsForTest(); +}); + +function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { + const event = createClaudeInputStartedEvent(data); + if (event) { + stdout?.(`${JSON.stringify(event)}\n`); + } +} + +function getProcessSupervisorForTest() { + return { + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }; +} + +describe("buildClaudeOwnerKey", () => { + it("is deterministic and distinguishes session keys", () => { + const base = { + agentAccountId: "acct-1", + agentId: "agent-main", + authProfileId: "profile-a", + sessionId: "sess-1", + sessionKey: "key-a", + }; + const a1 = buildClaudeOwnerKey(base); + const a2 = buildClaudeOwnerKey(base); + expect(a1).toBe(a2); + expect(buildClaudeOwnerKey({ ...base, sessionKey: "key-b" })).not.toBe(a1); + }); + + it("keeps queue and live-session owner hashes byte-identical", () => { + expect( + buildClaudeOwnerKey({ + agentAccountId: "acct-1", + agentId: "agent-main", + authProfileId: "profile-a", + sessionId: "sess-1", + sessionKey: "key-a", + }), + ).toBe("718b9a6cf473526c3c357883dfc8f1da1cf90b709d9ed38d675b52314abe6800"); + }); +}); + +describe("Claude live registry lifecycle", () => { + it("reuses a Claude live session process across turns", async () => { + const logInfoSpy = vi.spyOn(cliBackendLog, "info").mockImplementation(() => undefined); + const agentEvents: unknown[] = []; + const stop = onAgentEvent((evt) => { + if (evt.stream === "assistant") { + agentEvents.push(evt.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"; + 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 = buildClaudeLiveRunContext({ + prompt: "first", + backend: { + args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-one.json"], + resumeArgs: [ + "-p", + "--resume", + "{sessionId}", + "--strict-mcp-config", + "--mcp-config", + "/tmp/mcp-one.json", + ], + }, + mcpConfigHash: "same-mcp-config", + }); + const first = await executePreparedCliRun(firstContext); + const liveGeneration = getClaudeGeneration({ + backendId: "claude-cli", + sessionId: "s1", + }); + expect(liveGeneration).toBeDefined(); + const secondContext = buildClaudeLiveRunContext({ + prompt: "second", + backend: { + args: ["-p", "--strict-mcp-config", "--mcp-config", "/tmp/mcp-two.json"], + resumeArgs: [ + "-p", + "--resume", + "{sessionId}", + "--strict-mcp-config", + "--mcp-config", + "/tmp/mcp-two.json", + ], + }, + mcpConfigHash: "same-mcp-config", + }); + secondContext.requiredClaudeLiveSessionGeneration = liveGeneration; + const second = await executePreparedCliRun(secondContext, "live-session-1"); + + const changedContext = buildClaudeLiveRunContext({ + model: "opus", + prompt: "changed", + backend: { + args: ["-p"], + resumeArgs: ["-p", "--resume", "{sessionId}"], + }, + mcpConfigHash: "same-mcp-config", + }); + changedContext.requiredClaudeLiveSessionGeneration = liveGeneration; + await expect(executePreparedCliRun(changedContext, "live-session-1")).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_changed", + }); + + const spawnInput = mockCallArg(supervisorSpawnMock) as { + argv?: string[]; + stdinMode?: string; + }; + expect(first.text).toBe("one"); + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + expect(spawnInput.stdinMode).toBe("pipe-open"); + expect(spawnInput.argv).toContain("--input-format"); + expect(spawnInput.argv).toContain("--output-format"); + expect(spawnInput.argv).toContain("stream-json"); + expect(spawnInput.argv).toContain("--replay-user-messages"); + expect(spawnInput.argv).not.toContain("--session-id"); + expect(spawnInput.argv).toContain("/tmp/mcp-one.json"); + expect( + live.writes.map( + (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, + ), + ).toEqual(["first", "second"]); + expect(agentEvents).toEqual([ + { text: "one", delta: "one" }, + { text: "two", delta: "two" }, + ]); + const turnLogs = logInfoSpy.mock.calls + .map(([message]) => message) + .filter((message) => message.startsWith("claude live session turn:")); + expect(turnLogs).toHaveLength(2); + expect(turnLogs[0]).toContain("outBytes=3 outHash=7692c3ad3540"); + expect(turnLogs[1]).toContain("outBytes=3 outHash=3fc4ccfe7458"); + expect(turnLogs.join("\n")).not.toContain("one"); + expect(turnLogs.join("\n")).not.toContain("two"); + } finally { + logInfoSpy.mockRestore(); + stop(); + } + }); + + it("requires the exact warm Claude process even without native resume args", async () => { + const liveRuns = Array.from({ length: 3 }, () => + mockClaudeLiveRun(supervisorSpawnMock, { + pid: 2346, + events: [ + { type: "system", subtype: "init", session_id: "live-session-1" }, + { type: "result", session_id: "live-session-1", result: "one" }, + ], + }), + ); + + const firstContext = buildPreparedCliRunContext({ + prompt: "first", + backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, + }); + expect((await executePreparedCliRun(firstContext)).text).toBe("one"); + const liveGeneration = getClaudeGeneration({ + backendId: "claude-cli", + sessionId: "s1", + }); + expect(liveGeneration).toBeDefined(); + + resetClaudeLiveSessionsForTest(); + const missingContext = buildPreparedCliRunContext({ + prompt: "second", + backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, + }); + missingContext.requiredClaudeLiveSessionGeneration = liveGeneration; + + await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_missing", + }); + + const replacementContext = buildPreparedCliRunContext({ + prompt: "replacement", + backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, + }); + expect((await executePreparedCliRun(replacementContext)).text).toBe("one"); + await expect(executePreparedCliRun(missingContext, "live-session-1")).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_changed", + }); + missingContext.openClawHistoryPrompt = "bounded OpenClaw history\n\nsecond"; + expect((await executePreparedCliRun(missingContext)).text).toBe("one"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(3); + expect( + (JSON.parse(liveRuns[2]?.writes.at(-1) ?? "") as { message: { content: string } }).message + .content, + ).toBe("bounded OpenClaw history\n\nsecond"); + }); + + it("serializes concurrent Claude live session creation for the same key", async () => { + let releaseSpawn: (() => void) | undefined; + let turn = 0; + const spawnReady = new Promise((resolve) => { + releaseSpawn = resolve; + }); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + beforeSpawn: () => spawnReady, + onWrite: ({ emit }) => { + turn += 1; + emit([ + { type: "system", subtype: "init", session_id: "live-concurrent" }, + { + type: "result", + session_id: "live-concurrent", + result: turn === 1 ? "one" : "two", + }, + ]); + }, + }); + + const backend = { + liveSession: "claude-stdio" as const, + }; + const first = executePreparedCliRun( + buildPreparedCliRunContext({ + prompt: "first", + backend, + }), + ); + const second = executePreparedCliRun( + buildPreparedCliRunContext({ + prompt: "second", + backend, + }), + ); + await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); + releaseSpawn?.(); + + const results = await Promise.all([first, second]); + expect(results.map((result) => result.text).toSorted()).toEqual(["one", "two"]); + expect(live.stdin.write).toHaveBeenCalledTimes(2); + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + }); + + it("does not register a process whose pending spawn was closed", async () => { + let releaseSpawn: (() => void) | undefined; + const spawnBlocked = new Promise((resolve) => { + releaseSpawn = resolve; + }); + let stdoutListener: ((chunk: string) => void) | undefined; + const cancel = vi.fn(); + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + stdoutListener = input.onStdout; + await spawnBlocked; + return { + pid: 2349, + startedAtMs: Date.now(), + stdin: { + write: vi.fn((data: string, callback?: (error?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, data); + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "closed-spawn" }), + JSON.stringify({ type: "result", session_id: "closed-spawn", result: "late" }), + ].join("\n") + "\n", + ); + callback?.(); + }), + end: vi.fn(), + }, + wait: vi.fn(() => new Promise(() => {})), + cancel, + }; + }); + + const context = buildPreparedCliRunContext({ + runId: "run-close-pending-spawn", + sessionId: "session-close-pending-spawn", + backend: { liveSession: "claude-stdio" }, + }); + const run = runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "hello", + useResume: false, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + cleanup: async () => {}, + }); + + await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledOnce()); + expect( + getClaudeGeneration({ backendId: "claude-cli", sessionId: "session-close-pending-spawn" }), + ).toBeDefined(); + await closeClaudeSession(context, "restart"); + releaseSpawn?.(); + + await expect(run).rejects.toThrow("closed before handling the turn"); + expect( + getClaudeGeneration({ backendId: "claude-cli", sessionId: "session-close-pending-spawn" }), + ).toBeUndefined(); + expect(cancel).toHaveBeenCalledWith("manual-cancel"); + }); + + it("does not close a replacement spawned while the previous process exits", async () => { + let resolveOldExit: ((exit: RunExit) => void) | undefined; + const oldExit = new Promise((resolve) => { + resolveOldExit = resolve; + }); + const old = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "old-session" }, + { type: "result", session_id: "old-session", result: "old" }, + ], + }); + old.lifecycle.wait.mockImplementation(() => oldExit); + + const context = buildPreparedCliRunContext({ + prompt: "old", + backend: { liveSession: "claude-stdio" }, + }); + await expect(executePreparedCliRun(context)).resolves.toMatchObject({ text: "old" }); + + let releaseReplacementSpawn: (() => void) | undefined; + const replacementSpawnBlocked = new Promise((resolve) => { + releaseReplacementSpawn = resolve; + }); + const replacement = mockClaudeLiveRun(supervisorSpawnMock, { + beforeSpawn: () => replacementSpawnBlocked, + events: [ + { type: "system", subtype: "init", session_id: "replacement-session" }, + { type: "result", session_id: "replacement-session", result: "replacement" }, + ], + }); + + const closing = closeClaudeSession(context, "restart"); + await vi.waitFor(() => expect(old.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel")); + const replacementRun = executePreparedCliRun( + buildPreparedCliRunContext({ + prompt: "replacement", + backend: { liveSession: "claude-stdio" }, + }), + ); + await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(2)); + + resolveOldExit?.({ + reason: "manual-cancel", + exitCode: null, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + await closing; + releaseReplacementSpawn?.(); + + await expect(replacementRun).resolves.toMatchObject({ text: "replacement" }); + expect(replacement.lifecycle.cancel).not.toHaveBeenCalled(); + }); + + it("recovers when a required warm Claude process exits during reuse cleanup", async () => { + let stdoutListener: ((chunk: string) => void) | undefined; + let resolveExit: ((exit: RunExit) => void) | undefined; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + let turn = 0; + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, data); + turn += 1; + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-race" }), + JSON.stringify({ type: "result", session_id: "live-race", result: `turn-${turn}` }), + ].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 { + pid: 2350, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => exited), + cancel: vi.fn(), + }; + }); + const context = buildPreparedCliRunContext({ + prompt: "first", + backend: { args: ["-p"], resumeArgs: [], liveSession: "claude-stdio" }, + }); + const first = await runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "first", + useResume: false, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + cleanup: async () => {}, + }); + expect(first.output.text).toBe("turn-1"); + const generation = getClaudeGeneration({ + backendId: "claude-cli", + sessionId: "s1", + }); + expect(generation).toBeDefined(); + + let markCleanupStarted: (() => void) | undefined; + const cleanupStarted = new Promise((resolve) => { + markCleanupStarted = resolve; + }); + let releaseCleanup: (() => void) | undefined; + const cleanupReleased = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const reuse = runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "second", + useResume: false, + requiredSessionGeneration: generation, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + cleanup: async () => { + markCleanupStarted?.(); + await cleanupReleased; + }, + }); + await cleanupStarted; + resolveExit?.({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + await vi.waitFor(() => + expect(getClaudeGeneration({ backendId: "claude-cli", sessionId: "s1" })).toBeUndefined(), + ); + releaseCleanup?.(); + + await expect(reuse).rejects.toMatchObject({ + reason: "session_expired", + code: "cli_live_session_missing", + }); + expect(stdin.write).toHaveBeenCalledOnce(); + }); + + it("counts pending Claude live session creates against the session cap", async () => { + let releaseSpawn: (() => void) | undefined; + const spawnReady = new Promise((resolve) => { + releaseSpawn = resolve; + }); + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + const spawnIndex = supervisorSpawnMock.mock.calls.length; + await spawnReady; + const stdin = { + write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(input.onStdout, dataValue); + input.onStdout?.( + [ + JSON.stringify({ + type: "system", + subtype: "init", + session_id: `live-cap-${spawnIndex}`, + }), + JSON.stringify({ + type: "result", + session_id: `live-cap-${spawnIndex}`, + result: `ok-${spawnIndex}`, + }), + ].join("\n") + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }; + return { + runId: `live-run-${spawnIndex}`, + pid: 2300 + spawnIndex, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(), + }; + }); + + const backend = { + liveSession: "claude-stdio" as const, + }; + const runs = Array.from({ length: 17 }, (_, index) => + (() => { + const context = buildPreparedCliRunContext({ + runId: `run-live-cap-${index}`, + prompt: `prompt ${index}`, + sessionId: `session-${index}`, + backend, + }); + return runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: `prompt ${index}`, + useResume: false, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + cleanup: async () => {}, + }); + })(), + ); + const rejectedRun = runs[16]; + const rejectedRunExpectation = expect(rejectedRun).rejects.toThrow( + "Too many Claude CLI live sessions are active.", + ); + + await vi.waitFor(() => expect(supervisorSpawnMock).toHaveBeenCalledTimes(16)); + await rejectedRunExpectation; + releaseSpawn?.(); + await expect(Promise.all(runs.slice(0, 16))).resolves.toHaveLength(16); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(16); + }); + + it("reuses the same credential generation and restarts when it rotates", async () => { + let stdoutListener: ((chunk: string) => void) | undefined; + const cancel = vi.fn(); + const userInputUuids: string[] = []; + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + const parsed = JSON.parse(data) as { type?: string; uuid?: string }; + if (parsed.type === "user" && typeof parsed.uuid === "string") { + userInputUuids.push(parsed.uuid); + stdoutListener?.( + `${JSON.stringify({ + type: "command_lifecycle", + command_uuid: parsed.uuid, + state: "started", + })}\n`, + ); + } + stdoutListener?.( + [ + JSON.stringify({ + type: "system", + subtype: "init", + session_id: "live-credential-rotation", + }), + JSON.stringify({ + type: "result", + subtype: "success", + session_id: "live-credential-rotation", + result: "done", + }), + ].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-credential-${supervisorSpawnMock.mock.calls.length}`, + pid: 4242, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel, + }; + }); + const runTurn = (runId: string, credentialFingerprint: string) => { + const context = buildPreparedCliRunContext({ + runId, + backend: { liveSession: "claude-stdio" }, + }); + context.preparedBackend.secretInput = { + fd: 3, + fingerprint: credentialFingerprint, + createData: () => Buffer.from("secret"), + }; + return runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "hi", + useResume: false, + noOutputTimeoutMs: 5_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + cleanup: async () => {}, + }); + }; + + await runTurn("run-credential-a-first", "credential-a"); + await runTurn("run-credential-a-second", "credential-a"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); + + await runTurn("run-credential-b", "credential-b"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + expect(new Set(userInputUuids).size).toBe(3); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("restarts Claude live sessions when selected skills change", async () => { + const workspaceDir = tempDirs.make("openclaw-live-skills-"); + const weatherDir = path.join(workspaceDir, "skills", "weather"); + const gitDir = path.join(workspaceDir, "skills", "git"); + await fs.mkdir(weatherDir, { recursive: true }); + await fs.mkdir(gitDir, { recursive: true }); + await fs.writeFile(path.join(weatherDir, "SKILL.md"), "weather instructions\n", "utf-8"); + await fs.writeFile(path.join(gitDir, "SKILL.md"), "git instructions\n", "utf-8"); + + const cancels: Array> = []; + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const spawnIndex = supervisorSpawnMock.mock.calls.length; + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + const cancel = vi.fn(); + cancels.push(cancel); + const stdin = { + write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(input.onStdout, dataValue); + const text = spawnIndex === 1 ? "weather-ok" : "git-ok"; + input.onStdout?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: `live-${spawnIndex}` }), + JSON.stringify({ + type: "result", + session_id: `live-${spawnIndex}`, + result: text, + }), + ].join("\n") + "\n", + ); + cb?.(); + }), + end: vi.fn(), + }; + return { + runId: `live-run-${spawnIndex}`, + pid: 2345 + spawnIndex, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel, + }; + }); + + try { + const first = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "first", + workspaceDir, + skillsSnapshot: { + prompt: "weather", + skills: [{ name: "weather" }], + resolvedSkills: [ + { + name: "weather", + description: "Weather instructions.", + filePath: path.join(weatherDir, "SKILL.md"), + baseDir: weatherDir, + source: "test", + sourceInfo: { + path: weatherDir, + source: "test", + scope: "project", + origin: "top-level", + baseDir: weatherDir, + }, + disableModelInvocation: false, + }, + ], + }, + }), + ); + const second = await executePreparedCliRun( + buildClaudeLiveRunContext({ + prompt: "second", + workspaceDir, + skillsSnapshot: { + prompt: "git", + skills: [{ name: "git" }], + resolvedSkills: [ + { + name: "git", + description: "Git instructions.", + filePath: path.join(gitDir, "SKILL.md"), + baseDir: gitDir, + source: "test", + sourceInfo: { + path: gitDir, + source: "test", + scope: "project", + origin: "top-level", + baseDir: gitDir, + }, + disableModelInvocation: false, + }, + ], + }, + }), + ); + + expect(first.text).toBe("weather-ok"); + expect(second.text).toBe("git-ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); + expect(cancels[1]).not.toHaveBeenCalled(); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("closes idle Claude live sessions after ten minutes", async () => { + vi.useFakeTimers(); + 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( + buildClaudeLiveRunContext({ + prompt: "idle", + }), + ); + + expect(result.text).toBe("idle-ok"); + expect(live.lifecycle.cancel).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1); + expect(live.lifecycle.cancel).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(live.lifecycle.cancel).toHaveBeenCalledWith("manual-cancel"); + expect( + live.writes.map( + (entry) => (JSON.parse(entry) as { message: { content: string } }).message.content, + ), + ).toEqual(["idle"]); + } finally { + vi.useRealTimers(); + } + }); + it("serializes direct live turns before refreshing their system prompts", async () => { + let userTurn = 0; + let releaseCapabilityProbe: (() => void) | undefined; + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ data, emit }) => { + const parsed = JSON.parse(data) as { + type: string; + request_id?: string; + request?: { system_prompt?: string }; + }; + if (parsed.type === "control_request") { + if (parsed.request?.system_prompt === "") { + releaseCapabilityProbe = () => { + emit([ + { + type: "control_response", + response: { + subtype: "error", + request_id: parsed.request_id, + error: "set_model: system_prompt must be a non-empty string when present", + }, + }, + ]); + }; + return; + } + emit([ + { + type: "control_response", + response: { + subtype: "success", + request_id: parsed.request_id, + }, + }, + ]); + return; + } + userTurn += 1; + emit([ + { type: "system", subtype: "init", session_id: "live-serialized-refresh" }, + { + type: "result", + session_id: "live-serialized-refresh", + result: `turn-${userTurn}`, + }, + ]); + }, + }); + const backend = { + args: ["-p", "--output-format", "stream-json"], + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "always" as const, + }; + const getSerializedProcessSupervisor = () => ({ + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }); + const runTurn = ( + systemPrompt: string, + prompt: string, + useResume: boolean, + abortSignal?: AbortSignal, + cleanup: () => Promise = async () => {}, + ) => { + const context = buildPreparedCliRunContext({ backend, prompt, systemPrompt }); + context.params.abortSignal = abortSignal; + return runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt, + useResume, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: getSerializedProcessSupervisor, + onAssistantDelta: () => {}, + cleanup, + }); + }; + + await expect( + runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, "first", false), + ).resolves.toMatchObject({ output: { text: "turn-1" } }); + + const second = runTurn( + `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, + "second", + true, + ); + await vi.waitFor(() => expect(releaseCapabilityProbe).toBeTypeOf("function")); + const queuedAbort = new AbortController(); + const abortedCleanup = vi.fn(async () => {}); + const third = runTurn( + `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Third metadata`, + "third", + true, + queuedAbort.signal, + abortedCleanup, + ); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); + queuedAbort.abort(); + await expect(third).rejects.toMatchObject({ name: "AbortError" }); + expect(abortedCleanup).toHaveBeenCalledOnce(); + expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual(["user", "control_request"]); + releaseCapabilityProbe?.(); + + await expect(second).resolves.toMatchObject({ output: { text: "turn-2" } }); + await expect( + runTurn(`Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Fourth metadata`, "fourth", true), + ).resolves.toMatchObject({ output: { text: "turn-3" } }); + expect(live.writes.map((entry) => JSON.parse(entry).type)).toEqual([ + "user", + "control_request", + "control_request", + "user", + "control_request", + "user", + ]); + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agents/cli-runner/claude-live-registry.ts b/src/agents/cli-runner/claude-live-registry.ts new file mode 100644 index 000000000000..38e462598597 --- /dev/null +++ b/src/agents/cli-runner/claude-live-registry.ts @@ -0,0 +1,183 @@ +import { sha256Hex } from "../../infra/crypto-digest.js"; +import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { LIVE_SESSION_LIMITS } from "./claude-live-session-policy.js"; +import { cliBackendLog } from "./log.js"; +import type { PreparedCliRunContext } from "./types.js"; + +type ClaudeLiveSessionOwner = { + backendId: string; + agentAccountId?: string; + agentId?: string; + authProfileId?: string; + sessionId?: string; + sessionKey?: string; +}; + +type ClaudeLiveCloseReason = "idle" | "restart" | "abort" | "mcp-capture-rotation"; + +/** Structural process handle kept by the registry without importing its implementation. */ +type ClaudeLiveProcessHandle = { + key: string; + generation: string; + providerId: string; + modelId: string; + isIdle(): boolean; + close(reason: ClaudeLiveCloseReason, error?: unknown): void; + waitForExit(): Promise; + cleanupResources(): Promise; +}; + +type ClaudeLiveSessionCreate = { + generation: string; + closeReason?: ClaudeLiveCloseReason; +}; + +const liveSessions = new Map(); +const liveSessionCreates = new Map(); +const liveSessionTurns = new KeyedAsyncQueue(); + +function buildClaudeLiveOwnerKey(owner: ClaudeLiveSessionOwner): string { + return `${owner.backendId}:${buildClaudeOwnerKey(owner)}`; +} + +/** Hashes the account/agent/auth/session tuple shared by queue and registry ownership. */ +export function buildClaudeOwnerKey(input: Omit): string { + return sha256Hex( + JSON.stringify({ + agentAccountId: input.agentAccountId, + agentId: input.agentId, + authProfileId: input.authProfileId, + sessionId: input.sessionId, + sessionKey: input.sessionKey, + }), + ); +} + +export function buildClaudeLiveKey(context: PreparedCliRunContext): string { + return buildClaudeLiveOwnerKey({ + backendId: context.backendResolved.id, + agentAccountId: context.params.agentAccountId, + agentId: context.params.agentId, + authProfileId: context.effectiveAuthProfileId, + sessionId: context.params.sessionId, + sessionKey: context.params.sessionKey, + }); +} + +/** Returns whether this owner still has an in-process Claude stdio session. */ +export function hasClaudeSession(owner: ClaudeLiveSessionOwner): boolean { + return getClaudeGeneration(owner) !== undefined; +} + +/** Returns the opaque generation of this owner's current or pending Claude stdio session. */ +export function getClaudeGeneration(owner: ClaudeLiveSessionOwner): string | undefined { + const key = buildClaudeLiveOwnerKey(owner); + return liveSessions.get(key)?.generation ?? liveSessionCreates.get(key)?.generation; +} + +export function getClaudeSession(key: string): ClaudeLiveProcessHandle | undefined { + return liveSessions.get(key); +} + +export function registerClaudeSession( + session: ClaudeLiveProcessHandle, + pending: ClaudeLiveSessionCreate, +): void { + if (liveSessionCreates.get(session.key) !== pending || pending.closeReason) { + session.close(pending.closeReason ?? "restart"); + return; + } + liveSessions.set(session.key, session); + cliBackendLog.info( + `claude live session start: provider=${session.providerId} model=${session.modelId} activeSessions=${liveSessions.size}`, + ); +} + +export function removeClaudeSession(session: ClaudeLiveProcessHandle): void { + if (liveSessions.get(session.key) === session) { + liveSessions.delete(session.key); + } +} + +export function beginClaudeSessionCreate(key: string, generation: string): ClaudeLiveSessionCreate { + const create = { generation }; + liveSessionCreates.set(key, create); + return create; +} + +export function finishClaudeSessionCreate(key: string, create: ClaudeLiveSessionCreate): void { + if (liveSessionCreates.get(key) === create) { + liveSessionCreates.delete(key); + } +} + +export function enqueueClaudeTurn(key: string, task: () => Promise): Promise { + return liveSessionTurns.enqueue(key, task); +} + +/** Closes the live Claude session associated with a prepared run context, if one exists. */ +export async function closeClaudeSession( + context: PreparedCliRunContext, + reason: ClaudeLiveCloseReason, +): Promise { + const key = buildClaudeLiveKey(context); + const session = liveSessions.get(key); + const pending = liveSessionCreates.get(key); + if (session) { + session.close(reason); + } + if (pending) { + pending.closeReason = reason; + liveSessionCreates.delete(key); + } + if (session) { + await session.waitForExit(); + } +} + +function closeOldestIdleSession(): boolean { + for (const session of liveSessions.values()) { + if (session.isIdle()) { + session.close("idle"); + return true; + } + } + return false; +} + +export function ensureClaudeSessionCapacity(key: string, context: PreparedCliRunContext): void { + if ( + liveSessions.has(key) || + liveSessionCreates.has(key) || + liveSessions.size + liveSessionCreates.size < LIVE_SESSION_LIMITS.maxSessions + ) { + return; + } + if (closeOldestIdleSession()) { + return; + } + throw new FailoverError("Too many Claude CLI live sessions are active.", { + reason: "rate_limit", + provider: context.params.provider, + model: context.modelId, + status: resolveFailoverStatus("rate_limit"), + }); +} + +/** Closes all live Claude CLI sessions and clears creation promises for tests. */ +function resetClaudeLiveSessionsForTest(): void { + for (const session of liveSessions.values()) { + session.close("restart"); + } + liveSessions.clear(); + for (const pending of liveSessionCreates.values()) { + pending.closeReason = "restart"; + } + liveSessionCreates.clear(); +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.claudeLiveRegistryReset")] = + resetClaudeLiveSessionsForTest; +} diff --git a/src/agents/cli-runner/claude-live-session-policy.test.ts b/src/agents/cli-runner/claude-live-session-policy.test.ts index 7ecc238b3d2b..002ebdf793f5 100644 --- a/src/agents/cli-runner/claude-live-session-policy.test.ts +++ b/src/agents/cli-runner/claude-live-session-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveClaudeLiveMode } from "./claude-live-session-policy.js"; +import { acceptsClaudeLive, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; +import type { PreparedCliRunContext } from "./types.js"; describe("resolveClaudeLiveMode", () => { it("keeps root on Claude default permissions while preserving YOLO elsewhere", () => { @@ -11,3 +12,32 @@ describe("resolveClaudeLiveMode", () => { expect(resolveClaudeLiveMode("allowlist", "on-miss", 1000)).toBe("default"); }); }); + +describe("acceptsClaudeLive", () => { + it("accepts only local Claude stdin/jsonl stdio contexts", () => { + const context = { + params: { sessionEntry: {} }, + backendResolved: { id: "claude-cli" }, + preparedBackend: { + backend: { liveSession: "claude-stdio", output: "jsonl", input: "stdin" }, + }, + } as unknown as PreparedCliRunContext; + + expect(acceptsClaudeLive(context)).toBe(true); + expect( + acceptsClaudeLive({ + ...context, + params: { ...context.params, sessionEntry: { execHost: "node" } }, + } as unknown as PreparedCliRunContext), + ).toBe(false); + expect( + acceptsClaudeLive({ + ...context, + preparedBackend: { + ...context.preparedBackend, + backend: { ...context.preparedBackend.backend, output: "json" }, + }, + }), + ).toBe(false); + }); +}); diff --git a/src/agents/cli-runner/claude-live-session-policy.ts b/src/agents/cli-runner/claude-live-session-policy.ts index 4ed31f4182d4..71f73a15c3f5 100644 --- a/src/agents/cli-runner/claude-live-session-policy.ts +++ b/src/agents/cli-runner/claude-live-session-policy.ts @@ -1,10 +1,22 @@ import type { ExecAsk, ExecSecurity } from "../../infra/exec-approvals.js"; +import type { PreparedCliRunContext } from "./types.js"; export const LIVE_SESSION_LIMITS = { maxSessions: 16, maxStderrChars: 64 * 1024, } as const; +/** Returns whether a prepared backend context is eligible for Claude live stdio reuse. */ +export function acceptsClaudeLive(context: PreparedCliRunContext): boolean { + return ( + context.params.sessionEntry?.execHost !== "node" && + context.backendResolved.id === "claude-cli" && + context.preparedBackend.backend.liveSession === "claude-stdio" && + context.preparedBackend.backend.output === "jsonl" && + context.preparedBackend.backend.input === "stdin" + ); +} + /** Resolve Claude's live permission mode without asking root to use an unsupported bypass. */ export function resolveClaudeLiveMode( security: ExecSecurity, diff --git a/src/agents/cli-runner/claude-live-session.capability.test.ts b/src/agents/cli-runner/claude-live-session.capability.test.ts deleted file mode 100644 index 7b5c10396065..000000000000 --- a/src/agents/cli-runner/claude-live-session.capability.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** Claude live-session capability negotiation and input ownership tests. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CliBackendParseJsonlEvent } from "../../plugins/cli-backend.types.js"; -import type { getProcessSupervisor } from "../../process/supervisor/index.js"; -import { buildClaudeLiveRunContext, mockClaudeLiveRun } from "../cli-runner.test-helpers.js"; -import { supervisorSpawnMock } from "../cli-runner.test-support.js"; -import { runClaudeLiveSessionTurn } from "./claude-live-session.js"; -import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; - -type ProcessSupervisor = ReturnType; -type SupervisorSpawnFn = ProcessSupervisor["spawn"]; - -const liveSessionRequirement = { - capability: "msg_lifecycle_v1", - minimumVersion: "2.1.206", - versionArgs: ["--version"], - updateCommand: "claude update", -} as const; - -beforeEach(() => { - resetClaudeLiveSessionsForTest(); - supervisorSpawnMock.mockClear(); -}); - -afterEach(() => { - vi.restoreAllMocks(); - resetClaudeLiveSessionsForTest(); -}); - -function getProcessSupervisorForTest() { - return { - spawn: (params: Parameters[0]) => - supervisorSpawnMock(params) as ReturnType, - cancel: vi.fn(), - cancelScope: vi.fn(), - getRecord: vi.fn(), - }; -} - -function startLiveTurn( - runId: string, - useResume: boolean, - options: { - onPhase?: (phase: "send" | "resolve") => void; - parseJsonlEvent?: CliBackendParseJsonlEvent; - } = {}, -) { - const context = buildClaudeLiveRunContext({ - runId, - timeoutMs: 60_000, - liveSessionRequirement, - backend: { resumeArgs: ["-p", "--resume", "{sessionId}"] }, - }); - context.backendResolved.parseJsonlEvent = options.parseJsonlEvent; - return runClaudeLiveSessionTurn({ - context, - args: context.preparedBackend.backend.args ?? [], - env: {}, - prompt: "hi", - useResume, - noOutputTimeoutMs: 5_000, - getProcessSupervisor: getProcessSupervisorForTest, - onAssistantDelta: () => {}, - onPhase: options.onPhase, - cleanup: async () => {}, - }); -} - -describe("Claude live-session capability negotiation", () => { - it("rejects a malformed terminal result before background-task deferral", async () => { - const parseJsonlEvent = vi.fn((line) => { - const parsed = JSON.parse(line) as { type?: string; result?: string }; - if (parsed.type !== "result" || !parsed.result?.includes('')) { - return null; - } - return { - kind: "result", - errorText: - "Claude CLI returned malformed tool output (invalid request format): raw tool protocol appeared as assistant text.", - }; - }); - const phases: Array<"send" | "resolve"> = []; - const fixture = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-malformed", - capabilities: ["msg_lifecycle_v1"], - }, - { - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "task-1", task_type: "local_agent", description: "still running" }], - }, - { - type: "result", - subtype: "success", - session_id: "live-malformed", - result: [ - '', - 'pwd', - "", - ].join("\n"), - }, - ], - }); - - await expect( - startLiveTurn("run-malformed-result", false, { - parseJsonlEvent, - onPhase: (phase) => phases.push(phase), - }), - ).rejects.toMatchObject({ - name: "FailoverError", - reason: "format", - status: 400, - rawError: expect.stringContaining("raw tool protocol appeared as assistant text"), - }); - expect(phases).toEqual(["resolve"]); - expect(fixture.writes.filter((line) => line.includes('"type":"user"'))).toHaveLength(1); - expect( - parseJsonlEvent.mock.calls.filter(([line]) => line.includes('"type":"result"')), - ).toHaveLength(1); - }); - - it.each([ - { label: "fresh", useResume: false }, - { label: "resumed", useResume: true }, - ])( - "retains a matching start before $label init and trusts capability over version", - async (testCase) => { - mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-capable", - claude_code_version: "2.1.100-custom", - capabilities: ["interrupt_receipt_v1", "msg_lifecycle_v1", "future_v2"], - }, - { - type: "result", - subtype: "success", - session_id: "live-capable", - result: "done", - }, - ], - }); - - await expect( - startLiveTurn(`run-capable-${testCase.label}`, testCase.useResume), - ).resolves.toMatchObject({ - output: { text: "done" }, - }); - }, - ); - - it.each([ - { label: "fresh", useResume: false }, - { label: "resumed", useResume: true }, - ])( - "fails immediately when $label init omits the required lifecycle capability", - async (testCase) => { - const fixture = mockClaudeLiveRun(supervisorSpawnMock, { - events: [ - { - type: "system", - subtype: "init", - session_id: "live-legacy", - claude_code_version: "2.1.205", - capabilities: ["interrupt_receipt_v1"], - }, - ], - }); - - await expect( - startLiveTurn(`run-legacy-${testCase.label}`, testCase.useResume), - ).rejects.toMatchObject({ - code: "cli_live_session_unsupported", - message: expect.stringContaining( - "Claude Code build (version 2.1.205) did not advertise the required msg_lifecycle_v1 capability", - ), - }); - expect(fixture.lifecycle.cancel).toHaveBeenCalledOnce(); - }, - ); -}); diff --git a/src/agents/cli-runner/claude-live-session.test-support.ts b/src/agents/cli-runner/claude-live-session.test-support.ts index 63bae6d71f2e..4ce5b7859d83 100644 --- a/src/agents/cli-runner/claude-live-session.test-support.ts +++ b/src/agents/cli-runner/claude-live-session.test-support.ts @@ -1,29 +1,12 @@ -import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; -import "./claude-live-session.js"; - -type BuildClaudeLiveArgsParams = { - args: string[]; - backend: CliBackendConfig; - systemPrompt: string; - useResume: boolean; - permissionMode?: string; -}; - -type ClaudeLiveSessionTestApi = { - buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[]; - resetClaudeLiveSessionsForTest(): void; -}; - -function getTestApi(): ClaudeLiveSessionTestApi { - return (globalThis as Record)[ - Symbol.for("openclaw.claudeLiveSessionTestApi") - ] as ClaudeLiveSessionTestApi; -} - -export function buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[] { - return getTestApi().buildClaudeLiveArgs(params); -} +import "./claude-live-registry.js"; +/** Resets the process registry between live-session tests. */ export function resetClaudeLiveSessionsForTest(): void { - getTestApi().resetClaudeLiveSessionsForTest(); + const reset = (globalThis as Record)[ + Symbol.for("openclaw.claudeLiveRegistryReset") + ]; + if (typeof reset !== "function") { + throw new Error("Claude live registry reset seam is unavailable"); + } + reset(); } diff --git a/src/agents/cli-runner/claude-live-session.test.ts b/src/agents/cli-runner/claude-live-session.test.ts new file mode 100644 index 000000000000..1918c130c72a --- /dev/null +++ b/src/agents/cli-runner/claude-live-session.test.ts @@ -0,0 +1,536 @@ +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; +import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js"; +import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; +import type { getProcessSupervisor } from "../../process/supervisor/index.js"; +import { + buildClaudeLiveRunContext, + buildPreparedCliRunContext, + createClaudeInputStartedEvent, + expectRejectsWithFields, + mockCallArg, + mockClaudeLiveRun, +} from "../cli-runner.test-helpers.js"; +import { + restoreCliRunnerPrepareTestDeps, + supervisorSpawnMock, +} from "../cli-runner.test-support.js"; +import { runClaudeTurn } from "./claude-live-session.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; +import { executePreparedCliRun } from "./execute.js"; + +function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { + const event = createClaudeInputStartedEvent(data); + if (event) { + stdout?.(`${JSON.stringify(event)}\n`); + } +} + +beforeEach(() => { + resetAgentEventsForTest(); + resetClaudeLiveSessionsForTest(); + replyRunTesting.resetReplyRunRegistry(); + restoreCliRunnerPrepareTestDeps(); + supervisorSpawnMock.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + resetClaudeLiveSessionsForTest(); + replyRunTesting.resetReplyRunRegistry(); +}); + +const promptFile = "/tmp/system-prompt.md"; +const baseBackend = { + command: "claude", + args: ["-p"], + output: "jsonl", + input: "stdin", + modelArg: "--model", + sessionArgs: ["--session-id", "{sessionId}"], + sessionMode: "always", + systemPromptArg: "--append-system-prompt", + systemPromptFileArg: "--append-system-prompt-file", + systemPromptWhen: "first", + liveSession: "claude-stdio", +} as CliBackendConfig; + +type ProcessSupervisor = ReturnType; +type SupervisorSpawnFn = ProcessSupervisor["spawn"]; + +async function captureClaudeLiveArgs(params: { + args: string[]; + backend: CliBackendConfig; + useResume: boolean; +}): Promise { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-args" }, + { type: "result", session_id: "live-args", result: "ok" }, + ], + }); + const context = buildPreparedCliRunContext({ backend: params.backend }); + await runClaudeTurn({ + context, + args: params.args, + env: {}, + prompt: "hello", + useResume: params.useResume, + noOutputTimeoutMs: 1_000, + getProcessSupervisor: () => ({ + spawn: (input: Parameters[0]) => + supervisorSpawnMock(input) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }), + onAssistantDelta: () => {}, + cleanup: async () => {}, + }); + return (mockCallArg(supervisorSpawnMock) as { argv: string[] }).argv; +} + +describe("Claude live process arguments", () => { + it("normalizes the live protocol while retaining resume state", async () => { + const args = await captureClaudeLiveArgs({ + args: ["-p", "--resume", "claude-session", "--session-id", "openclaw-session"], + backend: baseBackend, + useResume: true, + }); + + expect(args).toContain("--resume"); + expect(args).toContain("claude-session"); + expect(args).not.toContain("openclaw-session"); + expect(args).toEqual( + expect.arrayContaining([ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--permission-prompt-tool", + "stdio", + ]), + ); + }); + + it.each([ + { systemPromptWhen: "first", useResume: true, retained: false }, + { systemPromptWhen: "always", useResume: true, retained: true }, + { systemPromptWhen: "first", useResume: false, retained: true }, + { systemPromptWhen: "always", useResume: false, retained: true }, + ] as const)( + "retains=$retained the prompt file for systemPromptWhen=$systemPromptWhen resume=$useResume", + async ({ systemPromptWhen, useResume, retained }) => { + const args = await captureClaudeLiveArgs({ + args: ["-p", "--append-system-prompt-file", promptFile], + backend: { ...baseBackend, systemPromptWhen }, + useResume, + }); + expect(args.includes("--append-system-prompt-file")).toBe(retained); + expect(args.includes(promptFile)).toBe(retained); + }, + ); +}); + +describe("runClaudeTurn", () => { + it("keeps pre-tool commentary out of an empty-result Claude live reply", async () => { + const agentEvents: Array<{ stream: string; data: unknown }> = []; + const stop = onAgentEvent((event) => { + agentEvents.push({ stream: event.stream, data: event.data }); + }); + 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( + buildClaudeLiveRunContext({ + emitCommentaryText: true, + }), + ); + + expect(result.text).toBe("Final answer."); + expect(agentEvents).toContainEqual({ + stream: "item", + data: expect.objectContaining({ + kind: "preamble", + progressText: "Let me check.", + }), + }); + expect(agentEvents).toContainEqual({ + stream: "assistant", + data: { text: "Final answer.", delta: "Final answer." }, + }); + } finally { + stop(); + } + }); + + it("reports Claude live session reply backends as streaming until the turn finishes", async () => { + let markWriteReady: (() => void) | undefined; + const writeReady = new Promise((resolve) => { + markWriteReady = resolve; + }); + const live = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: () => { + markWriteReady?.(); + }, + }); + const operation = createReplyOperation({ + sessionKey: "agent:main:main", + sessionId: "live-session-reply", + resetTriggered: false, + }); + operation.setPhase("running"); + const context = buildClaudeLiveRunContext({ + sessionId: "live-session-reply", + sessionKey: "agent:main:main", + prompt: "hello", + }); + + const run = executePreparedCliRun({ + ...context, + params: { + ...context.params, + replyOperation: operation, + }, + }); + + await writeReady; + 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"); + operation.complete(); + }); + + it("reuses a Claude live session when resumed turns omit the system prompt arg", async () => { + let turn = 0; + mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ emit }) => { + turn += 1; + emit([ + { type: "system", subtype: "init", session_id: "live-system" }, + { type: "result", session_id: "live-system", result: turn === 1 ? "one" : "two" }, + ]); + }, + }); + + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + }; + const first = await executePreparedCliRun( + buildPreparedCliRunContext({ + prompt: "first", + backend, + }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ + prompt: "second", + backend, + }), + "live-system", + ); + + expect(first.text).toBe("one"); + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledOnce(); + }); + + it("restarts Claude live sessions when a multi-section stable prompt changes", async () => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-stable-prompt" }, + { type: "result", session_id: "live-stable-prompt", result: "one" }, + ], + cancelable: true, + }); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-stable-prompt" }, + { type: "result", session_id: "live-stable-prompt", result: "two" }, + ], + }); + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "always" as const, + }; + + await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `# OpenClaw\n\n## Stable Instructions\nFirst instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, + }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `# OpenClaw\n\n## Stable Instructions\nSecond instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Metadata`, + }), + "live-stable-prompt", + ); + + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + }); + + it.each([ + { + name: "ignores the system_prompt field", + responses: [{ subtype: "success" }], + }, + { + name: "rejects the live refresh", + responses: [ + { + subtype: "error", + error: "set_model: system_prompt must be a non-empty string when present", + }, + { subtype: "error", error: "unsupported" }, + ], + }, + ])("restarts when Claude $name", async ({ responses }) => { + let controlRequest = 0; + mockClaudeLiveRun(supervisorSpawnMock, { + cancelable: true, + onWrite: ({ data, emit }) => { + const parsed = JSON.parse(data) as { type: string; request_id?: string }; + if (parsed.type === "control_request") { + const response = responses[controlRequest]; + controlRequest += 1; + emit([ + { + type: "control_response", + response: { + request_id: parsed.request_id, + ...response, + }, + }, + ]); + return; + } + emit([ + { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, + { type: "result", session_id: "live-rejected-prompt", result: "one" }, + ]); + }, + }); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-rejected-prompt" }, + { type: "result", session_id: "live-rejected-prompt", result: "two" }, + ], + }); + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "always" as const, + }; + + await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, + }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, + }), + "live-rejected-prompt", + ); + + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + expect(controlRequest).toBe(responses.length); + }); + + it("restarts on marker-free prompt changes instead of weakening prompt identity", async () => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-marker-free" }, + { type: "result", session_id: "live-marker-free", result: "one" }, + ], + cancelable: true, + }); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-marker-free" }, + { type: "result", session_id: "live-marker-free", result: "two" }, + ], + }); + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "always" as const, + }; + + await executePreparedCliRun( + buildPreparedCliRunContext({ backend, systemPrompt: "First complete prompt" }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ backend, systemPrompt: "Second complete prompt" }), + "live-marker-free", + ); + + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + }); + + it("keeps legacy first-only system prompts on full-prompt restart identity", async () => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, + { type: "result", session_id: "live-first-only-prompt", result: "one" }, + ], + cancelable: true, + }); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-first-only-prompt" }, + { type: "result", session_id: "live-first-only-prompt", result: "two" }, + ], + }); + const backend = { + resumeArgs: ["-p", "--output-format", "stream-json", "--resume={sessionId}"], + liveSession: "claude-stdio" as const, + systemPromptWhen: "first" as const, + }; + + await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}First metadata`, + }), + ); + const second = await executePreparedCliRun( + buildPreparedCliRunContext({ + backend, + systemPrompt: `Stable instructions${SYSTEM_PROMPT_CACHE_BOUNDARY}Second metadata`, + }), + "live-first-only-prompt", + ); + + expect(second.text).toBe("two"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + }); + + it("restarts the Claude live process after request abort", async () => { + const abortController = new AbortController(); + let stdoutListener: ((chunk: string) => void) | undefined; + const cancels: Array> = []; + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + stdoutListener = input.onStdout; + const spawnIndex = supervisorSpawnMock.mock.calls.length; + const cancel = vi.fn(); + cancels.push(cancel); + const stdin = { + write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, dataValue); + if (spawnIndex === 2) { + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort-2" }), + JSON.stringify({ + type: "result", + session_id: "live-abort-2", + result: "second-ok", + }), + ].join("\n") + "\n", + ); + } + cb?.(); + }), + end: vi.fn(), + }; + return { + runId: `live-run-${spawnIndex}`, + pid: 2345 + spawnIndex, + startedAtMs: Date.now(), + stdin, + wait: vi.fn( + () => + new Promise((resolve) => { + if (spawnIndex === 1) { + cancel.mockImplementationOnce(() => { + resolve({ + reason: "manual-cancel", + exitCode: null, + exitSignal: null, + durationMs: 50, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }); + } + }), + ), + cancel, + }; + }); + + const firstContext = buildClaudeLiveRunContext({}); + firstContext.params.abortSignal = abortController.signal; + const first = executePreparedCliRun(firstContext); + + await vi.waitFor(() => { + expect(supervisorSpawnMock).toHaveBeenCalledTimes(1); + }); + abortController.abort(); + + await expectRejectsWithFields(first, { name: "AbortError" }); + expect(cancels[0]).toHaveBeenCalledWith("manual-cancel"); + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-abort" }), + JSON.stringify({ + type: "result", + session_id: "live-abort", + result: "discarded", + }), + ].join("\n") + "\n", + ); + + const second = await executePreparedCliRun(buildClaudeLiveRunContext({})); + + expect(second.text).toBe("second-ok"); + expect(supervisorSpawnMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 22a932e8c21a..58ca5597632c 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -1,261 +1,85 @@ -/** - * Manages reusable Claude CLI stdio sessions for CLI-backed agent turns. - */ +/** Coordinates admission and reuse for Claude CLI live processes. */ import crypto from "node:crypto"; import { splitSystemPromptCacheBoundary, stripSystemPromptCacheBoundary, } from "@openclaw/ai/internal/shared"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { ReplyBackendHandle } from "../../auto-reply/reply/reply-run-registry.js"; import { createAbortError as createNamedAbortError } from "../../infra/abort-signal.js"; -import { - emitTrustedDiagnosticEvent, - type DiagnosticToolParamsSummary, - type DiagnosticToolSource, - type DiagnosticToolExecutionErrorEvent, -} from "../../infra/diagnostic-events.js"; +import { sha256Hex } from "../../infra/crypto-digest.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import type { ExecAsk, ExecSecurity } from "../../infra/exec-approvals.js"; -import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js"; -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { - CliBackendConfig, - CliBackendLiveSessionRequirement, - CliBackendParseJsonlEvent, -} from "../../plugins/cli-backend.types.js"; -import type { - CliOutput, CliStreamingDelta, - CliStreamJsonOutputLimits, CliThinkingDelta, CliThinkingProgress, CliToolResultDelta, CliToolUseStartDelta, CliUsage, } from "../cli-output-contracts.js"; +import { isTimeoutError, FailoverError, resolveFailoverStatus } from "../failover-error.js"; import { - CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, - CLI_STREAM_JSON_OUTPUT_LIMITS, - createCliJsonlStreamingParser, - frameBoundedCliJsonlChunk, - normalizeClaudeCliStreamJsonRecord, -} from "../cli-output-stream.js"; -import { extractCliErrorMessage, parseCliOutput } from "../cli-output.js"; -import { classifyFailoverReason } from "../embedded-agent-helpers.js"; -import { resolveExecDefaults } from "../exec-defaults.js"; + abortClaudeTurn, + beginClaudeTurn, + createClaudeUserInputMessage, + refreshClaudePrompt, + resolveClaudeLiveExecPermission, + spawnClaudeProcess, + writeClaudeInput, + type ClaudeLiveProcess, +} from "./claude-live-process.js"; import { - type CliTimeoutContext, - FailoverError, - isTimeoutError, - resolveFailoverStatus, -} from "../failover-error.js"; -import { resolveCliToolTerminalReason } from "../run-termination.js"; -import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; -import { LIVE_SESSION_LIMITS, resolveClaudeLiveMode } from "./claude-live-session-policy.js"; -import { - requestClaudeNativeToolApproval, - resolveClaudeNativeToolApprovalPlan, -} from "./claude-live-tool-approval.js"; -import { buildClaudeOwnerKey } from "./helpers.js"; -import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js"; -import { createCliOutputFailoverError } from "./output-error.js"; + buildClaudeLiveKey, + beginClaudeSessionCreate, + enqueueClaudeTurn, + ensureClaudeSessionCapacity, + finishClaudeSessionCreate, + getClaudeSession, + registerClaudeSession, + removeClaudeSession, +} from "./claude-live-registry.js"; +import type { ClaudeLiveToolTerminalOutcome } from "./claude-live-turn.js"; +import { cliBackendLog } from "./log.js"; import type { PreparedCliRunContext } from "./types.js"; type ProcessSupervisor = ReturnType< typeof import("../../process/supervisor/index.js").getProcessSupervisor >; -type ManagedRun = Awaited>; -type ClaudeLiveTurn = { - backend: CliBackendConfig; - parseJsonlEvent?: CliBackendParseJsonlEvent; - diagnosticRefs: ClaudeLiveDiagnosticRefs; - /** Enclosing run abort signal; authoritative for tool terminal reason on turn failure. */ - abortSignal?: AbortSignal; - outputLimits: ClaudeLiveOutputLimits; - startedAtMs: number; - rawLines: string[]; - sessionId?: string; - noOutputTimer: NodeJS.Timeout | null; - /** Last stdout/stderr time; null until the process emits anything this turn. */ - lastOutputAtMs: number | null; - timeoutTimer: NodeJS.Timeout | null; - activeTools: Map; - observedStdout: boolean; - /** UUID sent with this input; terminal records belong to the turn only after its started event. */ - inputUuid: string; - inputStarted: boolean; - /** Reports process identity from init even when the current input has not started yet. */ - onSessionId?: (sessionId: string) => void; - /** Only resumed turns may replay a lifecycle-only stall through a fork. */ - useResume: boolean; - /** True after output that makes replaying the submitted input unsafe. */ - hasReplayUnsafeActivity: boolean; - completedToolCallIds: Set; - toolEventCount: number; - streamingParser: ReturnType; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onPhase?: (phase: "send" | "resolve") => void; - execPermission: ClaudeLiveExecPermission; - resolve: (output: CliOutput) => void; - reject: (error: unknown) => void; -}; -type ClaudeLiveSession = { - key: string; - generation: string; - fingerprint: string; - systemPromptHash: string; - systemPromptSwitchCapability: "unknown" | "supported" | "unsupported"; - liveSessionRequirement?: CliBackendLiveSessionRequirement; - liveSessionCapabilityReady: boolean; - managedRun: ManagedRun; - providerId: string; - modelId: string; - sessionId?: string; - noOutputTimeoutMs: number; - stderr: string; - stdoutBuffer: { pending: string }; - currentTurn: ClaudeLiveTurn | null; - idleTimer: NodeJS.Timeout | null; - cleanup: () => Promise; - cleanupPromise: Promise | null; - closing: boolean; - pendingControlRequest: ClaudeLivePendingControlRequest | null; - mcpCaptureKey?: string; - /** - * Native-tool allow-always grants are process-session scoped and in-memory only. - * They must not survive the Claude CLI process, so persistence is intentionally absent. - */ - nativeToolApprovalGrants: Set; - /** - * Subagent/workflow task ids from the latest background_tasks_changed event. - * That event lists all CLI background work, but only local_agent and - * local_workflow hold the final result (local_bash is killed at exit). - */ - outstandingBackgroundTaskIds: Set; -}; -type ClaudeLiveSessionCreate = { - generation: string; - promise: Promise; -}; -type ClaudeLivePendingControlRequest = { - requestId: string; - timer: NodeJS.Timeout; - resolve: (response: ClaudeLiveControlResponse | null) => void; -}; -type ClaudeLiveControlResponse = { - subtype: string; - error?: string; -}; + type ClaudeLiveRunResult = { - output: CliOutput; + output: import("../cli-output-contracts.js").CliOutput; }; -type ClaudeLiveOutputLimits = CliStreamJsonOutputLimits; -type ClaudeLiveExecPermission = { - security: ExecSecurity; - ask: ExecAsk; - permissionMode: "bypassPermissions" | "default"; + +type RunClaudeTurnParams = { + context: PreparedCliRunContext; + args: string[]; + executableCommand?: string; + executableLeadingArgv?: readonly string[]; + env: Record; + prompt: string; + useResume: boolean; + forceNewSession?: boolean; + requiredSessionGeneration?: string; + noOutputTimeoutMs: number; + getProcessSupervisor: () => ProcessSupervisor; + onAssistantDelta: (delta: CliStreamingDelta) => void; + onThinkingDelta?: (delta: CliThinkingDelta) => void; + onThinkingProgress?: (progress: CliThinkingProgress) => void; + onToolUseStart?: (delta: CliToolUseStartDelta) => void; + onToolResult?: (delta: CliToolResultDelta) => void; + resolveToolResultTerminalOutcome?: ( + delta: CliToolResultDelta, + ) => ClaudeLiveToolTerminalOutcome | undefined; + onCommentaryText?: (text: string) => void; + onMcpCaptureReady?: (captureKey: string) => void; + onSessionId?: (sessionId: string) => void; + onAssistantMessage?: (message: unknown) => void; + onUsage?: (usage: CliUsage, terminal: boolean) => void; + onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; + onRequestPayload?: (payload: string) => void; + onPhase?: (phase: "send" | "resolve") => void; + cleanup: () => Promise; }; -type ClaudeLiveDiagnosticRefs = { - runId: string; - sessionId: string; - sessionKey?: string; - agentId?: string; -}; -type ClaudeLiveActiveTool = { - toolName: string; - toolCallId: string; - kind: CliToolUseStartDelta["kind"]; - startedAt: number; -}; -type ClaudeLiveToolTerminalOutcome = - | { outcome: "blocked"; deniedReason: string; reason?: string } - | { outcome: "cancelled" | "failed" | "timed_out" | "unknown" }; -const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; -const CLAUDE_LIVE_CONTROL_TIMEOUT_MS = 3_000; -const CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR = - "set_model: system_prompt must be a non-empty string when present"; -const CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS = 5_000; -const liveSessions = new Map(); -const liveSessionCreates = new Map(); -const liveSessionTurns = new KeyedAsyncQueue(); - -function sha256(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -/** Closes all live Claude CLI sessions and clears creation promises for tests. */ -function resetClaudeLiveSessionsForTest(): void { - for (const session of liveSessions.values()) { - closeLiveSession(session, "restart"); - } - liveSessions.clear(); - liveSessionCreates.clear(); -} - -/** Returns whether this owner still has an in-process Claude stdio session. */ -export function hasClaudeLiveSessionForOwner(owner: ClaudeLiveSessionOwner): boolean { - return getClaudeLiveSessionGenerationForOwner(owner) !== undefined; -} - -/** Returns the opaque generation of this owner's current or pending Claude stdio session. */ -export function getClaudeLiveSessionGenerationForOwner( - owner: ClaudeLiveSessionOwner, -): string | undefined { - const key = buildClaudeLiveOwnerKey(owner); - return liveSessions.get(key)?.generation ?? liveSessionCreates.get(key)?.generation; -} - -async function waitForManagedRunExit(managedRun: ManagedRun): Promise { - let timeout: NodeJS.Timeout | null = null; - try { - await Promise.race([ - managedRun.wait().then( - () => undefined, - () => undefined, - ), - new Promise((resolve) => { - timeout = setTimeout(resolve, CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - -/** Closes the live Claude session associated with a prepared run context, if one exists. */ -export async function closeClaudeLiveSessionForContext( - context: PreparedCliRunContext, -): Promise { - const key = buildClaudeLiveKey(context); - const session = liveSessions.get(key); - if (session) { - closeLiveSession(session, "restart"); - await waitForManagedRunExit(session.managedRun); - } - liveSessionCreates.delete(key); -} - -/** Close a tainted live process so its replacement gets a fresh MCP capture key. */ -export async function rotateClaudeLiveMcpCaptureKeyForContext( - context: PreparedCliRunContext, -): Promise { - await closeClaudeLiveSessionForContext(context); -} - -/** Returns whether a prepared backend context is eligible for Claude live stdio reuse. */ -export function shouldUseClaudeLiveSession(context: PreparedCliRunContext): boolean { - return ( - context.params.sessionEntry?.execHost !== "node" && - context.backendResolved.id === "claude-cli" && - context.preparedBackend.backend.liveSession === "claude-stdio" && - context.preparedBackend.backend.output === "jsonl" && - context.preparedBackend.backend.input === "stdin" - ); -} function upsertArgValue(args: string[], flag: string, value: string): string[] { const normalized: string[] = []; @@ -305,7 +129,6 @@ function stripLiveProcessArgs( return stripped; } -/** Builds Claude CLI args for stream-json live sessions, stripping one-shot session flags. */ function buildClaudeLiveArgs(params: { args: string[]; backend: CliBackendConfig; @@ -333,44 +156,11 @@ function buildClaudeLiveArgs(params: { ), "--replay-user-messages", ); - // Live sessions always speak stream-json over stdin/stdout. Strip stale one-shot args above, then - // force the live protocol flags so resume and non-resume turns share the same process contract. return params.permissionMode ? upsertArgValue(liveArgs, "--permission-mode", params.permissionMode) : liveArgs; } -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.claudeLiveSessionTestApi")] = { - buildClaudeLiveArgs, - resetClaudeLiveSessionsForTest, - }; -} - -type ClaudeLiveSessionOwner = { - backendId: string; - agentAccountId?: string; - agentId?: string; - authProfileId?: string; - sessionId?: string; - sessionKey?: string; -}; - -function buildClaudeLiveOwnerKey(owner: ClaudeLiveSessionOwner): string { - return `${owner.backendId}:${buildClaudeOwnerKey(owner)}`; -} - -function buildClaudeLiveKey(context: PreparedCliRunContext): string { - return buildClaudeLiveOwnerKey({ - backendId: context.backendResolved.id, - agentAccountId: context.params.agentAccountId, - agentId: context.params.agentId, - authProfileId: context.effectiveAuthProfileId, - sessionId: context.params.sessionId, - sessionKey: context.params.sessionKey, - }); -} - function buildClaudeLiveFingerprint(params: { context: PreparedCliRunContext; argv: string[]; @@ -383,9 +173,9 @@ function buildClaudeLiveFingerprint(params: { const normalizeMcpConfigPath = Boolean(params.context.preparedBackend.mcpConfigHash); const skillSnapshot = params.context.params.skillsSnapshot; const skillsFingerprint = skillSnapshot - ? sha256( + ? sha256Hex( JSON.stringify({ - promptHash: sha256(skillSnapshot.prompt), + promptHash: sha256Hex(skillSnapshot.prompt), skillFilter: skillSnapshot.skillFilter, skills: skillSnapshot.skills, resolvedSkills: (skillSnapshot.resolvedSkills ?? []).map((skill) => ({ @@ -398,7 +188,6 @@ function buildClaudeLiveFingerprint(params: { }), ) : undefined; - const normalizePluginDir = Boolean(skillsFingerprint); const omittedValueFlags = new Set( [ params.context.preparedBackend.backend.systemPromptArg, @@ -411,7 +200,7 @@ function buildClaudeLiveFingerprint(params: { [ "--session-id", normalizeMcpConfigPath ? "--mcp-config" : undefined, - normalizePluginDir ? "--plugin-dir" : undefined, + skillsFingerprint ? "--plugin-dir" : undefined, ].filter((entry): entry is string => typeof entry === "string" && entry.length > 0), ); const stableArgv: string[] = []; @@ -437,15 +226,15 @@ function buildClaudeLiveFingerprint(params: { } return JSON.stringify({ command: params.argv[0], - workspaceDirHash: sha256(params.context.workspaceDir), - cwdHash: params.context.cwdHash ?? sha256(params.context.cwd ?? params.context.workspaceDir), + workspaceDirHash: sha256Hex(params.context.workspaceDir), + cwdHash: params.context.cwdHash ?? sha256Hex(params.context.cwd ?? params.context.workspaceDir), provider: params.context.params.provider, model: params.context.normalizedModel, - systemPromptHash: sha256(stableSystemPrompt), + systemPromptHash: sha256Hex(stableSystemPrompt), authProfileIdHash: params.context.effectiveAuthProfileId - ? sha256(params.context.effectiveAuthProfileId) + ? sha256Hex(params.context.effectiveAuthProfileId) : undefined, - authEpochHash: params.context.authEpoch ? sha256(params.context.authEpoch) : undefined, + authEpochHash: params.context.authEpoch ? sha256Hex(params.context.authEpoch) : undefined, extraSystemPromptHash: params.context.extraSystemPromptHash, promptToolNamesHash: params.context.promptToolNamesHash, mcpConfigHash: params.context.preparedBackend.mcpConfigHash, @@ -454,12 +243,10 @@ function buildClaudeLiveFingerprint(params: { argv: stableArgv, env: Object.keys(params.env) .toSorted() - .map((key) => [key, params.env[key] ? sha256(params.env[key]) : ""]), + .map((key) => [key, params.env[key] ? sha256Hex(params.env[key]) : ""]), }); } -// Preserve timeout identity and abort reasons so audit terminal outcomes -// can distinguish timed_out from cancelled runs. function createAbortError(reason?: unknown): Error { if (reason instanceof Error && isTimeoutError(reason)) { return reason; @@ -479,1270 +266,6 @@ function createAbortError(reason?: unknown): Error { return error; } -function clearTurnTimers(turn: ClaudeLiveTurn): void { - if (turn.noOutputTimer) { - clearTimeout(turn.noOutputTimer); - turn.noOutputTimer = null; - } - if (turn.timeoutTimer) { - clearTimeout(turn.timeoutTimer); - turn.timeoutTimer = null; - } -} - -function clearOutstandingBackgroundTasks(session: ClaudeLiveSession): void { - session.outstandingBackgroundTaskIds.clear(); -} - -function settleClaudeLivePendingControlRequest( - session: ClaudeLiveSession, - response: ClaudeLiveControlResponse | null, -): void { - const pending = session.pendingControlRequest; - if (!pending) { - return; - } - clearTimeout(pending.timer); - session.pendingControlRequest = null; - pending.resolve(response); -} - -function finishTurn(session: ClaudeLiveSession, output: CliOutput): void { - const turn = session.currentTurn; - if (!turn) { - return; - } - cliBackendLog.info( - `claude live session turn: provider=${session.providerId} model=${session.modelId} durationMs=${Date.now() - turn.startedAtMs} rawLines=${turn.rawLines.length} ${formatCliBackendOutputDigest(output.text)}`, - ); - turn.streamingParser.finish(); - failActiveClaudeLiveTools(turn, new Error("Tool result missing before turn completed")); - clearTurnTimers(turn); - clearOutstandingBackgroundTasks(session); - session.currentTurn = null; - turn.resolve(output); - scheduleIdleClose(session); -} - -function failTurn(session: ClaudeLiveSession, error: unknown): void { - const turn = session.currentTurn; - if (!turn) { - return; - } - const errorKind = error instanceof Error ? error.name : typeof error; - cliBackendLog.warn( - `claude live session turn failed: provider=${session.providerId} model=${session.modelId} durationMs=${Date.now() - turn.startedAtMs} error=${errorKind}`, - ); - turn.streamingParser.finish(); - failActiveClaudeLiveTools(turn, error); - clearTurnTimers(turn); - clearOutstandingBackgroundTasks(session); - session.currentTurn = null; - turn.reject(error); -} - -function abortTurn(session: ClaudeLiveSession, error: Error): void { - const turn = session.currentTurn; - if (!turn) { - return; - } - closeLiveSession(session, "abort", error); -} - -function cleanupLiveSession(session: ClaudeLiveSession): Promise { - if (!session.cleanupPromise) { - session.cleanupPromise = session.cleanup().catch((error: unknown) => { - cliBackendLog.warn(`Claude live session cleanup failed: ${formatErrorMessage(error)}`); - }); - } - return session.cleanupPromise; -} - -function closeLiveSession( - session: ClaudeLiveSession, - reason: "idle" | "restart" | "abort", - error?: unknown, -): void { - if (session.closing) { - return; - } - cliBackendLog.info( - `claude live session close: provider=${session.providerId} model=${session.modelId} reason=${reason}`, - ); - session.closing = true; - if (session.idleTimer) { - clearTimeout(session.idleTimer); - session.idleTimer = null; - } - if (liveSessions.get(session.key) === session) { - liveSessions.delete(session.key); - } - settleClaudeLivePendingControlRequest(session, null); - if (error) { - failTurn(session, error); - } else { - clearOutstandingBackgroundTasks(session); - } - session.managedRun.cancel("manual-cancel"); - void cleanupLiveSession(session); -} - -function scheduleIdleClose(session: ClaudeLiveSession): void { - if (session.idleTimer) { - clearTimeout(session.idleTimer); - } - session.idleTimer = setTimeout(() => { - if (!session.currentTurn) { - closeLiveSession(session, "idle"); - } - }, CLAUDE_LIVE_IDLE_TIMEOUT_MS); -} - -function createTimeoutError( - session: ClaudeLiveSession, - message: string, - code?: string, - cliTimeout?: CliTimeoutContext, -): FailoverError { - return new FailoverError(message, { - reason: "timeout", - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus("timeout"), - code, - cliTimeout, - }); -} - -function createOutputLimitError(session: ClaudeLiveSession, message: string): FailoverError { - return new FailoverError(message, { - reason: "format", - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus("format"), - }); -} - -function diagnosticToolSourceForClaudeLiveTool(toolName: string): DiagnosticToolSource { - return toolName.startsWith("mcp__") ? "mcp" : "core"; -} - -function claudeLiveDiagnosticBase(turn: ClaudeLiveTurn) { - return { - runId: turn.diagnosticRefs.runId, - sessionId: turn.diagnosticRefs.sessionId, - ...(turn.diagnosticRefs.sessionKey ? { sessionKey: turn.diagnosticRefs.sessionKey } : {}), - ...(turn.diagnosticRefs.agentId ? { agentId: turn.diagnosticRefs.agentId } : {}), - }; -} - -function emitClaudeLiveProgress(turn: ClaudeLiveTurn, reason: string): void { - emitTrustedDiagnosticEvent({ - type: "run.progress", - ...claudeLiveDiagnosticBase(turn), - reason, - }); -} - -function summarizeClaudeLiveToolInput(input: unknown): DiagnosticToolParamsSummary | undefined { - if (input === undefined) { - return undefined; - } - if (input === null) { - return { kind: "null" }; - } - if (Array.isArray(input)) { - return { kind: "array", length: input.length }; - } - switch (typeof input) { - case "object": - return { kind: "object" }; - case "string": - return { kind: "string", length: input.length }; - case "number": - return { kind: "number" }; - case "boolean": - return { kind: "boolean" }; - case "undefined": - return { kind: "undefined" }; - default: - return { kind: "other" }; - } -} - -function markClaudeLiveToolStarted(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { - if (turn.completedToolCallIds.has(tool.toolCallId) || turn.activeTools.has(tool.toolCallId)) { - return; - } - const now = Date.now(); - turn.activeTools.set(tool.toolCallId, { - toolName: tool.name, - toolCallId: tool.toolCallId, - kind: tool.kind, - startedAt: now, - }); - turn.toolEventCount += 1; - emitTrustedDiagnosticEvent({ - type: "tool.execution.started", - ...claudeLiveDiagnosticBase(turn), - toolName: tool.name, - toolSource: diagnosticToolSourceForClaudeLiveTool(tool.name), - toolOwner: "claude-cli", - toolCallId: tool.toolCallId, - paramsSummary: summarizeClaudeLiveToolInput(tool.args), - }); - emitClaudeLiveProgress(turn, "cli_live:tool_started"); -} - -function markClaudeLiveToolCompleted( - turn: ClaudeLiveTurn, - result: CliToolResultDelta, - terminalOutcome?: ClaudeLiveToolTerminalOutcome, -): void { - if (turn.completedToolCallIds.has(result.toolCallId)) { - return; - } - turn.toolEventCount += 1; - const activeTool = turn.activeTools.get(result.toolCallId); - if (!activeTool) { - emitClaudeLiveProgress(turn, "cli_live:tool_result"); - return; - } - turn.activeTools.delete(result.toolCallId); - turn.completedToolCallIds.add(result.toolCallId); - const event = { - ...claudeLiveDiagnosticBase(turn), - toolName: activeTool.toolName, - toolSource: diagnosticToolSourceForClaudeLiveTool(activeTool.toolName), - toolOwner: "claude-cli", - toolCallId: activeTool.toolCallId, - durationMs: Math.max(0, Date.now() - activeTool.startedAt), - }; - if (terminalOutcome?.outcome === "blocked") { - emitTrustedDiagnosticEvent({ - type: "tool.execution.blocked", - ...event, - deniedReason: terminalOutcome.deniedReason, - reason: terminalOutcome.reason ?? "blocked by before-tool policy", - }); - } else if (terminalOutcome?.outcome === "unknown") { - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory: "cli_tool_ambiguous", - errorCode: "tool_outcome_unknown", - }); - } else if (terminalOutcome || result.isError) { - const terminalReason = terminalOutcome?.outcome ?? "failed"; - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory: terminalReason === "cancelled" ? "aborted" : "tool_failed", - terminalReason, - }); - } else { - emitTrustedDiagnosticEvent({ - type: "tool.execution.completed", - ...event, - }); - } - emitClaudeLiveProgress(turn, "cli_live:tool_result"); -} - -function markClaudeLiveToolDenied(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { - markClaudeLiveToolStarted(turn, tool); - markClaudeLiveToolCompleted( - turn, - { toolCallId: tool.toolCallId, name: tool.name, isError: true }, - { - outcome: "blocked", - deniedReason: "cli_live_exec_policy", - reason: "blocked by CLI live execution policy", - }, - ); -} - -function failActiveClaudeLiveTools(turn: ClaudeLiveTurn, error: unknown): void { - const terminalReason = resolveCliToolTerminalReason({ - error, - abortSignal: turn.abortSignal, - }); - const errorCategory = - terminalReason === "timed_out" - ? "timeout" - : terminalReason === "cancelled" - ? "aborted" - : "error"; - for (const activeTool of turn.activeTools.values()) { - const event: Omit = - { - ...claudeLiveDiagnosticBase(turn), - toolName: activeTool.toolName, - toolSource: diagnosticToolSourceForClaudeLiveTool(activeTool.toolName), - toolOwner: "claude-cli", - toolCallId: activeTool.toolCallId, - durationMs: Math.max(0, Date.now() - activeTool.startedAt), - }; - if (activeTool.kind === "server_tool_use") { - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory: "cli_tool_ambiguous", - errorCode: "tool_outcome_unknown", - }); - continue; - } - emitTrustedDiagnosticEvent({ - type: "tool.execution.error", - ...event, - errorCategory, - terminalReason, - }); - } - turn.activeTools.clear(); -} - -function noteClaudeLiveProgress( - turn: ClaudeLiveTurn, - parsed: Record, - sawToolEvent: boolean, -): void { - if (parsed.type === "result") { - emitClaudeLiveProgress(turn, "cli_live:result"); - return; - } - if (sawToolEvent) { - return; - } - emitClaudeLiveProgress(turn, "cli_live:stream_progress"); -} - -// The CLI emits a tool_use line, then nothing until the tool result, so a -// quiet long-running tool is indistinguishable from a wedged process at the -// stdout level. While observed tool calls or CLI-reported background tasks -// (background_tasks_changed) are outstanding, extend the quiet window to the -// blocked-tool floor instead of killing mid-work. -function armNoOutputTimer(session: ClaudeLiveSession, turn: ClaudeLiveTurn, delayMs: number): void { - if (turn.noOutputTimer) { - clearTimeout(turn.noOutputTimer); - } - turn.noOutputTimer = setTimeout(() => { - const quietSinceMs = turn.lastOutputAtMs ?? turn.startedAtMs; - const hasOutstandingBackgroundWork = - turn.activeTools.size > 0 || session.outstandingBackgroundTaskIds.size > 0; - if (hasOutstandingBackgroundWork) { - const quietBudgetMs = Math.max(session.noOutputTimeoutMs, BLOCKED_TOOL_CALL_ABORT_FLOOR_MS); - const remainingMs = quietSinceMs + quietBudgetMs - Date.now(); - if (remainingMs > 0) { - armNoOutputTimer(session, turn, remainingMs); - return; - } - } - const retryableResumeStall = - turn.useResume && - session.stdoutBuffer.pending.trim().length === 0 && - !turn.hasReplayUnsafeActivity && - turn.toolEventCount === 0 && - turn.activeTools.size === 0 && - session.outstandingBackgroundTaskIds.size === 0; - closeLiveSession( - session, - "abort", - createTimeoutError( - session, - `CLI produced no output for ${Math.round((Date.now() - quietSinceMs) / 1000)}s and was terminated.`, - // A resumed stream can emit only lifecycle/init records before it - // wedges. No assistant/tool work has happened, so recovery can fork - // the cached session before transcript reseed. - turn.lastOutputAtMs === null || retryableResumeStall ? "cli_no_output_timeout" : undefined, - { - mode: "no-output", - timeoutSeconds: Math.round((Date.now() - quietSinceMs) / 1000), - observedActivity: - turn.lastOutputAtMs !== null || turn.toolEventCount > 0 || turn.rawLines.length > 0, - activeToolCount: turn.activeTools.size, - backgroundTaskCount: session.outstandingBackgroundTaskIds.size, - }, - ), - ); - }, delayMs); -} - -// Claude Code holds its final output for background subagents AND workflows -// (headless "Background tasks at exit" contract); both continue the parent and -// emit a post-drain result. Dropping either type here would finalize the turn -// early and strand that work; the turn timeout stays the explicit hard bound. -const CLAUDE_LIVE_RESULT_HOLDING_BACKGROUND_TASK_TYPES = new Set(["local_agent", "local_workflow"]); - -/** Replace outstanding subagent/workflow task ids from background_tasks_changed. */ -function applyBackgroundTasksChanged( - session: ClaudeLiveSession, - parsed: Record, -): void { - if (parsed.type !== "system" || parsed.subtype !== "background_tasks_changed") { - return; - } - // tasks is the full authoritative list (not a delta). Only subagent/workflow - // types hold the final result; e.g. local_bash is listed but killed at exit. - const tasks = Array.isArray(parsed.tasks) ? parsed.tasks : []; - session.outstandingBackgroundTaskIds.clear(); - for (const task of tasks) { - if (!isRecord(task)) { - continue; - } - const taskType = typeof task.task_type === "string" ? task.task_type.trim() : ""; - if (!CLAUDE_LIVE_RESULT_HOLDING_BACKGROUND_TASK_TYPES.has(taskType)) { - continue; - } - const taskId = typeof task.task_id === "string" ? task.task_id.trim() : ""; - if (taskId) { - session.outstandingBackgroundTaskIds.add(taskId); - } - } -} - -function applyClaudeLiveInputLifecycle( - turn: ClaudeLiveTurn, - parsed: Record, -): void { - if ( - parsed.type === "command_lifecycle" && - parsed.command_uuid === turn.inputUuid && - parsed.state === "started" && - !turn.inputStarted - ) { - // A reused process may finish queued work before reading this input. Only - // Claude's matching started event makes later terminal records ours. - turn.inputStarted = true; - emitClaudeLiveProgress(turn, "cli_live:input_started"); - } -} - -function applyClaudeLiveSessionRequirement( - session: ClaudeLiveSession, - parsed: Record, -): boolean { - const requirement = session.liveSessionRequirement; - if (!requirement || parsed.type !== "system" || parsed.subtype !== "init") { - return true; - } - const capabilities = Array.isArray(parsed.capabilities) - ? parsed.capabilities.filter((value): value is string => typeof value === "string") - : []; - if (capabilities.includes(requirement.capability)) { - session.liveSessionCapabilityReady = true; - return true; - } - const version = - typeof parsed.claude_code_version === "string" - ? parsed.claude_code_version.trim() || undefined - : undefined; - const versionDetail = version ? ` (version ${version})` : ""; - closeLiveSession( - session, - "abort", - new FailoverError( - `The running Claude Code build${versionDetail} did not advertise the required ${requirement.capability} capability. Claude Code ${requirement.minimumVersion} is the first known compatible release. Run \`${requirement.updateCommand}\`, restart OpenClaw, and retry.`, - { - reason: "format", - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus("format"), - code: "cli_live_session_unsupported", - }, - ), - ); - return false; -} - -function resetNoOutputTimer(session: ClaudeLiveSession): void { - const turn = session.currentTurn; - if (!turn) { - return; - } - turn.lastOutputAtMs = Date.now(); - armNoOutputTimer(session, turn, session.noOutputTimeoutMs); -} - -function parseSessionId(parsed: Record): string | undefined { - const sessionId = - typeof parsed.session_id === "string" - ? parsed.session_id.trim() - : typeof parsed.sessionId === "string" - ? parsed.sessionId.trim() - : ""; - return sessionId || undefined; -} - -function resolveClaudeLiveExecPermission(context: PreparedCliRunContext): ClaudeLiveExecPermission { - const { security, ask } = resolveExecDefaults({ - cfg: context.params.config, - sessionEntry: context.params.sessionEntry, - execOverrides: context.params.execOverrides, - agentId: context.params.agentId, - sessionKey: context.params.runtimePolicySessionKey ?? context.params.sessionKey, - }); - return { - security, - ask, - permissionMode: resolveClaudeLiveMode(security, ask, process.getuid?.()), - }; -} - -function parseClaudeLiveJsonLine(trimmed: string): Record | null { - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return null; - } - return isRecord(parsed) ? parsed : null; -} - -function writeClaudeLiveControlResponse(session: ClaudeLiveSession, response: unknown): void { - const stdin = session.managedRun.stdin; - if (!stdin) { - throw new Error("Claude CLI live session stdin is unavailable"); - } - stdin.write(`${JSON.stringify(response)}\n`); -} - -function handleClaudeLiveControlResponse( - session: ClaudeLiveSession, - parsed: Record, -): boolean { - const pending = session.pendingControlRequest; - if (!pending || parsed.type !== "control_response" || !isRecord(parsed.response)) { - return false; - } - const response = parsed.response; - if (response.request_id !== pending.requestId) { - return false; - } - settleClaudeLivePendingControlRequest(session, { - subtype: typeof response.subtype === "string" ? response.subtype : "", - ...(typeof response.error === "string" ? { error: response.error } : {}), - }); - return true; -} - -async function requestClaudeLiveModelUpdate(params: { - session: ClaudeLiveSession; - model: string; - systemPrompt: string; -}): Promise { - if (params.session.pendingControlRequest) { - return null; - } - const requestId = crypto.randomUUID(); - const response = new Promise((resolve) => { - params.session.pendingControlRequest = { - requestId, - timer: setTimeout(() => { - settleClaudeLivePendingControlRequest(params.session, null); - }, CLAUDE_LIVE_CONTROL_TIMEOUT_MS), - resolve, - }; - }); - try { - await writeTurnInput( - params.session, - `${JSON.stringify({ - type: "control_request", - request_id: requestId, - request: { - subtype: "set_model", - model: params.model, - system_prompt: params.systemPrompt, - }, - })}\n`, - ); - } catch { - settleClaudeLivePendingControlRequest(params.session, null); - } - return response; -} - -async function supportsClaudeLiveSystemPromptSwitch(params: { - session: ClaudeLiveSession; - model: string; -}): Promise { - if (params.session.systemPromptSwitchCapability !== "unknown") { - return params.session.systemPromptSwitchCapability === "supported"; - } - // Older CLIs may accept set_model while ignoring unknown fields. The current - // prompt-switch contract rejects an empty field with this exact validation - // error, so only that response is strong enough to weaken process identity. - const response = await requestClaudeLiveModelUpdate({ - session: params.session, - model: params.model, - systemPrompt: "", - }); - const supported = - response?.subtype === "error" && response.error === CLAUDE_LIVE_SYSTEM_PROMPT_PROBE_ERROR; - params.session.systemPromptSwitchCapability = supported ? "supported" : "unsupported"; - return supported; -} - -async function updateClaudeLiveSystemPrompt(params: { - session: ClaudeLiveSession; - model: string; - systemPrompt: string; -}): Promise { - const systemPrompt = stripSystemPromptCacheBoundary(params.systemPrompt); - if ( - !systemPrompt.trim() || - !(await supportsClaudeLiveSystemPromptSwitch({ - session: params.session, - model: params.model, - })) - ) { - return false; - } - const response = await requestClaudeLiveModelUpdate({ - session: params.session, - model: params.model, - systemPrompt, - }); - return response?.subtype === "success"; -} - -async function refreshClaudeLiveSystemPromptForReuse(params: { - session: ClaudeLiveSession; - context: PreparedCliRunContext; - systemPromptHash: string; -}): Promise { - if (params.session.systemPromptHash === params.systemPromptHash) { - return true; - } - const updated = await updateClaudeLiveSystemPrompt({ - session: params.session, - model: params.context.normalizedModel, - systemPrompt: params.context.systemPrompt, - }); - if (updated) { - params.session.systemPromptHash = params.systemPromptHash; - return true; - } - // Older or unhealthy Claude CLIs may reject the control frame. Restart so - // the next process still receives the current prompt through argv. - closeLiveSession(params.session, "restart"); - return false; -} - -function writeClaudeLiveToolControlResponse(params: { - session: ClaudeLiveSession; - requestId: string; - toolUseId?: string; - toolInput: Record; - decision: { behavior: "allow" } | { behavior: "deny"; message: string }; -}): void { - writeClaudeLiveControlResponse(params.session, { - type: "control_response", - response: { - subtype: "success", - request_id: params.requestId, - response: - params.decision.behavior === "allow" - ? { - behavior: "allow", - updatedInput: params.toolInput, - ...(params.toolUseId ? { toolUseID: params.toolUseId } : {}), - } - : { - behavior: "deny", - decisionClassification: "user_reject", - message: params.decision.message, - }, - }, - }); -} - -function markClaudeLiveControlToolDenied(params: { - turn: ClaudeLiveTurn; - toolUseId?: string; - toolName: string; - toolInput: Record; -}): void { - if (!params.toolUseId || !params.toolName) { - return; - } - markClaudeLiveToolDenied(params.turn, { - toolCallId: params.toolUseId, - name: params.toolName, - kind: "tool_use", - args: params.toolInput, - }); -} - -function handleClaudeLiveControlRequest( - session: ClaudeLiveSession, - turn: ClaudeLiveTurn, - parsed: Record, -): void { - if (parsed.type !== "control_request" || !isRecord(parsed.request)) { - return; - } - const request = parsed.request; - if (request.subtype !== "can_use_tool") { - return; - } - const requestId = typeof parsed.request_id === "string" ? parsed.request_id : ""; - if (!requestId) { - return; - } - const toolUseId = typeof request.tool_use_id === "string" ? request.tool_use_id : undefined; - const toolName = typeof request.tool_name === "string" ? request.tool_name.trim() : ""; - const toolInput = isRecord(request.input) ? request.input : {}; - const plan = resolveClaudeNativeToolApprovalPlan(turn.execPermission); - if ( - plan === "allow" || - (plan === "prompt" && - turn.execPermission.ask !== "always" && - session.nativeToolApprovalGrants.has(toolName)) - ) { - writeClaudeLiveToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: { behavior: "allow" }, - }); - return; - } - if (plan === "deny") { - markClaudeLiveControlToolDenied({ turn, toolUseId, toolName, toolInput }); - writeClaudeLiveToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: { - behavior: "deny", - message: `OpenClaw exec policy denied Claude native tool use (security=${turn.execPermission.security}, ask=${turn.execPermission.ask}).`, - }, - }); - return; - } - void (async () => { - const outcome = await requestClaudeNativeToolApproval({ - toolName, - toolInput, - pluginId: session.providerId, - sessionKey: turn.diagnosticRefs.sessionKey, - agentId: turn.diagnosticRefs.agentId, - toolCallId: toolUseId, - abortSignal: turn.abortSignal, - ask: turn.execPermission.ask, - }); - const runAborted = turn.abortSignal?.aborted === true; - const allowed = !runAborted && outcome.kind === "allow"; - if (!runAborted && outcome.kind === "allow" && outcome.grantAlways) { - session.nativeToolApprovalGrants.add(toolName); - } - if (!allowed) { - markClaudeLiveControlToolDenied({ turn, toolUseId, toolName, toolInput }); - } - if (session.closing || !session.managedRun.stdin) { - return; - } - try { - writeClaudeLiveToolControlResponse({ - session, - requestId, - toolUseId, - toolInput, - decision: allowed - ? { behavior: "allow" } - : { - behavior: "deny", - message: - outcome.kind === "deny" && outcome.reason === "policy-oversized" - ? "OpenClaw denied Claude native tool use (Bash): the command is too large to display for out-of-band approval. Split it into smaller commands and retry." - : outcome.kind === "deny" && outcome.reason === "user" && !runAborted - ? `OpenClaw user denied Claude native tool use (${toolName}).` - : `OpenClaw approval was not granted for Claude native tool use (${toolName}).`, - }, - }); - } catch { - // The live process may close while an out-of-band approval is pending. - } - })(); -} - -function pushClaudeLiveTurnLine( - session: ClaudeLiveSession, - turn: ClaudeLiveTurn, - line: string, -): boolean { - turn.streamingParser.push(`${line}\n`); - if (!turn.streamingParser.getErrorText()) { - return true; - } - closeLiveSession( - session, - "abort", - createOutputLimitError(session, "Claude CLI turn output exceeded limit."), - ); - return false; -} - -function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { - const turn = session.currentTurn; - const trimmed = line.trim(); - if (!trimmed) { - if (turn) { - pushClaudeLiveTurnLine(session, turn, line); - } - return; - } - const parsed = parseClaudeLiveJsonLine(trimmed); - if (turn) { - turn.observedStdout = true; - } - if (!parsed) { - if (turn) { - turn.hasReplayUnsafeActivity = true; - } - return; - } - const parsedSessionId = parseSessionId(parsed); - if (parsedSessionId) { - session.sessionId = parsedSessionId; - if (parsed.type === "system" && parsed.subtype === "init") { - turn?.onSessionId?.(parsedSessionId); - } - } - if (handleClaudeLiveControlResponse(session, parsed)) { - return; - } - if (!turn) { - return; - } - applyClaudeLiveInputLifecycle(turn, parsed); - if (!applyClaudeLiveSessionRequirement(session, parsed)) { - return; - } - // command_lifecycle can precede system/init. Retain the matching start, but - // never trust assistant/tool/result records until capability negotiation succeeds. - if (!session.liveSessionCapabilityReady) { - return; - } - if (!turn.inputStarted) { - if (!(parsed.type === "system" && parsed.subtype === "init")) { - turn.hasReplayUnsafeActivity = true; - } - return; - } - if ( - !(parsed.type === "system" && parsed.subtype === "init") && - parsed.type !== "command_lifecycle" - ) { - turn.hasReplayUnsafeActivity = true; - } - const normalizedLine = normalizeClaudeCliStreamJsonRecord(parsed)?.line ?? trimmed; - turn.rawLines.push(normalizedLine); - applyBackgroundTasksChanged(session, parsed); - const toolEventCountBefore = turn.toolEventCount; - if (!pushClaudeLiveTurnLine(session, turn, line)) { - return; - } - turn.sessionId = parsedSessionId ?? turn.sessionId; - noteClaudeLiveProgress(turn, parsed, turn.toolEventCount !== toolEventCountBefore); - handleClaudeLiveControlRequest(session, turn, parsed); - if (parsed.type !== "result") { - return; - } - turn.onPhase?.("resolve"); - const raw = turn.rawLines.join("\n"); - // Reuse the parser that classified pre-tool text as commentary. Reparsing the - // transcript loses that boundary when Claude's terminal result is empty. - const output = - turn.streamingParser.getOutput() ?? - parseCliOutput({ - raw, - backend: turn.backend, - providerId: session.providerId, - parseJsonlEvent: turn.parseJsonlEvent, - outputMode: "jsonl", - fallbackSessionId: turn.sessionId, - }); - const syntheticNoResponsePendingContinuation = - output.terminalFailure?.reason === "synthetic_no_response" && - session.outstandingBackgroundTaskIds.size > 0; - if (output.errorText && !syntheticNoResponsePendingContinuation) { - const error = createCliOutputFailoverError({ - output, - provider: session.providerId, - model: session.modelId, - runId: turn.diagnosticRefs.runId, - sessionId: turn.diagnosticRefs.sessionId, - }); - if (error) { - failTurn(session, error); - } - scheduleIdleClose(session); - return; - } - // Interim success result while background_tasks_changed still reports - // outstanding subagent/workflow tasks: keep the turn open for the final - // post-drain result. Other listed types (e.g. local_bash) do not hold it. - if (session.outstandingBackgroundTaskIds.size > 0) { - // An interim result is not terminal; background work returns the run to send. - turn.onPhase?.("send"); - emitClaudeLiveProgress(turn, "cli_live:result_deferred_background_tasks"); - return; - } - finishTurn(session, output); -} - -function handleClaudeStdout(session: ClaudeLiveSession, chunk: string) { - session.currentTurn?.onCliOutput?.(chunk, "stdout"); - resetNoOutputTimer(session); - const maxPendingLineChars = - session.currentTurn?.outputLimits.maxPendingLineChars ?? - CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS; - try { - if ( - !frameBoundedCliJsonlChunk(session.stdoutBuffer, chunk, maxPendingLineChars, (line) => { - handleClaudeLiveLine(session, line); - return !session.closing; - }) - ) { - closeLiveSession( - session, - "abort", - createOutputLimitError(session, "Claude CLI JSONL line exceeded output limit."), - ); - } - } catch (error) { - closeLiveSession(session, "abort", error); - } -} - -function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null): void { - session.closing = true; - if (session.idleTimer) { - clearTimeout(session.idleTimer); - session.idleTimer = null; - } - if (liveSessions.get(session.key) === session) { - liveSessions.delete(session.key); - } - settleClaudeLivePendingControlRequest(session, null); - void cleanupLiveSession(session); - if (!session.currentTurn) { - return; - } - if (session.stdoutBuffer.pending.trim()) { - const pendingLine = session.stdoutBuffer.pending; - session.stdoutBuffer.pending = ""; - try { - handleClaudeLiveLine(session, pendingLine); - } catch (error) { - failTurn(session, error); - return; - } - } - if (!session.currentTurn) { - return; - } - const stderr = session.stderr.trim(); - const fallbackMessage = - exitCode === 0 ? "Claude CLI exited before completing the turn." : "Claude CLI failed."; - const message = extractCliErrorMessage(stderr) ?? (stderr || fallbackMessage); - if (exitCode === 0 && !stderr) { - const turn = session.currentTurn; - const retryCode = - turn && !turn.observedStdout && turn.rawLines.length === 0 - ? "cli_unknown_empty_failure" - : undefined; - failTurn( - session, - new FailoverError(message, { - reason: "empty_response", - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus("empty_response"), - code: retryCode, - }), - ); - return; - } - const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown"; - const code = reason === "context_overflow" ? "cli_context_overflow" : undefined; - failTurn( - session, - new FailoverError(message, { - reason, - provider: session.providerId, - model: session.modelId, - status: resolveFailoverStatus(reason), - code, - }), - ); -} - -function createClaudeUserInputMessage(content: string, uuid: string): string { - return `${JSON.stringify({ - type: "user", - uuid, - session_id: "", - parent_tool_use_id: null, - message: { - role: "user", - content, - }, - })}\n`; -} - -async function writeTurnInput(session: ClaudeLiveSession, payload: string): Promise { - const stdin = session.managedRun.stdin; - if (!stdin) { - throw new Error("Claude CLI live session stdin is unavailable"); - } - await new Promise((resolve, reject) => { - stdin.write(payload, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -async function createClaudeLiveSession(params: { - context: PreparedCliRunContext; - argv: string[]; - env: Record; - generation: string; - fingerprint: string; - systemPromptHash: string; - key: string; - mcpCaptureKey?: string; - noOutputTimeoutMs: number; - supervisor: ProcessSupervisor; - cleanup: () => Promise; -}): Promise { - let session: ClaudeLiveSession | null = null; - const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({ - mode: params.context.backendResolved.bundleMcpMode, - backend: params.context.preparedBackend.backend, - env: params.env, - captureKey: params.mcpCaptureKey, - }); - let managedRun: ManagedRun; - try { - managedRun = await params.supervisor.spawn({ - sessionId: params.context.params.sessionId, - backendId: params.context.backendResolved.id, - scopeKey: `claude-live:${params.key}`, - replaceExistingScope: true, - mode: "child", - argv: params.argv, - cwd: params.context.cwd ?? params.context.workspaceDir, - env: mcpCaptureAttempt.env ?? params.env, - stdinMode: "pipe-open", - secretInput: params.context.preparedBackend.secretInput, - captureOutput: false, - onStdout: (chunk) => { - if (session) { - handleClaudeStdout(session, chunk); - } - }, - onStderr: (chunk) => { - if (session) { - session.currentTurn?.onCliOutput?.(chunk, "stderr"); - if (session.currentTurn && chunk.trim()) { - session.currentTurn.hasReplayUnsafeActivity = true; - } - session.stderr += chunk; - if (session.stderr.length > LIVE_SESSION_LIMITS.maxStderrChars) { - closeLiveSession( - session, - "abort", - createOutputLimitError(session, "Claude CLI stderr exceeded limit."), - ); - return; - } - resetNoOutputTimer(session); - } - }, - }); - } catch (error) { - await mcpCaptureAttempt.cleanup?.(); - throw error; - } - session = { - key: params.key, - generation: params.generation, - fingerprint: params.fingerprint, - systemPromptHash: params.systemPromptHash, - systemPromptSwitchCapability: "unknown", - liveSessionRequirement: params.context.backendResolved.liveSessionRequirement, - liveSessionCapabilityReady: !params.context.backendResolved.liveSessionRequirement, - managedRun, - providerId: params.context.params.provider, - modelId: params.context.modelId, - noOutputTimeoutMs: params.noOutputTimeoutMs, - stderr: "", - stdoutBuffer: { pending: "" }, - currentTurn: null, - idleTimer: null, - cleanup: async () => { - await mcpCaptureAttempt.cleanup?.(); - await params.cleanup(); - }, - cleanupPromise: null, - closing: false, - pendingControlRequest: null, - mcpCaptureKey: params.mcpCaptureKey, - nativeToolApprovalGrants: new Set(), - outstandingBackgroundTaskIds: new Set(), - }; - void managedRun.wait().then( - (exit) => handleClaudeExit(session, exit.exitCode), - (error: unknown) => { - if (session) { - closeLiveSession(session, "abort", error); - } - }, - ); - liveSessions.set(params.key, session); - cliBackendLog.info( - `claude live session start: provider=${session.providerId} model=${session.modelId} activeSessions=${liveSessions.size}`, - ); - return session; -} - -function createTurn(params: { - context: PreparedCliRunContext; - noOutputTimeoutMs: number; - inputUuid: string; - useResume: boolean; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onToolUseStart?: (delta: CliToolUseStartDelta) => void; - onToolResult?: (delta: CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: CliToolResultDelta, - ) => ClaudeLiveToolTerminalOutcome | undefined; - onCommentaryText?: (text: string) => void; - onSessionId?: (sessionId: string) => void; - onAssistantMessage?: (message: unknown) => void; - onUsage?: (usage: CliUsage, terminal: boolean) => void; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onPhase?: (phase: "send" | "resolve") => void; - session: ClaudeLiveSession; - execPermission: ClaudeLiveExecPermission; - resolve: (output: CliOutput) => void; - reject: (error: unknown) => void; -}): ClaudeLiveTurn { - const turn: ClaudeLiveTurn = { - backend: params.context.preparedBackend.backend, - parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, - diagnosticRefs: { - runId: params.context.params.runId, - sessionId: params.context.params.sessionId, - ...(params.context.params.sessionKey ? { sessionKey: params.context.params.sessionKey } : {}), - ...(params.context.params.agentId ? { agentId: params.context.params.agentId } : {}), - }, - abortSignal: params.context.params.abortSignal, - outputLimits: CLI_STREAM_JSON_OUTPUT_LIMITS, - startedAtMs: Date.now(), - rawLines: [], - noOutputTimer: null, - lastOutputAtMs: null, - timeoutTimer: null, - activeTools: new Map(), - observedStdout: false, - inputUuid: params.inputUuid, - inputStarted: false, - onSessionId: params.onSessionId, - useResume: params.useResume, - hasReplayUnsafeActivity: false, - completedToolCallIds: new Set(), - toolEventCount: 0, - streamingParser: createCliJsonlStreamingParser({ - backend: params.context.preparedBackend.backend, - providerId: params.context.backendResolved.id, - parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, - onAssistantDelta: params.onAssistantDelta, - onThinkingDelta: params.onThinkingDelta, - onThinkingProgress: params.onThinkingProgress, - onToolUseStart: (delta) => { - markClaudeLiveToolStarted(turn, delta); - params.onToolUseStart?.(delta); - }, - onToolResult: (delta) => { - markClaudeLiveToolCompleted(turn, delta, params.resolveToolResultTerminalOutcome?.(delta)); - params.onToolResult?.(delta); - }, - onCommentaryText: params.onCommentaryText, - onSessionId: params.onSessionId, - onAssistantMessage: params.onAssistantMessage, - onUsage: params.onUsage, - }), - onCliOutput: params.onCliOutput, - onPhase: params.onPhase, - execPermission: params.execPermission, - resolve: params.resolve, - reject: params.reject, - }; - armNoOutputTimer(params.session, turn, params.noOutputTimeoutMs); - turn.timeoutTimer = setTimeout(() => { - closeLiveSession( - params.session, - "abort", - createTimeoutError( - params.session, - `CLI exceeded timeout (${Math.round(params.context.params.timeoutMs / 1000)}s) and was terminated.`, - "cli_overall_timeout", - { - mode: "overall", - timeoutSeconds: Math.round(params.context.params.timeoutMs / 1000), - observedActivity: - turn.observedStdout || turn.rawLines.length > 0 || turn.toolEventCount > 0, - activeToolCount: turn.activeTools.size, - backgroundTaskCount: params.session.outstandingBackgroundTaskIds.size, - }, - ), - ); - }, params.context.params.timeoutMs); - return turn; -} - -function closeOldestIdleSession(): boolean { - for (const session of liveSessions.values()) { - if (!session.currentTurn) { - closeLiveSession(session, "idle"); - return true; - } - } - return false; -} - -function ensureLiveSessionCapacity(key: string, context: PreparedCliRunContext): void { - if ( - liveSessions.has(key) || - liveSessionCreates.has(key) || - liveSessions.size + liveSessionCreates.size < LIVE_SESSION_LIMITS.maxSessions - ) { - return; - } - if (closeOldestIdleSession()) { - return; - } - throw new FailoverError("Too many Claude CLI live sessions are active.", { - reason: "rate_limit", - provider: context.params.provider, - model: context.modelId, - status: resolveFailoverStatus("rate_limit"), - }); -} - function createRequiredLiveSessionError(params: { context: PreparedCliRunContext; code: "cli_live_session_changed" | "cli_live_session_missing"; @@ -1758,38 +281,7 @@ function createRequiredLiveSessionError(params: { }); } -type RunClaudeLiveSessionTurnParams = { - context: PreparedCliRunContext; - args: string[]; - executableCommand?: string; - executableLeadingArgv?: readonly string[]; - env: Record; - prompt: string; - useResume: boolean; - forceNewSession?: boolean; - requiredSessionGeneration?: string; - noOutputTimeoutMs: number; - getProcessSupervisor: () => ProcessSupervisor; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onToolUseStart?: (delta: CliToolUseStartDelta) => void; - onToolResult?: (delta: CliToolResultDelta) => void; - resolveToolResultTerminalOutcome?: ( - delta: CliToolResultDelta, - ) => ClaudeLiveToolTerminalOutcome | undefined; - onCommentaryText?: (text: string) => void; - onMcpCaptureReady?: (captureKey: string) => void; - onSessionId?: (sessionId: string) => void; - onAssistantMessage?: (message: unknown) => void; - onUsage?: (usage: CliUsage, terminal: boolean) => void; - onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; - onRequestPayload?: (payload: string) => void; - onPhase?: (phase: "send" | "resolve") => void; - cleanup: () => Promise; -}; - -async function abortClaudeLiveTurnBeforeStart( +async function abortTurnBeforeStart( cleanup: () => Promise, abortError: Error, ): Promise { @@ -1804,23 +296,16 @@ async function abortClaudeLiveTurnBeforeStart( } /** Runs one prompt through a reusable Claude CLI live session. */ -export function runClaudeLiveSessionTurn( - params: RunClaudeLiveSessionTurnParams, -): Promise { +export function runClaudeTurn(params: RunClaudeTurnParams): Promise { const key = buildClaudeLiveKey(params.context); - // Keep prompt refresh, turn assignment, and stdin writes under one owner lock. - // Callers normally arrive through the outer CLI queue, but this owner enforces - // the invariant itself so alternate callers cannot mutate an active process. let cleanupPromise: Promise | undefined; const cleanup = () => (cleanupPromise ??= Promise.resolve().then(params.cleanup)); const abortSignal = params.context.params.abortSignal; if (!abortSignal) { - return liveSessionTurns.enqueue(key, () => - runSerializedClaudeLiveSessionTurn(params, key, cleanup), - ); + return enqueueClaudeTurn(key, () => runSerializedClaudeTurn(params, key, cleanup)); } if (abortSignal.aborted) { - return abortClaudeLiveTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); + return abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); } return new Promise((resolve, reject) => { let started = false; @@ -1845,19 +330,19 @@ export function runClaudeLiveSessionTurn( }; const onAbort = () => { if (!started) { - void abortClaudeLiveTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)).catch( + void abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)).catch( (error: unknown) => settle({ kind: "reject", error }), ); } }; abortSignal.addEventListener("abort", onAbort, { once: true }); - const queued = liveSessionTurns.enqueue(key, async () => { + const queued = enqueueClaudeTurn(key, async () => { started = true; abortSignal.removeEventListener("abort", onAbort); if (abortSignal.aborted) { - return await abortClaudeLiveTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); + return await abortTurnBeforeStart(cleanup, createAbortError(abortSignal.reason)); } - return await runSerializedClaudeLiveSessionTurn(params, key, cleanup); + return await runSerializedClaudeTurn(params, key, cleanup); }); void queued.then( (value) => settle({ kind: "resolve", value }), @@ -1866,8 +351,8 @@ export function runClaudeLiveSessionTurn( }); } -async function runSerializedClaudeLiveSessionTurn( - params: RunClaudeLiveSessionTurnParams, +async function runSerializedClaudeTurn( + params: RunClaudeTurnParams, key: string, cleanup: () => Promise, ): Promise { @@ -1889,8 +374,8 @@ async function runSerializedClaudeLiveSessionTurn( argv, env: params.env, }); - const systemPromptHash = sha256(stripSystemPromptCacheBoundary(params.context.systemPrompt)); - let session = liveSessions.get(key) ?? null; + const systemPromptHash = sha256Hex(stripSystemPromptCacheBoundary(params.context.systemPrompt)); + let session = getClaudeSession(key) as ClaudeLiveProcess | undefined; if ( session && params.requiredSessionGeneration && @@ -1903,14 +388,12 @@ async function runSerializedClaudeLiveSessionTurn( }); } if (session && params.forceNewSession) { - closeLiveSession(session, "restart"); - session = null; + session.close("restart"); + session = undefined; } if (session && resumeCapable && !params.useResume) { - // Non-resume turns must start from a fresh process when the backend supports resume; otherwise - // Claude could inherit conversation state from the previous live turn. - closeLiveSession(session, "restart"); - session = null; + session.close("restart"); + session = undefined; } if (session && session.fingerprint !== fingerprint) { if (params.requiredSessionGeneration) { @@ -1920,16 +403,12 @@ async function runSerializedClaudeLiveSessionTurn( code: "cli_live_session_changed", }); } - closeLiveSession(session, "restart"); - session = null; + session.close("restart"); + session = undefined; } if ( session && - !(await refreshClaudeLiveSystemPromptForReuse({ - session, - context: params.context, - systemPromptHash, - })) + !(await refreshClaudePrompt({ session, context: params.context, systemPromptHash })) ) { if (params.requiredSessionGeneration) { await cleanup(); @@ -1938,19 +417,16 @@ async function runSerializedClaudeLiveSessionTurn( code: "cli_live_session_changed", }); } - session = null; + session = undefined; } if (!session && params.requiredSessionGeneration) { - const pendingGeneration = liveSessionCreates.get(key)?.generation; - if (pendingGeneration !== params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: pendingGeneration ? "cli_live_session_changed" : "cli_live_session_missing", - }); - } + await cleanup(); + throw createRequiredLiveSessionError({ + context: params.context, + code: "cli_live_session_missing", + }); } - let cleanupTurnArtifacts = Boolean(session); + const cleanupTurnArtifacts = Boolean(session); let notifiedMcpCaptureKey: string | undefined; const notifyMcpCaptureReady = (captureKey: string | undefined) => { if (!captureKey || notifiedMcpCaptureKey === captureKey) { @@ -1960,120 +436,55 @@ async function runSerializedClaudeLiveSessionTurn( notifiedMcpCaptureKey = captureKey; }; try { - ensureLiveSessionCapacity(key, params.context); + ensureClaudeSessionCapacity(key, params.context); } catch (error) { await cleanup(); throw error; } if (!session) { - const pendingSession = liveSessionCreates.get(key); - if (pendingSession) { + // The owner queue stays held until creation completes, so a same-key turn + // cannot observe the pending promise. Pending records serve generation queries and capacity. + if (params.requiredSessionGeneration) { + await cleanup(); + throw createRequiredLiveSessionError({ + context: params.context, + code: "cli_live_session_missing", + }); + } + const generation = crypto.randomUUID(); + const mcpCaptureKey = params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined; + if (mcpCaptureKey) { try { - session = await pendingSession.promise; + notifyMcpCaptureReady(mcpCaptureKey); } catch (error) { await cleanup(); - if (params.requiredSessionGeneration) { - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_missing", - cause: error, - }); - } throw error; } - if ( - params.requiredSessionGeneration && - session.generation !== params.requiredSessionGeneration - ) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - if (params.forceNewSession) { - closeLiveSession(session, "restart"); - session = null; - } else if (session.fingerprint !== fingerprint) { - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - closeLiveSession(session, "restart"); - session = null; - } else if (resumeCapable && !params.useResume) { - closeLiveSession(session, "restart"); - session = null; - } else { - if ( - !(await refreshClaudeLiveSystemPromptForReuse({ - session, - context: params.context, - systemPromptHash, - })) - ) { - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_changed", - }); - } - session = null; - } - cleanupTurnArtifacts = true; - } } - if (!session) { - if (params.requiredSessionGeneration) { - await cleanup(); - throw createRequiredLiveSessionError({ - context: params.context, - code: "cli_live_session_missing", - }); - } - const generation = crypto.randomUUID(); - const mcpCaptureKey = params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined; - if (mcpCaptureKey) { - // Fence the Gateway grant before the capture-bearing child can issue - // its first loopback request during process startup. - try { - notifyMcpCaptureReady(mcpCaptureKey); - } catch (error) { - await cleanup(); - throw error; - } - } - const createSession = createClaudeLiveSession({ - context: params.context, - argv, - env: params.env, - generation, - fingerprint, - systemPromptHash, - key, - mcpCaptureKey, - noOutputTimeoutMs: params.noOutputTimeoutMs, - supervisor: params.getProcessSupervisor(), - cleanup, - }).finally(() => { - if (liveSessionCreates.get(key)?.promise === createSession) { - liveSessionCreates.delete(key); - } - }); - liveSessionCreates.set(key, { generation, promise: createSession }); - try { - session = await createSession; - } catch (error) { - await cleanup(); - throw error; - } + const pendingCreate = beginClaudeSessionCreate(key, generation); + const createSession: Promise = spawnClaudeProcess({ + context: params.context, + argv, + env: params.env, + generation, + fingerprint, + systemPromptHash, + key, + mcpCaptureKey, + noOutputTimeoutMs: params.noOutputTimeoutMs, + supervisor: params.getProcessSupervisor(), + cleanup, + onSpawned: (spawned) => registerClaudeSession(spawned, pendingCreate), + onClosed: removeClaudeSession, + }).finally(() => finishClaudeSessionCreate(key, pendingCreate)); + try { + session = await createSession; + } catch (error) { + await cleanup(); + throw error; } } - if (cleanupTurnArtifacts && session) { + if (cleanupTurnArtifacts) { if (session.idleTimer) { clearTimeout(session.idleTimer); session.idleTimer = null; @@ -2083,7 +494,7 @@ async function runSerializedClaudeLiveSessionTurn( `claude live session reuse: provider=${session.providerId} model=${session.modelId}`, ); } - if (session.closing || liveSessions.get(key) !== session) { + if (session.closing || getClaudeSession(key) !== session) { await cleanup(); if (params.requiredSessionGeneration) { throw createRequiredLiveSessionError({ @@ -2096,50 +507,36 @@ async function runSerializedClaudeLiveSessionTurn( if (session.currentTurn) { throw new Error("Claude CLI live session is already handling a turn"); } - const liveSession = session; - if (liveSession.sessionId) { - params.onSessionId?.(liveSession.sessionId); + if (session.sessionId) { + params.onSessionId?.(session.sessionId); } - notifyMcpCaptureReady(liveSession.mcpCaptureKey); - liveSession.noOutputTimeoutMs = params.noOutputTimeoutMs; - liveSession.stderr = ""; - + notifyMcpCaptureReady(session.mcpCaptureKey); + session.noOutputTimeoutMs = params.noOutputTimeoutMs; + session.stderr = ""; const inputUuid = crypto.randomUUID(); - const outputPromise = new Promise((resolve, reject) => { - liveSession.currentTurn = createTurn({ - context: params.context, - noOutputTimeoutMs: params.noOutputTimeoutMs, - inputUuid, - useResume: params.useResume, - onAssistantDelta: params.onAssistantDelta, - onThinkingDelta: params.onThinkingDelta, - onThinkingProgress: params.onThinkingProgress, - onToolUseStart: params.onToolUseStart, - onToolResult: params.onToolResult, - resolveToolResultTerminalOutcome: params.resolveToolResultTerminalOutcome, - onCommentaryText: params.onCommentaryText, - onSessionId: params.onSessionId, - onAssistantMessage: params.onAssistantMessage, - onUsage: params.onUsage, - onCliOutput: params.onCliOutput, - onPhase: params.onPhase, - session: liveSession, - execPermission, - resolve, - reject, - }); + const outputPromise = beginClaudeTurn(session, { + context: params.context, + inputUuid, + useResume: params.useResume, + execPermission, + onAssistantDelta: params.onAssistantDelta, + onThinkingDelta: params.onThinkingDelta, + onThinkingProgress: params.onThinkingProgress, + onToolUseStart: params.onToolUseStart, + onToolResult: params.onToolResult, + resolveToolResultTerminalOutcome: params.resolveToolResultTerminalOutcome, + onCommentaryText: params.onCommentaryText, + onSessionId: params.onSessionId, + onAssistantMessage: params.onAssistantMessage, + onUsage: params.onUsage, + onCliOutput: params.onCliOutput, + onPhase: params.onPhase, }); - // Timeout/abort can reject the turn while stdin is backpressured. Keep the - // rejection handled until the final await below rethrows the canonical result. void outputPromise.catch(() => undefined); const abort = () => - abortTurn(liveSession, createAbortError(params.context.params.abortSignal?.reason)); + abortClaudeTurn(session, createAbortError(params.context.params.abortSignal?.reason)); const replyBackendHandle: ReplyBackendHandle | undefined = params.context.params.replyOperation - ? { - kind: "cli", - runId: params.context.params.runId, - cancel: abort, - } + ? { kind: "cli", runId: params.context.params.runId, cancel: abort } : undefined; params.context.params.abortSignal?.addEventListener("abort", abort, { once: true }); if (replyBackendHandle) { @@ -2152,9 +549,9 @@ async function runSerializedClaudeLiveSessionTurn( try { const requestPayload = createClaudeUserInputMessage(params.prompt, inputUuid); params.onRequestPayload?.(requestPayload); - await Promise.race([writeTurnInput(liveSession, requestPayload), outputPromise]); + await Promise.race([writeClaudeInput(session, requestPayload), outputPromise]); } catch (error) { - closeLiveSession(liveSession, "abort", error); + session.close("abort", error); } } return { output: await outputPromise }; @@ -2165,14 +562,11 @@ async function runSerializedClaudeLiveSessionTurn( params.context.params.replyOperation?.detachBackend(replyBackendHandle); } } finally { - if (liveSession.mcpCaptureKey) { - // The capture key is process environment, so a captured turn must end its - // process before the attempt releases that key to avoid cross-turn sends. - closeLiveSession(liveSession, "restart"); - await waitForManagedRunExit(liveSession.managedRun); - await cleanupLiveSession(liveSession); + if (session.mcpCaptureKey) { + session.close("restart"); + await session.waitForExit(); + await session.cleanupResources(); } } } } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts b/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts new file mode 100644 index 000000000000..fa2279abbd5e --- /dev/null +++ b/src/agents/cli-runner/claude-live-turn-diagnostics.test.ts @@ -0,0 +1,495 @@ +/** Claude live turn progress reporting and diagnostic correlation tests. */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + onInternalDiagnosticEvent, + setDiagnosticsEnabledForProcess, + waitForDiagnosticEventsDrained, +} from "../../infra/diagnostic-events.js"; +import { + getDiagnosticSessionActivitySnapshot, + resetDiagnosticRunActivityForTest, + startDiagnosticRunActivityTracking, +} from "../../logging/diagnostic-run-activity.js"; +import type { getProcessSupervisor } from "../../process/supervisor/index.js"; +import { + buildClaudeLiveRunContext, + createClaudeInputStartedEvent, + expectRejectsWithFields, + mockClaudeLiveRun, + type PreparedCliRunContextOverrides, +} from "../cli-runner.test-helpers.js"; +import { supervisorSpawnMock } from "../cli-runner.test-support.js"; +import { runClaudeTurn } from "./claude-live-session.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; + +type ProcessSupervisor = ReturnType; +type SupervisorSpawnFn = ProcessSupervisor["spawn"]; + +beforeEach(() => { + setDiagnosticsEnabledForProcess(true); + resetDiagnosticRunActivityForTest(); + startDiagnosticRunActivityTracking(); + resetClaudeLiveSessionsForTest(); + supervisorSpawnMock.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + resetDiagnosticRunActivityForTest(); + resetClaudeLiveSessionsForTest(); +}); + +function getProcessSupervisorForTest() { + return { + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }; +} + +function startLiveTurn( + runId: string, + useResume: boolean, + options: { + context?: PreparedCliRunContextOverrides; + abortSignal?: AbortSignal; + noOutputTimeoutMs?: number; + resolveToolResultTerminalOutcome?: ( + delta: import("../cli-output-contracts.js").CliToolResultDelta, + ) => import("./claude-live-turn.js").ClaudeLiveToolTerminalOutcome | undefined; + } = {}, +) { + const context = buildClaudeLiveRunContext({ + ...options.context, + runId, + timeoutMs: options.context?.timeoutMs ?? 60_000, + backend: { resumeArgs: ["-p", "--resume", "{sessionId}"] }, + }); + context.params.abortSignal = options.abortSignal; + return runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "hi", + useResume, + noOutputTimeoutMs: options.noOutputTimeoutMs ?? 5_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + resolveToolResultTerminalOutcome: options.resolveToolResultTerminalOutcome, + cleanup: async () => {}, + }); +} + +function emitClaudeInputStarted(stdout: ((chunk: string) => void) | undefined, data: string): void { + const event = createClaudeInputStartedEvent(data); + if (event) { + stdout?.(`${JSON.stringify(event)}\n`); + } +} + +describe("Claude live turn progress and diagnostic correlation", () => { + it("reports Claude live stream progress without timer heartbeats", async () => { + vi.useFakeTimers({ + toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], + }); + vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); + const diagnosticEvents: string[] = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if (event.type === "run.progress" || event.type.startsWith("tool.execution.")) { + diagnosticEvents.push(event.type); + } + }); + let stdoutListener: ((chunk: string) => void) | undefined; + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, data); + stdoutListener?.( + [ + JSON.stringify({ + type: "system", + subtype: "init", + session_id: "live-diagnostics", + }), + JSON.stringify({ + type: "assistant", + session_id: "live-diagnostics", + message: { + role: "assistant", + content: [ + { + type: "mcp_tool_use", + id: "tool-live-1", + name: "mcp__team__lookup", + input: { query: "status" }, + }, + { + type: "server_tool_use", + id: "tool-live-2", + name: "web_search", + input: { query: "release status" }, + }, + ], + }, + }), + ].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 { + pid: 3060, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(), + }; + }); + + try { + const resultPromise = startLiveTurn("run-live-diagnostics", false, { + context: { + sessionId: "session-live-diagnostics", + sessionKey: "agent:main:diagnostics", + prompt: "hello", + timeoutMs: 120_000, + }, + noOutputTimeoutMs: 120_000, + }); + + await waitForDiagnosticEventsDrained(); + await vi.waitFor(() => + expect( + getDiagnosticSessionActivitySnapshot({ + sessionKey: "agent:main:diagnostics", + }).activeToolName, + ).toBe("mcp__team__lookup"), + ); + expect( + getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) + .lastProgressReason, + ).toBe("cli_live:tool_started"); + + await vi.advanceTimersByTimeAsync(10_000); + await waitForDiagnosticEventsDrained(); + expect( + getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) + .lastProgressReason, + ).toBe("cli_live:tool_started"); + expect( + getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) + .lastProgressAgeMs, + ).toBeGreaterThanOrEqual(10_000); + + stdoutListener?.( + [ + JSON.stringify({ + type: "user", + session_id: "live-diagnostics", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-live-1", + content: "lookup failed", + is_error: true, + }, + { + type: "tool_result", + tool_use_id: "tool-live-2", + content: "done", + }, + ], + }, + }), + JSON.stringify({ + type: "assistant", + session_id: "live-diagnostics", + message: { + role: "assistant", + content: [{ type: "text", text: "ok" }], + }, + }), + JSON.stringify({ + type: "result", + session_id: "live-diagnostics", + result: "ok", + }), + ].join("\n") + "\n", + ); + + await expect(resultPromise).resolves.toMatchObject({ output: { text: "ok" } }); + await waitForDiagnosticEventsDrained(); + expect( + getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) + .activeToolName, + ).toBeUndefined(); + expect( + getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" }) + .lastProgressReason, + ).toBe("cli_live:result"); + expect(diagnosticEvents.filter((event) => event === "tool.execution.started")).toHaveLength( + 2, + ); + expect(diagnosticEvents).toContain("tool.execution.completed"); + expect(diagnosticEvents).toContain("tool.execution.error"); + } finally { + stopDiagnostics(); + } + }); + + it("keeps identical parallel Claude live tool outcomes explicitly unknown", async () => { + const diagnosticEvents: Array> = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if ( + event.type.startsWith("tool.execution.") && + "toolCallId" in event && + typeof event.toolCallId === "string" && + event.toolCallId.startsWith("tool-live-identical-") + ) { + diagnosticEvents.push(event as unknown as Record); + } + }); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { type: "system", subtype: "init", session_id: "live-identical" }, + { + type: "assistant", + session_id: "live-identical", + message: { + role: "assistant", + content: [ + { + type: "mcp_tool_use", + id: "tool-live-identical-a", + name: "mcp__openclaw__message", + input: { action: "react", emoji: "same" }, + }, + { + type: "mcp_tool_use", + id: "tool-live-identical-b", + name: "mcp__openclaw__message", + input: { action: "react", emoji: "same" }, + }, + ], + }, + }, + { + type: "user", + session_id: "live-identical", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tool-live-identical-a", content: "ok" }, + { type: "tool_result", tool_use_id: "tool-live-identical-b", content: "ok" }, + ], + }, + }, + { type: "result", session_id: "live-identical", result: "ok" }, + ], + }); + + try { + await expect( + startLiveTurn("run-live-identical", false, { + context: { + sessionId: "session-live-identical", + sessionKey: "agent:main:live-identical", + prompt: "hello", + }, + resolveToolResultTerminalOutcome: () => ({ outcome: "unknown" }), + }), + ).resolves.toMatchObject({ output: { text: "ok" } }); + await waitForDiagnosticEventsDrained(); + } finally { + stopDiagnostics(); + } + + expect(diagnosticEvents).toMatchObject([ + { type: "tool.execution.started", toolCallId: "tool-live-identical-a" }, + { type: "tool.execution.started", toolCallId: "tool-live-identical-b" }, + { + type: "tool.execution.error", + toolCallId: "tool-live-identical-a", + errorCode: "tool_outcome_unknown", + }, + { + type: "tool.execution.error", + toolCallId: "tool-live-identical-b", + errorCode: "tool_outcome_unknown", + }, + ]); + }); + + it.each([ + [ + "client timeout", + "tool_use", + "Bash", + Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), + "TimeoutError", + { terminalReason: "timed_out" }, + ], + [ + "client cancellation", + "tool_use", + "Bash", + new Error("operator cancelled"), + "AbortError", + { terminalReason: "cancelled" }, + ], + [ + "server-native timeout", + "server_tool_use", + "web_search", + Object.assign(new Error("gateway timeout"), { name: "TimeoutError" }), + "TimeoutError", + { errorCode: "tool_outcome_unknown" }, + ], + [ + "server-native cancellation", + "server_tool_use", + "web_search", + new Error("operator cancelled"), + "AbortError", + { errorCode: "tool_outcome_unknown" }, + ], + ] as const)( + "classifies active Claude live tools on %s", + async (_, toolType, toolName, abortReason, expectedErrorName, expectedOutcome) => { + const abortController = new AbortController(); + const diagnosticEvents: Array> = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if (event.type === "tool.execution.error") { + diagnosticEvents.push(event as unknown as Record); + } + }); + let stdoutListener: ((chunk: string) => void) | undefined; + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + emitClaudeInputStarted(stdoutListener, data); + stdoutListener?.( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "live-timeout" }), + JSON.stringify({ + type: "assistant", + session_id: "live-timeout", + message: { + role: "assistant", + content: [ + { + type: toolType, + id: "tool-live-timeout", + name: toolName, + input: { query: "status" }, + }, + ], + }, + }), + ].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 { + pid: 3061, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn(), + }; + }); + + try { + const resultPromise = startLiveTurn("run-live-timeout", false, { + context: { + sessionId: "session-live-timeout", + sessionKey: "agent:main:timeout", + }, + abortSignal: abortController.signal, + noOutputTimeoutMs: 120_000, + }); + + await vi.waitFor(() => expect(stdoutListener).toBeDefined()); + abortController.abort(abortReason); + await expectRejectsWithFields(resultPromise, { name: expectedErrorName }); + await waitForDiagnosticEventsDrained(); + expect(diagnosticEvents).toContainEqual( + expect.objectContaining({ + toolCallId: "tool-live-timeout", + ...expectedOutcome, + }), + ); + if (toolType === "server_tool_use") { + const terminal = diagnosticEvents.find( + (event) => event.toolCallId === "tool-live-timeout", + ); + expect(terminal).not.toHaveProperty("terminalReason"); + } + } finally { + stopDiagnostics(); + } + }, + ); +}); + +describe("Claude live turn progress timeout cleanup", () => { + it("fails Claude live turns without unhandled rejection when stdin write is stuck", async () => { + vi.useFakeTimers(); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + const cancel = vi.fn(); + let pendingWriteCallback: ((err?: Error | null) => void) | undefined; + const stdin = { + write: vi.fn((_dataValue: string, cb?: (err?: Error | null) => void) => { + pendingWriteCallback = cb; + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementationOnce(async () => ({ + pid: 2345, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel: vi.fn((reason: string) => { + cancel(reason); + pendingWriteCallback?.(new Error("stdin closed")); + }), + })); + + try { + const run = startLiveTurn("run-live-stuck-write", false, { + context: { timeoutMs: 10_000 }, + noOutputTimeoutMs: 1_000, + }); + const runExpectation = expectRejectsWithFields(run, { + name: "FailoverError", + message: "CLI produced no output for 1s and was terminated.", + }); + + await vi.advanceTimersByTimeAsync(1_000); + + await runExpectation; + await Promise.resolve(); + expect(unhandledRejections).toEqual([]); + expect(cancel).toHaveBeenCalledWith("manual-cancel"); + expect(stdin.write).toHaveBeenCalledOnce(); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); +}); diff --git a/src/agents/cli-runner/claude-live-turn-timeouts.ts b/src/agents/cli-runner/claude-live-turn-timeouts.ts new file mode 100644 index 000000000000..9d50013bbb2c --- /dev/null +++ b/src/agents/cli-runner/claude-live-turn-timeouts.ts @@ -0,0 +1,136 @@ +import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js"; +import { type CliTimeoutContext, FailoverError, resolveFailoverStatus } from "../failover-error.js"; + +type ClaudeLiveTimeoutTurn = { + startedAtMs: number; + rawLines: { length: number }; + noOutputTimer: NodeJS.Timeout | null; + lastOutputAtMs: number | null; + timeoutTimer: NodeJS.Timeout | null; + activeTools: { size: number }; + observedStdout: boolean; + useResume: boolean; + hasReplayUnsafeActivity: boolean; + toolEventCount: number; +}; + +type ClaudeLiveTimeoutHost = { + providerId: string; + modelId: string; + noOutputTimeoutMs: number; + stdoutBuffer: { pending: string }; + outstandingBackgroundTaskIds: { size: number }; + close(reason: "idle" | "restart" | "abort" | "mcp-capture-rotation", error?: unknown): void; +}; + +function createClaudeTimeoutError( + host: ClaudeLiveTimeoutHost, + message: string, + code?: string, + cliTimeout?: CliTimeoutContext, +): FailoverError { + return new FailoverError(message, { + reason: "timeout", + provider: host.providerId, + model: host.modelId, + status: resolveFailoverStatus("timeout"), + code, + cliTimeout, + }); +} + +function armNoOutputTimer( + host: ClaudeLiveTimeoutHost, + turn: ClaudeLiveTimeoutTurn, + delayMs: number, +): void { + if (turn.noOutputTimer) { + clearTimeout(turn.noOutputTimer); + } + turn.noOutputTimer = setTimeout(() => { + const quietSinceMs = turn.lastOutputAtMs ?? turn.startedAtMs; + const hasOutstandingWork = + turn.activeTools.size > 0 || host.outstandingBackgroundTaskIds.size > 0; + if (hasOutstandingWork) { + const remainingMs = + quietSinceMs + + Math.max(host.noOutputTimeoutMs, BLOCKED_TOOL_CALL_ABORT_FLOOR_MS) - + Date.now(); + if (remainingMs > 0) { + armNoOutputTimer(host, turn, remainingMs); + return; + } + } + const retryableResumeStall = + turn.useResume && + host.stdoutBuffer.pending.trim().length === 0 && + !turn.hasReplayUnsafeActivity && + turn.toolEventCount === 0 && + turn.activeTools.size === 0 && + host.outstandingBackgroundTaskIds.size === 0; + host.close( + "abort", + createClaudeTimeoutError( + host, + `CLI produced no output for ${Math.round((Date.now() - quietSinceMs) / 1000)}s and was terminated.`, + turn.lastOutputAtMs === null || retryableResumeStall ? "cli_no_output_timeout" : undefined, + { + mode: "no-output", + timeoutSeconds: Math.round((Date.now() - quietSinceMs) / 1000), + observedActivity: + turn.lastOutputAtMs !== null || turn.toolEventCount > 0 || turn.rawLines.length > 0, + activeToolCount: turn.activeTools.size, + backgroundTaskCount: host.outstandingBackgroundTaskIds.size, + }, + ), + ); + }, delayMs); +} + +export function clearClaudeTurnTimers(turn: ClaudeLiveTimeoutTurn): void { + if (turn.noOutputTimer) { + clearTimeout(turn.noOutputTimer); + turn.noOutputTimer = null; + } + if (turn.timeoutTimer) { + clearTimeout(turn.timeoutTimer); + turn.timeoutTimer = null; + } +} + +export function resetClaudeNoOutputTimer( + host: ClaudeLiveTimeoutHost, + turn: ClaudeLiveTimeoutTurn | null, +): void { + if (!turn) { + return; + } + turn.lastOutputAtMs = Date.now(); + armNoOutputTimer(host, turn, host.noOutputTimeoutMs); +} + +export function armClaudeTurnTimers( + host: ClaudeLiveTimeoutHost, + turn: ClaudeLiveTimeoutTurn, + overallTimeoutMs: number, +): void { + armNoOutputTimer(host, turn, host.noOutputTimeoutMs); + turn.timeoutTimer = setTimeout(() => { + host.close( + "abort", + createClaudeTimeoutError( + host, + `CLI exceeded timeout (${Math.round(overallTimeoutMs / 1000)}s) and was terminated.`, + "cli_overall_timeout", + { + mode: "overall", + timeoutSeconds: Math.round(overallTimeoutMs / 1000), + observedActivity: + turn.observedStdout || turn.rawLines.length > 0 || turn.toolEventCount > 0, + activeToolCount: turn.activeTools.size, + backgroundTaskCount: host.outstandingBackgroundTaskIds.size, + }, + ), + ); + }, overallTimeoutMs); +} diff --git a/src/agents/cli-runner/claude-live-turn.test.ts b/src/agents/cli-runner/claude-live-turn.test.ts new file mode 100644 index 000000000000..4c808e98f4a1 --- /dev/null +++ b/src/agents/cli-runner/claude-live-turn.test.ts @@ -0,0 +1,975 @@ +/** Claude live turn parsing, capability negotiation, and input ownership tests. */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CliBackendParseJsonlEvent } from "../../plugins/cli-backend.types.js"; +import type { getProcessSupervisor } from "../../process/supervisor/index.js"; +import { + buildClaudeLiveRunContext, + expectRejectsWithFields, + mockClaudeLiveRun, + type PreparedCliRunContextOverrides, +} from "../cli-runner.test-helpers.js"; +import { supervisorSpawnMock } from "../cli-runner.test-support.js"; +import { createClaudeApiErrorFixture } from "../test-helpers/claude-api-error-fixture.js"; +import { runClaudeTurn } from "./claude-live-session.js"; +import { resetClaudeLiveSessionsForTest } from "./claude-live-session.test-support.js"; + +type ProcessSupervisor = ReturnType; +type SupervisorSpawnFn = ProcessSupervisor["spawn"]; + +const liveSessionRequirement = { + capability: "msg_lifecycle_v1", + minimumVersion: "2.1.206", + versionArgs: ["--version"], + updateCommand: "claude update", +} as const; + +beforeEach(() => { + resetClaudeLiveSessionsForTest(); + supervisorSpawnMock.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + resetClaudeLiveSessionsForTest(); +}); + +function getProcessSupervisorForTest() { + return { + spawn: (params: Parameters[0]) => + supervisorSpawnMock(params) as ReturnType, + cancel: vi.fn(), + cancelScope: vi.fn(), + getRecord: vi.fn(), + }; +} + +function startLiveTurn( + runId: string, + useResume: boolean, + options: { + context?: PreparedCliRunContextOverrides; + abortSignal?: AbortSignal; + noOutputTimeoutMs?: number; + requireCapability?: boolean; + onPhase?: (phase: "send" | "resolve") => void; + parseJsonlEvent?: CliBackendParseJsonlEvent; + onToolResult?: (delta: import("../cli-output-contracts.js").CliToolResultDelta) => void; + resolveToolResultTerminalOutcome?: ( + delta: import("../cli-output-contracts.js").CliToolResultDelta, + ) => import("./claude-live-turn.js").ClaudeLiveToolTerminalOutcome | undefined; + } = {}, +) { + const context = buildClaudeLiveRunContext({ + ...options.context, + runId, + timeoutMs: options.context?.timeoutMs ?? 60_000, + ...(options.requireCapability ? { liveSessionRequirement } : {}), + backend: { resumeArgs: ["-p", "--resume", "{sessionId}"] }, + }); + context.params.abortSignal = options.abortSignal; + context.backendResolved.parseJsonlEvent = options.parseJsonlEvent; + return runClaudeTurn({ + context, + args: context.preparedBackend.backend.args ?? [], + env: {}, + prompt: "hi", + useResume, + noOutputTimeoutMs: options.noOutputTimeoutMs ?? 5_000, + getProcessSupervisor: getProcessSupervisorForTest, + onAssistantDelta: () => {}, + onToolResult: options.onToolResult, + resolveToolResultTerminalOutcome: options.resolveToolResultTerminalOutcome, + onPhase: options.onPhase, + cleanup: async () => {}, + }); +} + +function installLiveStdoutDriver(params: { autoStart?: boolean } = {}) { + let stdoutListener: ((chunk: string) => void) | undefined; + const cancel = vi.fn(); + const userInputUuids: string[] = []; + let markReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + const stdin = { + write: vi.fn((data: string, cb?: (err?: Error | null) => void) => { + const parsed = JSON.parse(data) as { type?: string; uuid?: string }; + if (parsed.type === "user" && typeof parsed.uuid === "string") { + userInputUuids.push(parsed.uuid); + if (params.autoStart !== false) { + stdoutListener?.( + jsonl([{ type: "command_lifecycle", command_uuid: parsed.uuid, state: "started" }]), + ); + } + } + cb?.(); + markReady?.(); + }), + end: vi.fn(), + }; + supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => { + const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void }; + stdoutListener = input.onStdout; + return { + runId: "live-turn-run", + pid: 4242, + startedAtMs: Date.now(), + stdin, + wait: vi.fn(() => new Promise(() => {})), + cancel, + }; + }); + return { + cancel, + stdout: { + emit: (chunk: string) => stdoutListener?.(chunk), + startCurrentInput: () => { + const inputUuid = userInputUuids.at(-1); + if (!inputUuid) { + throw new Error("Claude input UUID was not written"); + } + stdoutListener?.( + jsonl([{ type: "command_lifecycle", command_uuid: inputUuid, state: "started" }]), + ); + }, + waitReady: () => ready, + }, + }; +} + +function jsonl(lines: unknown[]): string { + return lines.map((line) => JSON.stringify(line)).join("\n") + "\n"; +} + +describe("Claude live-session capability negotiation", () => { + it("rejects a malformed terminal result before background-task deferral", async () => { + const parseJsonlEvent = vi.fn((line) => { + const parsed = JSON.parse(line) as { type?: string; result?: string }; + if (parsed.type !== "result" || !parsed.result?.includes('')) { + return null; + } + return { + kind: "result", + errorText: + "Claude CLI returned malformed tool output (invalid request format): raw tool protocol appeared as assistant text.", + }; + }); + const phases: Array<"send" | "resolve"> = []; + const fixture = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { + type: "system", + subtype: "init", + session_id: "live-malformed", + capabilities: ["msg_lifecycle_v1"], + }, + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "task-1", task_type: "local_agent", description: "still running" }], + }, + { + type: "result", + subtype: "success", + session_id: "live-malformed", + result: [ + '', + 'pwd', + "", + ].join("\n"), + }, + ], + }); + + await expect( + startLiveTurn("run-malformed-result", false, { + parseJsonlEvent, + onPhase: (phase) => phases.push(phase), + }), + ).rejects.toMatchObject({ + name: "FailoverError", + reason: "format", + status: 400, + rawError: expect.stringContaining("raw tool protocol appeared as assistant text"), + }); + expect(phases).toEqual(["resolve"]); + expect(fixture.writes.filter((line) => line.includes('"type":"user"'))).toHaveLength(1); + expect( + parseJsonlEvent.mock.calls.filter(([line]) => line.includes('"type":"result"')), + ).toHaveLength(1); + }); + + it.each([ + { label: "fresh", useResume: false }, + { label: "resumed", useResume: true }, + ])( + "retains a matching start before $label init and trusts capability over version", + async (testCase) => { + mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { + type: "system", + subtype: "init", + session_id: "live-capable", + claude_code_version: "2.1.100-custom", + capabilities: ["interrupt_receipt_v1", "msg_lifecycle_v1", "future_v2"], + }, + { + type: "result", + subtype: "success", + session_id: "live-capable", + result: "done", + }, + ], + }); + + await expect( + startLiveTurn(`run-capable-${testCase.label}`, testCase.useResume, { + requireCapability: true, + }), + ).resolves.toMatchObject({ + output: { text: "done" }, + }); + }, + ); + + it.each([ + { label: "fresh", useResume: false }, + { label: "resumed", useResume: true }, + ])( + "fails immediately when $label init omits the required lifecycle capability", + async (testCase) => { + const fixture = mockClaudeLiveRun(supervisorSpawnMock, { + events: [ + { + type: "system", + subtype: "init", + session_id: "live-legacy", + claude_code_version: "2.1.205", + capabilities: ["interrupt_receipt_v1"], + }, + ], + }); + + await expect( + startLiveTurn(`run-legacy-${testCase.label}`, testCase.useResume, { + requireCapability: true, + }), + ).rejects.toMatchObject({ + code: "cli_live_session_unsupported", + message: expect.stringContaining( + "Claude Code build (version 2.1.205) did not advertise the required msg_lifecycle_v1 capability", + ), + }); + expect(fixture.lifecycle.cancel).toHaveBeenCalledOnce(); + }, + ); +}); + +describe("Claude live turn input ownership and replay safety", () => { + it("ignores exact synthetic replay until the matching input starts", async () => { + const driver = installLiveStdoutDriver({ autoStart: false }); + const resultPromise = startLiveTurn("run-synthetic-placeholder", true); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic" }, + { + type: "assistant", + session_id: "live-synthetic", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-synthetic", + result: "", + }, + { + type: "command_lifecycle", + command_uuid: "prior-synthetic-input", + state: "completed", + }, + ]), + ); + + let settled = false; + void resultPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + expect(driver.cancel).not.toHaveBeenCalled(); + + driver.stdout.startCurrentInput(); + driver.stdout.emit( + jsonl([ + { + type: "assistant", + session_id: "live-synthetic", + message: { + model: "claude-fable-5", + role: "assistant", + content: [{ type: "text", text: "The background work is complete." }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-synthetic", + result: "The background work is complete.", + }, + ]), + ); + + const result = await resultPromise; + expect(result.output.text).toBe("The background work is complete."); + expect(driver.cancel).not.toHaveBeenCalled(); + }); + + it("ignores markerless prior results until the matching input starts", async () => { + const driver = installLiveStdoutDriver({ autoStart: false }); + const resultPromise = startLiveTurn("run-markerless-prior-result", true); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { + type: "result", + subtype: "success", + session_id: "live-markerless", + result: "", + origin: { kind: "task-notification" }, + }, + { + type: "result", + subtype: "error_during_execution", + is_error: true, + session_id: "live-markerless", + result: "prior task failed", + }, + { + type: "command_lifecycle", + command_uuid: "prior-markerless-input", + state: "completed", + }, + ]), + ); + let settled = false; + void resultPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await Promise.resolve(); + expect(settled).toBe(false); + + driver.stdout.startCurrentInput(); + driver.stdout.emit( + jsonl([ + { + type: "assistant", + session_id: "live-markerless", + message: { + role: "assistant", + content: [{ type: "text", text: "current answer" }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-markerless", + result: "current answer", + }, + ]), + ); + + await expect(resultPromise).resolves.toMatchObject({ output: { text: "current answer" } }); + expect(driver.cancel).not.toHaveBeenCalled(); + }); + + it("does not defer ordinary or non-empty results that resemble a synthetic placeholder", async () => { + const ordinaryDriver = installLiveStdoutDriver(); + const ordinaryPromise = startLiveTurn("run-ordinary-placeholder", false); + await ordinaryDriver.stdout.waitReady(); + ordinaryDriver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-ordinary-placeholder" }, + { + type: "assistant", + session_id: "live-ordinary-placeholder", + message: { + model: "claude-fable-5", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-ordinary-placeholder", + result: "", + }, + ]), + ); + const ordinary = await ordinaryPromise; + expect(ordinary.output.text).toBe(""); + expect(ordinaryDriver.cancel).not.toHaveBeenCalled(); + + resetClaudeLiveSessionsForTest(); + const nonEmptyDriver = installLiveStdoutDriver(); + const nonEmptyPromise = startLiveTurn("run-synthetic-nonempty", true); + await nonEmptyDriver.stdout.waitReady(); + nonEmptyDriver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-nonempty" }, + { + type: "assistant", + session_id: "live-synthetic-nonempty", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-synthetic-nonempty", + result: "real answer", + }, + ]), + ); + const nonEmpty = await nonEmptyPromise; + expect(nonEmpty.output.text).toBe("real answer"); + expect(nonEmptyDriver.cancel).not.toHaveBeenCalled(); + }); + + it("fails a current-input synthetic placeholder on a fresh live process", async () => { + const driver = installLiveStdoutDriver(); + const resultPromise = startLiveTurn("run-synthetic-fresh", false); + await driver.stdout.waitReady(); + driver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-fresh" }, + { + type: "assistant", + session_id: "live-synthetic-fresh", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "result", + subtype: "success", + session_id: "live-synthetic-fresh", + result: "", + }, + ]), + ); + + await expect(resultPromise).rejects.toMatchObject({ + name: "FailoverError", + reason: "format", + code: "cli_synthetic_no_response", + }); + expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); + }); + + it("times out and cleans up when lifecycle records never start the current input", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const driver = installLiveStdoutDriver({ autoStart: false }); + const resultPromise = startLiveTurn("run-missing-input-lifecycle", true, { + context: { timeoutMs: 60_000 }, + noOutputTimeoutMs: 1_000, + }); + await vi.advanceTimersByTimeAsync(0); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { + type: "command_lifecycle", + command_uuid: "unrelated-input", + state: "started", + }, + { + type: "result", + subtype: "error_during_execution", + is_error: true, + session_id: "live-missing-lifecycle", + result: "unrelated failure", + }, + ]), + ); + + const rejection = expect(resultPromise).rejects.toMatchObject({ + name: "FailoverError", + code: undefined, + cliTimeout: { + mode: "no-output", + timeoutSeconds: 1, + observedActivity: true, + activeToolCount: 0, + backgroundTaskCount: 0, + }, + }); + await vi.advanceTimersByTimeAsync(1_000); + await rejection; + expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); + }); + + it.each([ + { + label: "does not replay after current-turn synthetic output", + useResume: true, + expectedCode: undefined, + chunk: jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-no-result" }, + { + type: "assistant", + session_id: "live-synthetic-no-result", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + ]), + }, + { + label: "marks a resumed init-only stall as safe for recovery", + useResume: true, + expectedCode: "cli_no_output_timeout", + chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-init-no-result" }]), + }, + { + label: "does not mark a fresh init-only stall as safe to replay", + useResume: false, + expectedCode: undefined, + chunk: jsonl([{ type: "system", subtype: "init", session_id: "live-fresh-init-no-result" }]), + }, + { + label: "does not mark a stall as retryable after substantive assistant output", + useResume: true, + expectedCode: undefined, + chunk: jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-substantive" }, + { + type: "assistant", + session_id: "live-synthetic-substantive", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "assistant", + session_id: "live-synthetic-substantive", + message: { + model: "claude-fable-5", + role: "assistant", + content: [{ type: "text", text: "Partial real answer" }], + }, + }, + ]), + }, + { + label: "does not mark an incomplete stdout record as safe to replay", + useResume: true, + expectedCode: undefined, + chunk: '{"type":"assistant","message":{"model":"claude-fable-5"', + }, + ])("$label", async ({ useResume, expectedCode, chunk }) => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const driver = installLiveStdoutDriver(); + const resultPromise = startLiveTurn( + `run-replay-safe-stall-${useResume ? "resume" : "fresh"}`, + useResume, + { + context: { timeoutMs: 60_000 }, + noOutputTimeoutMs: 1_000, + }, + ); + await vi.advanceTimersByTimeAsync(0); + await driver.stdout.waitReady(); + driver.stdout.emit(chunk); + + const errorPromise = resultPromise.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(1_000); + const error = (await errorPromise) as { code?: string; cliTimeout?: unknown }; + expect(error).toMatchObject({ + name: "FailoverError", + cliTimeout: { + mode: "no-output", + timeoutSeconds: 1, + observedActivity: true, + activeToolCount: 0, + backgroundTaskCount: 0, + }, + }); + expect(error.code).toBe(expectedCode); + expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); + }); + + it("still aborts on the turn timeout after input starts but never returns a result", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const driver = installLiveStdoutDriver(); + const resultPromise = startLiveTurn("run-synthetic-timeout", true, { + context: { timeoutMs: 5_000 }, + noOutputTimeoutMs: 60_000, + }); + await vi.advanceTimersByTimeAsync(0); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-timeout" }, + { + type: "assistant", + session_id: "live-synthetic-timeout", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "Continue from where you left off." }], + }, + }, + ]), + ); + + const rejection = expect(resultPromise).rejects.toMatchObject({ + name: "FailoverError", + message: expect.stringMatching(/exceeded timeout/i), + code: "cli_overall_timeout", + cliTimeout: { + mode: "overall", + timeoutSeconds: 5, + observedActivity: true, + activeToolCount: 0, + backgroundTaskCount: 0, + }, + }); + await vi.advanceTimersByTimeAsync(5_000); + await rejection; + expect(driver.cancel).toHaveBeenCalledWith("manual-cancel"); + }); + + it("fails immediately when an error result follows a synthetic placeholder", async () => { + const driver = installLiveStdoutDriver(); + const resultPromise = startLiveTurn("run-synthetic-error", true); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-synthetic-error" }, + { + type: "assistant", + session_id: "live-synthetic-error", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: "No response requested." }], + }, + }, + { + type: "result", + subtype: "error_during_execution", + is_error: true, + session_id: "live-synthetic-error", + result: "provider failed", + }, + ]), + ); + + await expect(resultPromise).rejects.toMatchObject({ + name: "FailoverError", + rawError: expect.stringMatching(/provider failed/i), + }); + }); + + it("fails the turn on an error result even when background tasks are outstanding", async () => { + const driver = installLiveStdoutDriver(); + const phases: Array<"send" | "resolve"> = []; + const resultPromise = startLiveTurn("run-bg-error", false, { + onPhase: (phase) => phases.push(phase), + }); + await driver.stdout.waitReady(); + + driver.stdout.emit( + jsonl([ + { type: "system", subtype: "init", session_id: "live-bg-err" }, + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "task-err", task_type: "local_agent", description: "stuck" }], + }, + { + type: "result", + subtype: "error_during_execution", + is_error: true, + session_id: "live-bg-err", + result: "agent crashed", + }, + ]), + ); + + await expect(resultPromise).rejects.toMatchObject({ + name: "FailoverError", + rawError: expect.stringMatching(/agent crashed/i), + }); + expect(phases).toEqual(["resolve"]); + }); +}); + +describe("Claude live turn output bounds and result projection", () => { + it("accepts Claude live stream-json lines larger than 256 KiB", async () => { + const largeText = "x".repeat(270 * 1024); + mockClaudeLiveRun(supervisorSpawnMock, { + events: [{ type: "result", session_id: "live-session-large", result: largeText }], + }); + + const result = await startLiveTurn("run-live-large", false); + + expect(result.output.text).toHaveLength(largeText.length); + expect(result.output.text).toBe(largeText); + }); + + it("frames coalesced Claude live image and PDF records before omitting retained bytes", async () => { + const toolResults: unknown[] = []; + const base64 = "a".repeat(4_300_000); + mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: ({ emit }) => { + const events: Record[] = [ + { type: "system", subtype: "init", session_id: "live-binary-results" }, + ]; + for (const [type, mediaType] of [ + ["image", "image/png"], + ["document", "application/pdf"], + ] as const) { + events.push( + { + type: "assistant", + session_id: "live-binary-results", + message: { + role: "assistant", + content: [{ type: "tool_use", id: `read-${type}`, name: "Read", input: {} }], + }, + }, + { + type: "user", + session_id: "live-binary-results", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: `read-${type}`, + content: [ + { type: "text", text: `Read ${type}` }, + { type, source: { type: "base64", media_type: mediaType, data: base64 } }, + ], + }, + ], + }, + }, + ); + } + events.push({ + type: "result", + session_id: "live-binary-results", + result: "both files read", + }); + emit(events); + }, + }); + + const result = await startLiveTurn("run-live-binary-results", false, { + onToolResult: (delta) => toolResults.push(delta.result), + }); + + expect(result.output.text).toBe("both files read"); + expect(toolResults).toEqual([ + [ + { type: "text", text: "Read image" }, + { + type: "image", + source: { type: "base64", media_type: "image/png" }, + omitted: true, + bytes: 3_225_000, + }, + ], + [ + { type: "text", text: "Read document" }, + { + type: "document", + source: { type: "base64", media_type: "application/pdf" }, + omitted: true, + bytes: 3_225_000, + }, + ], + ]); + }); + + it.each([ + { + name: "an oversized complete line", + chunks: () => [`${"a".repeat(8 * 1024 * 1024 + 1)}\n`], + }, + { + name: "an oversized growing unterminated line", + chunks: () => ["a".repeat(4_300_000), "a".repeat(4_300_000)], + }, + ])("rejects $name from Claude live stdout", async ({ chunks }) => { + const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: () => { + for (const chunk of chunks()) { + live.spawnInput.onStdout?.(chunk); + } + }, + }); + + await expect(startLiveTurn("run-live-oversized-line", false)).rejects.toThrow( + "Claude CLI JSONL line exceeded output limit.", + ); + }); + + it.each([ + { + name: "a coalesced blank-frame flood", + createChunk: () => "\n".repeat(20_001), + }, + { + name: "whitespace-only records exceeding the raw budget", + createChunk: () => `${" ".repeat(4_300_000)}\n${" ".repeat(4_300_000)}\n`, + }, + { + name: "valid JSON padded beyond the raw budget", + createChunk: () => `${" ".repeat(4_300_000)}{}\n${" ".repeat(4_300_000)}{}\n`, + }, + { + name: "internal formatting around compacted Claude media", + createChunk: () => { + const line = JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "padded-live-image", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "YQ==" }, + }, + ], + }, + ], + }, + }).replace('"message":', `"message":${" ".repeat(4_300_000)}`); + return `${line}\n${line}\n`; + }, + }, + ])("rejects $name from the managed Claude live session", async ({ createChunk }) => { + const live: ReturnType = mockClaudeLiveRun(supervisorSpawnMock, { + onWrite: () => live.spawnInput.onStdout?.(createChunk()), + }); + + await expect(startLiveTurn("run-live-output-budget", false)).rejects.toThrow( + "Claude CLI turn output exceeded limit.", + ); + }); + + it("ignores non-JSON stdout lines from Claude live sessions", async () => { + 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 startLiveTurn("run-live-mixed", false); + expect(result.output.text).toBe("mixed-ok"); + }); + + it("fails Claude live turns on is_error results", async () => { + 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(startLiveTurn("run-live-error", false), { + name: "FailoverError", + message: "Credit balance is too low", + }); + }); + + it("surfaces Claude live max-turn results with run and session recovery context", async () => { + 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(startLiveTurn("run-live-max-turns", false), { + name: "FailoverError", + message: + "Claude CLI stopped after reaching the maximum number of turns (limit: 1). " + + "OpenClaw run: run-live-max-turns. OpenClaw session: s1. " + + "Claude session: live-max-turns. Tool actions may already have run; verify their effects before retrying. " + + "Retry with a higher --max-turns value or a narrower task.", + sessionId: "s1", + reason: "unknown", + code: "cli_max_turns", + rawError: "Reached maximum number of turns (1)", + }); + }); + + it("surfaces nested Claude stream-json API errors instead of raw event output", async () => { + const { message, jsonl: apiErrorJsonl } = createClaudeApiErrorFixture(); + mockClaudeLiveRun(supervisorSpawnMock, { + events: apiErrorJsonl.split("\n"), + }); + + await expectRejectsWithFields(startLiveTurn("run-live-api-error", false), { + name: "FailoverError", + message, + reason: "billing", + status: 402, + }); + }); +}); diff --git a/src/agents/cli-runner/claude-live-turn.ts b/src/agents/cli-runner/claude-live-turn.ts new file mode 100644 index 000000000000..4e1a2bbb7d60 --- /dev/null +++ b/src/agents/cli-runner/claude-live-turn.ts @@ -0,0 +1,635 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { + emitTrustedDiagnosticEvent, + type DiagnosticToolExecutionErrorEvent, + type DiagnosticToolParamsSummary, + type DiagnosticToolSource, +} from "../../infra/diagnostic-events.js"; +import type { + CliBackendConfig, + CliBackendParseJsonlEvent, +} from "../../plugins/cli-backend.types.js"; +import type { + CliOutput, + CliStreamingDelta, + CliStreamJsonOutputLimits, + CliThinkingDelta, + CliThinkingProgress, + CliToolResultDelta, + CliToolUseStartDelta, + CliUsage, +} from "../cli-output-contracts.js"; +import { pickCliSessionId } from "../cli-output-records.js"; +import { + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, + CLI_STREAM_JSON_OUTPUT_LIMITS, + createCliJsonlStreamingParser, + frameBoundedCliJsonlChunk, + normalizeClaudeCliStreamJsonRecord, +} from "../cli-output-stream.js"; +import { extractCliErrorMessage, parseCliOutput } from "../cli-output.js"; +import { classifyFailoverReason } from "../embedded-agent-helpers.js"; +import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; +import { resolveCliToolTerminalReason } from "../run-termination.js"; +import { + armClaudeTurnTimers, + clearClaudeTurnTimers, + resetClaudeNoOutputTimer, +} from "./claude-live-turn-timeouts.js"; +import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js"; +import { createCliOutputFailoverError } from "./output-error.js"; +import type { PreparedCliRunContext } from "./types.js"; + +export type ClaudeLiveExecPermission = { + security: import("../../infra/exec-approvals.js").ExecSecurity; + ask: import("../../infra/exec-approvals.js").ExecAsk; + permissionMode: "bypassPermissions" | "default"; +}; + +export type ClaudeLiveToolTerminalOutcome = + | { outcome: "blocked"; deniedReason: string; reason?: string } + | { outcome: "cancelled" | "failed" | "timed_out" | "unknown" }; + +type ClaudeLiveActiveTool = { + toolName: string; + toolCallId: string; + kind: CliToolUseStartDelta["kind"]; + startedAt: number; +}; + +export type ClaudeLiveTurn = { + backend: CliBackendConfig; + parseJsonlEvent?: CliBackendParseJsonlEvent; + diagnosticRefs: { + runId: string; + sessionId: string; + sessionKey?: string; + agentId?: string; + }; + abortSignal?: AbortSignal; + outputLimits: CliStreamJsonOutputLimits; + startedAtMs: number; + rawLines: string[]; + sessionId?: string; + noOutputTimer: NodeJS.Timeout | null; + lastOutputAtMs: number | null; + timeoutTimer: NodeJS.Timeout | null; + activeTools: Map; + observedStdout: boolean; + inputUuid: string; + inputStarted: boolean; + onSessionId?: (sessionId: string) => void; + useResume: boolean; + hasReplayUnsafeActivity: boolean; + completedToolCallIds: Set; + toolEventCount: number; + streamingParser: ReturnType; + onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; + onPhase?: (phase: "send" | "resolve") => void; + execPermission: ClaudeLiveExecPermission; + resolve: (output: CliOutput) => void; + reject: (error: unknown) => void; +}; + +export type ClaudeLiveTurnHost = { + backend: CliBackendConfig; + providerId: string; + modelId: string; + noOutputTimeoutMs: number; + stderr: string; + stdoutBuffer: { pending: string }; + currentTurn: ClaudeLiveTurn | null; + outstandingBackgroundTaskIds: Set; + liveSessionCapabilityReady: boolean; + closing: boolean; + close(reason: "idle" | "restart" | "abort" | "mcp-capture-rotation", error?: unknown): void; + scheduleIdleClose(): void; + acceptControlResponse(parsed: Record): boolean; + acceptControlRequest(turn: ClaudeLiveTurn, parsed: Record): void; + acceptSessionRequirement(parsed: Record): boolean; + acceptSessionId(sessionId: string): void; + settleControlRequest(): void; + cleanupAfterExit(): void; +}; + +function finishClaudeTurn(host: ClaudeLiveTurnHost, output: CliOutput): void { + const turn = host.currentTurn; + if (!turn) { + return; + } + cliBackendLog.info( + `claude live session turn: provider=${host.providerId} model=${host.modelId} durationMs=${Date.now() - turn.startedAtMs} rawLines=${turn.rawLines.length} ${formatCliBackendOutputDigest(output.text)}`, + ); + turn.streamingParser.finish(); + failActiveClaudeLiveTools(turn, new Error("Tool result missing before turn completed")); + clearClaudeTurnTimers(turn); + host.outstandingBackgroundTaskIds.clear(); + host.currentTurn = null; + turn.resolve(output); + host.scheduleIdleClose(); +} + +export function failClaudeTurn(host: ClaudeLiveTurnHost, error: unknown): void { + const turn = host.currentTurn; + if (!turn) { + return; + } + const errorKind = error instanceof Error ? error.name : typeof error; + cliBackendLog.warn( + `claude live session turn failed: provider=${host.providerId} model=${host.modelId} durationMs=${Date.now() - turn.startedAtMs} error=${errorKind}`, + ); + turn.streamingParser.finish(); + failActiveClaudeLiveTools(turn, error); + clearClaudeTurnTimers(turn); + host.outstandingBackgroundTaskIds.clear(); + host.currentTurn = null; + turn.reject(error); +} + +export function createClaudeOutputLimitError( + host: ClaudeLiveTurnHost, + message: string, +): FailoverError { + return new FailoverError(message, { + reason: "format", + provider: host.providerId, + model: host.modelId, + status: resolveFailoverStatus("format"), + }); +} + +function diagnosticBase(turn: ClaudeLiveTurn) { + return { + runId: turn.diagnosticRefs.runId, + sessionId: turn.diagnosticRefs.sessionId, + ...(turn.diagnosticRefs.sessionKey ? { sessionKey: turn.diagnosticRefs.sessionKey } : {}), + ...(turn.diagnosticRefs.agentId ? { agentId: turn.diagnosticRefs.agentId } : {}), + }; +} + +function emitProgress(turn: ClaudeLiveTurn, reason: string): void { + emitTrustedDiagnosticEvent({ type: "run.progress", ...diagnosticBase(turn), reason }); +} + +function toolSource(toolName: string): DiagnosticToolSource { + return toolName.startsWith("mcp__") ? "mcp" : "core"; +} + +function summarizeToolInput(input: unknown): DiagnosticToolParamsSummary | undefined { + if (input === undefined) { + return undefined; + } + if (input === null) { + return { kind: "null" }; + } + if (Array.isArray(input)) { + return { kind: "array", length: input.length }; + } + switch (typeof input) { + case "object": + return { kind: "object" }; + case "string": + return { kind: "string", length: input.length }; + case "number": + return { kind: "number" }; + case "boolean": + return { kind: "boolean" }; + case "undefined": + return { kind: "undefined" }; + default: + return { kind: "other" }; + } +} + +function markClaudeLiveToolStarted(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { + if (turn.completedToolCallIds.has(tool.toolCallId) || turn.activeTools.has(tool.toolCallId)) { + return; + } + const now = Date.now(); + turn.activeTools.set(tool.toolCallId, { + toolName: tool.name, + toolCallId: tool.toolCallId, + kind: tool.kind, + startedAt: now, + }); + turn.toolEventCount += 1; + emitTrustedDiagnosticEvent({ + type: "tool.execution.started", + ...diagnosticBase(turn), + toolName: tool.name, + toolSource: toolSource(tool.name), + toolOwner: "claude-cli", + toolCallId: tool.toolCallId, + paramsSummary: summarizeToolInput(tool.args), + }); + emitProgress(turn, "cli_live:tool_started"); +} + +function markClaudeLiveToolCompleted( + turn: ClaudeLiveTurn, + result: CliToolResultDelta, + terminalOutcome?: ClaudeLiveToolTerminalOutcome, +): void { + if (turn.completedToolCallIds.has(result.toolCallId)) { + return; + } + turn.toolEventCount += 1; + const activeTool = turn.activeTools.get(result.toolCallId); + if (!activeTool) { + emitProgress(turn, "cli_live:tool_result"); + return; + } + turn.activeTools.delete(result.toolCallId); + turn.completedToolCallIds.add(result.toolCallId); + const event = { + ...diagnosticBase(turn), + toolName: activeTool.toolName, + toolSource: toolSource(activeTool.toolName), + toolOwner: "claude-cli" as const, + toolCallId: activeTool.toolCallId, + durationMs: Math.max(0, Date.now() - activeTool.startedAt), + }; + if (terminalOutcome?.outcome === "blocked") { + emitTrustedDiagnosticEvent({ + type: "tool.execution.blocked", + ...event, + deniedReason: terminalOutcome.deniedReason, + reason: terminalOutcome.reason ?? "blocked by before-tool policy", + }); + } else if (terminalOutcome?.outcome === "unknown") { + emitTrustedDiagnosticEvent({ + type: "tool.execution.error", + ...event, + errorCategory: "cli_tool_ambiguous", + errorCode: "tool_outcome_unknown", + }); + } else if (terminalOutcome || result.isError) { + const terminalReason = terminalOutcome?.outcome ?? "failed"; + emitTrustedDiagnosticEvent({ + type: "tool.execution.error", + ...event, + errorCategory: terminalReason === "cancelled" ? "aborted" : "tool_failed", + terminalReason, + }); + } else { + emitTrustedDiagnosticEvent({ type: "tool.execution.completed", ...event }); + } + emitProgress(turn, "cli_live:tool_result"); +} + +export function markClaudeLiveToolDenied(turn: ClaudeLiveTurn, tool: CliToolUseStartDelta): void { + markClaudeLiveToolStarted(turn, tool); + markClaudeLiveToolCompleted( + turn, + { toolCallId: tool.toolCallId, name: tool.name, isError: true }, + { + outcome: "blocked", + deniedReason: "cli_live_exec_policy", + reason: "blocked by CLI live execution policy", + }, + ); +} + +function failActiveClaudeLiveTools(turn: ClaudeLiveTurn, error: unknown): void { + const terminalReason = resolveCliToolTerminalReason({ error, abortSignal: turn.abortSignal }); + const errorCategory = + terminalReason === "timed_out" + ? "timeout" + : terminalReason === "cancelled" + ? "aborted" + : "error"; + for (const activeTool of turn.activeTools.values()) { + const event: Omit = + { + ...diagnosticBase(turn), + toolName: activeTool.toolName, + toolSource: toolSource(activeTool.toolName), + toolOwner: "claude-cli", + toolCallId: activeTool.toolCallId, + durationMs: Math.max(0, Date.now() - activeTool.startedAt), + }; + emitTrustedDiagnosticEvent( + activeTool.kind === "server_tool_use" + ? { + type: "tool.execution.error", + ...event, + errorCategory: "cli_tool_ambiguous", + errorCode: "tool_outcome_unknown", + } + : { type: "tool.execution.error", ...event, errorCategory, terminalReason }, + ); + } + turn.activeTools.clear(); +} + +function noteClaudeLiveProgress( + turn: ClaudeLiveTurn, + parsed: Record, + sawToolEvent: boolean, +): void { + if (parsed.type === "result") { + emitProgress(turn, "cli_live:result"); + return; + } + if (sawToolEvent) { + return; + } + emitProgress(turn, "cli_live:stream_progress"); +} + +const RESULT_HOLDING_BACKGROUND_TASK_TYPES = new Set(["local_agent", "local_workflow"]); + +function applyBackgroundTasksChanged( + host: ClaudeLiveTurnHost, + parsed: Record, +): void { + if (parsed.type !== "system" || parsed.subtype !== "background_tasks_changed") { + return; + } + host.outstandingBackgroundTaskIds.clear(); + for (const task of Array.isArray(parsed.tasks) ? parsed.tasks : []) { + if (!isRecord(task)) { + continue; + } + const taskType = typeof task.task_type === "string" ? task.task_type.trim() : ""; + const taskId = typeof task.task_id === "string" ? task.task_id.trim() : ""; + if (RESULT_HOLDING_BACKGROUND_TASK_TYPES.has(taskType) && taskId) { + host.outstandingBackgroundTaskIds.add(taskId); + } + } +} + +function pushTurnLine(host: ClaudeLiveTurnHost, turn: ClaudeLiveTurn, line: string): boolean { + turn.streamingParser.push(`${line}\n`); + if (!turn.streamingParser.getErrorText()) { + return true; + } + host.close("abort", createClaudeOutputLimitError(host, "Claude CLI turn output exceeded limit.")); + return false; +} + +function acceptClaudeLine(host: ClaudeLiveTurnHost, line: string): void { + const turn = host.currentTurn; + const trimmed = line.trim(); + if (!trimmed) { + if (turn) { + pushTurnLine(host, turn, line); + } + return; + } + let parsed: Record | null = null; + try { + const candidate: unknown = JSON.parse(trimmed); + parsed = isRecord(candidate) ? candidate : null; + } catch {} + if (turn) { + turn.observedStdout = true; + } + if (!parsed) { + if (turn) { + turn.hasReplayUnsafeActivity = true; + } + return; + } + const parsedSessionId = pickCliSessionId(parsed, host.backend); + if (parsedSessionId) { + host.acceptSessionId(parsedSessionId); + if (parsed.type === "system" && parsed.subtype === "init") { + turn?.onSessionId?.(parsedSessionId); + } + } + if (host.acceptControlResponse(parsed) || !turn) { + return; + } + if ( + parsed.type === "command_lifecycle" && + parsed.command_uuid === turn.inputUuid && + parsed.state === "started" && + !turn.inputStarted + ) { + turn.inputStarted = true; + emitProgress(turn, "cli_live:input_started"); + } + if (!host.acceptSessionRequirement(parsed)) { + return; + } + if (!host.liveSessionCapabilityReady) { + return; + } + if (!turn.inputStarted) { + if (!(parsed.type === "system" && parsed.subtype === "init")) { + turn.hasReplayUnsafeActivity = true; + } + return; + } + if ( + !(parsed.type === "system" && parsed.subtype === "init") && + parsed.type !== "command_lifecycle" + ) { + turn.hasReplayUnsafeActivity = true; + } + const normalizedLine = normalizeClaudeCliStreamJsonRecord(parsed)?.line ?? trimmed; + turn.rawLines.push(normalizedLine); + applyBackgroundTasksChanged(host, parsed); + const toolEventCountBefore = turn.toolEventCount; + if (!pushTurnLine(host, turn, line)) { + return; + } + turn.sessionId = parsedSessionId ?? turn.sessionId; + noteClaudeLiveProgress(turn, parsed, turn.toolEventCount !== toolEventCountBefore); + host.acceptControlRequest(turn, parsed); + if (parsed.type !== "result") { + return; + } + turn.onPhase?.("resolve"); + const raw = turn.rawLines.join("\n"); + const output = + turn.streamingParser.getOutput() ?? + parseCliOutput({ + raw, + backend: turn.backend, + providerId: host.providerId, + parseJsonlEvent: turn.parseJsonlEvent, + outputMode: "jsonl", + fallbackSessionId: turn.sessionId, + }); + const syntheticNoResponsePendingContinuation = + output.terminalFailure?.reason === "synthetic_no_response" && + host.outstandingBackgroundTaskIds.size > 0; + if (output.errorText && !syntheticNoResponsePendingContinuation) { + const error = createCliOutputFailoverError({ + output, + provider: host.providerId, + model: host.modelId, + runId: turn.diagnosticRefs.runId, + sessionId: turn.diagnosticRefs.sessionId, + }); + if (error) { + failClaudeTurn(host, error); + } + host.scheduleIdleClose(); + return; + } + if (host.outstandingBackgroundTaskIds.size > 0) { + turn.onPhase?.("send"); + emitProgress(turn, "cli_live:result_deferred_background_tasks"); + return; + } + finishClaudeTurn(host, output); +} + +export function acceptClaudeStdout(host: ClaudeLiveTurnHost, chunk: string): void { + host.currentTurn?.onCliOutput?.(chunk, "stdout"); + resetClaudeNoOutputTimer(host, host.currentTurn); + const maxPendingLineChars = + host.currentTurn?.outputLimits.maxPendingLineChars ?? + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS; + try { + if ( + !frameBoundedCliJsonlChunk(host.stdoutBuffer, chunk, maxPendingLineChars, (line) => { + acceptClaudeLine(host, line); + return !host.closing; + }) + ) { + host.close( + "abort", + createClaudeOutputLimitError(host, "Claude CLI JSONL line exceeded output limit."), + ); + } + } catch (error) { + host.close("abort", error); + } +} + +export function acceptClaudeExit(host: ClaudeLiveTurnHost, exitCode: number | null): void { + host.closing = true; + host.settleControlRequest(); + host.cleanupAfterExit(); + if (!host.currentTurn) { + return; + } + if (host.stdoutBuffer.pending.trim()) { + const pendingLine = host.stdoutBuffer.pending; + host.stdoutBuffer.pending = ""; + try { + acceptClaudeLine(host, pendingLine); + } catch (error) { + failClaudeTurn(host, error); + return; + } + } + if (!host.currentTurn) { + return; + } + const stderr = host.stderr.trim(); + const message = + extractCliErrorMessage(stderr) ?? + (stderr || + (exitCode === 0 ? "Claude CLI exited before completing the turn." : "Claude CLI failed.")); + if (exitCode === 0 && !stderr) { + const turn = host.currentTurn; + failClaudeTurn( + host, + new FailoverError(message, { + reason: "empty_response", + provider: host.providerId, + model: host.modelId, + status: resolveFailoverStatus("empty_response"), + code: + turn && !turn.observedStdout && turn.rawLines.length === 0 + ? "cli_unknown_empty_failure" + : undefined, + }), + ); + return; + } + const reason = classifyFailoverReason(message, { provider: host.providerId }) ?? "unknown"; + failClaudeTurn( + host, + new FailoverError(message, { + reason, + provider: host.providerId, + model: host.modelId, + status: resolveFailoverStatus(reason), + code: reason === "context_overflow" ? "cli_context_overflow" : undefined, + }), + ); +} + +export function createClaudeTurn(params: { + context: PreparedCliRunContext; + inputUuid: string; + useResume: boolean; + host: ClaudeLiveTurnHost; + execPermission: ClaudeLiveExecPermission; + onAssistantDelta: (delta: CliStreamingDelta) => void; + onThinkingDelta?: (delta: CliThinkingDelta) => void; + onThinkingProgress?: (progress: CliThinkingProgress) => void; + onToolUseStart?: (delta: CliToolUseStartDelta) => void; + onToolResult?: (delta: CliToolResultDelta) => void; + resolveToolResultTerminalOutcome?: ( + delta: CliToolResultDelta, + ) => ClaudeLiveToolTerminalOutcome | undefined; + onCommentaryText?: (text: string) => void; + onSessionId?: (sessionId: string) => void; + onAssistantMessage?: (message: unknown) => void; + onUsage?: (usage: CliUsage, terminal: boolean) => void; + onCliOutput?: (chunk: string, stream: "stderr" | "stdout") => void; + onPhase?: (phase: "send" | "resolve") => void; + resolve: (output: CliOutput) => void; + reject: (error: unknown) => void; +}): ClaudeLiveTurn { + const turn: ClaudeLiveTurn = { + backend: params.context.preparedBackend.backend, + parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, + diagnosticRefs: { + runId: params.context.params.runId, + sessionId: params.context.params.sessionId, + ...(params.context.params.sessionKey ? { sessionKey: params.context.params.sessionKey } : {}), + ...(params.context.params.agentId ? { agentId: params.context.params.agentId } : {}), + }, + abortSignal: params.context.params.abortSignal, + outputLimits: CLI_STREAM_JSON_OUTPUT_LIMITS, + startedAtMs: Date.now(), + rawLines: [], + noOutputTimer: null, + lastOutputAtMs: null, + timeoutTimer: null, + activeTools: new Map(), + observedStdout: false, + inputUuid: params.inputUuid, + inputStarted: false, + onSessionId: params.onSessionId, + useResume: params.useResume, + hasReplayUnsafeActivity: false, + completedToolCallIds: new Set(), + toolEventCount: 0, + streamingParser: createCliJsonlStreamingParser({ + backend: params.context.preparedBackend.backend, + providerId: params.context.backendResolved.id, + parseJsonlEvent: params.context.backendResolved.parseJsonlEvent, + onAssistantDelta: params.onAssistantDelta, + onThinkingDelta: params.onThinkingDelta, + onThinkingProgress: params.onThinkingProgress, + onToolUseStart: (delta) => { + markClaudeLiveToolStarted(turn, delta); + params.onToolUseStart?.(delta); + }, + onToolResult: (delta) => { + markClaudeLiveToolCompleted(turn, delta, params.resolveToolResultTerminalOutcome?.(delta)); + params.onToolResult?.(delta); + }, + onCommentaryText: params.onCommentaryText, + onSessionId: params.onSessionId, + onAssistantMessage: params.onAssistantMessage, + onUsage: params.onUsage, + }), + onCliOutput: params.onCliOutput, + onPhase: params.onPhase, + execPermission: params.execPermission, + resolve: params.resolve, + reject: params.reject, + }; + armClaudeTurnTimers(params.host, turn, params.context.params.timeoutMs); + return turn; +} diff --git a/src/agents/cli-runner/execute-process.ts b/src/agents/cli-runner/execute-process.ts index 65af7575a6cd..e24b25a4b18c 100644 --- a/src/agents/cli-runner/execute-process.ts +++ b/src/agents/cli-runner/execute-process.ts @@ -13,7 +13,7 @@ import { extractCliErrorMessage, parseCliOutput } from "../cli-output.js"; import { classifyFailoverReason } from "../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; -import { runClaudeLiveSessionTurn } from "./claude-live-session.js"; +import { runClaudeTurn } from "./claude-live-session.js"; import type { CliExecuteDeps } from "./execute-deps.js"; import type { CliEventHandlers } from "./execute-events.js"; import { @@ -111,7 +111,7 @@ export async function executeCliProcess(params: { backend: context.backendResolved.id, }); params.claimFallbackCleanup(); - const liveResult = await runClaudeLiveSessionTurn({ + const liveResult = await runClaudeTurn({ context, args: params.executionArgs, executableCommand: params.executionCommand, diff --git a/src/agents/cli-runner/execute-tool-tracking.ts b/src/agents/cli-runner/execute-tool-tracking.ts index 5b5b4e400b4a..eea2313d7ab5 100644 --- a/src/agents/cli-runner/execute-tool-tracking.ts +++ b/src/agents/cli-runner/execute-tool-tracking.ts @@ -26,7 +26,7 @@ import { extractMessagingToolSendResult, extractMessagingToolSourceReplyPayload, } from "../embedded-agent-subscribe.tools.js"; -import { rotateClaudeLiveMcpCaptureKeyForContext } from "./claude-live-session.js"; +import { closeClaudeSession } from "./claude-live-registry.js"; import { attachCliMessagingDeliveryEvidence } from "./delivery-evidence.js"; import { appendUniqueCliMessagingEvidence, @@ -541,7 +541,7 @@ export function createCliToolTracking(context: PreparedCliRunContext) { return; } if (params.useManagedClaudeLiveSession) { - await rotateClaudeLiveMcpCaptureKeyForContext(context); + await closeClaudeSession(context, "mcp-capture-rotation"); } const internalStates = await Promise.all( Array.from(inFlightPreparedMessagingCalls).map(isPreparedInternalSourceReply), diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 240f2644eda7..dbdbe6530f12 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -21,10 +21,8 @@ import { } from "../embedded-agent-runner/run/images.js"; import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js"; -import { - closeClaudeLiveSessionForContext, - shouldUseClaudeLiveSession, -} from "./claude-live-session.js"; +import { buildClaudeOwnerKey, closeClaudeSession } from "./claude-live-registry.js"; +import { acceptsClaudeLive } from "./claude-live-session-policy.js"; import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { executeDeps } from "./execute-deps.js"; import { createCliEventHandlers } from "./execute-events.js"; @@ -46,7 +44,6 @@ import { import { executeCliProcess } from "./execute-process.js"; import { createCliToolTracking } from "./execute-tool-tracking.js"; import { - buildClaudeOwnerKey, buildCliArgs, enqueueCliRun, prepareCliPromptImagePayload, @@ -276,8 +273,7 @@ export async function executePreparedCliRun( cliSessionId: useResume ? resolvedSessionId : undefined, ownerKey: claudeOwnerKey, }); - const useManagedClaudeLiveSession = - shouldUseClaudeLiveSession(context) && !params.onSuccessfulAuthBinding; + const useManagedClaudeLiveSession = acceptsClaudeLive(context) && !params.onSuccessfulAuthBinding; // Fresh-session retries invoke this function again. Keep one helper per // observable CLI attempt so every started call retains its own terminal event. const diagnostics = createClaudeCliModelCallDiagnostics({ @@ -610,7 +606,7 @@ export async function executePreparedCliRun( } // The fork argument only applies at process startup; a cached warm child // would run inside the source session. Force a fresh spawn. - await closeClaudeLiveSessionForContext(context); + await closeClaudeSession(context, "restart"); } return await executeAttempt(); }); diff --git a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts index f10ec719ee0f..11ff3d692463 100644 --- a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts +++ b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts @@ -24,12 +24,11 @@ * Path 2 (src/agents/cli-runner/execute.ts) — implicit: same gate as Path 3, * and gated by `resolveSystemPromptUsage` which is tested below. * Path 3 (src/agents/cli-runner/helpers.ts — buildCliArgs) — covered here. - * Path 4 (src/agents/cli-runner/claude-live-session.ts — stripLiveProcessArgs - * via buildClaudeLiveArgs) — covered here. + * Path 4 (src/agents/cli-runner/claude-live-session.ts) — covered by + * claude-live-session.test.ts. */ import { describe, expect, it } from "vitest"; import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; -import { buildClaudeLiveArgs } from "./claude-live-session.test-support.js"; import { buildCliArgs, resolveSystemPromptUsage } from "./helpers.js"; // Minimal backend config matching the Anthropic claude-cli backend shape. @@ -242,52 +241,3 @@ describe("buildCliArgs — issue #80374", () => { ).toThrow("does not support checkpointed session resume"); }); }); - -// ─── buildClaudeLiveArgs (Path 4: live-stdio strip guard) ─────────────────── - -describe("buildClaudeLiveArgs — issue #80374 (live-stdio path)", () => { - const ARGS_WITH_SP = [ - "-p", - "--output-format", - "stream-json", - "--append-system-prompt-file", - PROMPT_FILE, - ]; - - it("legacy 'first': strips --append-system-prompt-file on resume", () => { - const liveArgs = buildClaudeLiveArgs({ - args: ARGS_WITH_SP, - backend: BACKEND_FIRST as CliBackendConfig, - systemPrompt: SYSTEM_PROMPT, - useResume: true, - }); - expect(liveArgs).not.toContain("--append-system-prompt-file"); - expect(liveArgs).not.toContain(PROMPT_FILE); - }); - - it("new 'always': keeps --append-system-prompt-file on resume (issue #80374)", () => { - const liveArgs = buildClaudeLiveArgs({ - args: ARGS_WITH_SP, - backend: BACKEND_ALWAYS as CliBackendConfig, - systemPrompt: SYSTEM_PROMPT, - useResume: true, - }); - expect(liveArgs).toContain("--append-system-prompt-file"); - expect(liveArgs).toContain(PROMPT_FILE); - }); - - it("keeps --append-system-prompt-file when useResume=false (both 'first' and 'always')", () => { - for (const backend of [BACKEND_FIRST, BACKEND_ALWAYS]) { - const liveArgs = buildClaudeLiveArgs({ - args: ARGS_WITH_SP, - backend: backend as CliBackendConfig, - systemPrompt: SYSTEM_PROMPT, - useResume: false, - }); - expect( - liveArgs, - `systemPromptWhen=${backend.systemPromptWhen} fresh session should keep flag`, - ).toContain("--append-system-prompt-file"); - } - }); -}); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index ca3542d172d3..3a2b21048cc9 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -62,34 +62,6 @@ export function enqueueCliRun(key: string, task: () => Promise): Promise { args: [], cleanup: vi.fn(async () => undefined), })), - getClaudeLiveSessionGenerationForOwner: vi.fn(() => undefined), + getClaudeGeneration: vi.fn(() => undefined), readExternalCliBootstrapCredential: readExternalCliBootstrapCredentialImpl, resolveApiKeyForProfile: resolveApiKeyForProfileImpl, }); @@ -4218,7 +4218,7 @@ describe("prepareCliRunContext", () => { setCliRunnerPrepareTestDeps({ claudeCliSessionTranscriptHasContent: transcriptCheck, claudeCliSessionTranscriptHasOrphanedToolUse: orphanCheck, - getClaudeLiveSessionGenerationForOwner: getLiveSessionGeneration, + getClaudeGeneration: getLiveSessionGeneration, }); const context = await fixture.prepare({ diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 4b0eecbd880c..ffee2f228ea0 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -120,7 +120,7 @@ import { } from "../workspace.js"; import { CliAuthProfilePreparationError } from "./auth-profile-preparation-error.js"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; -import { getClaudeLiveSessionGenerationForOwner } from "./claude-live-session.js"; +import { getClaudeGeneration } from "./claude-live-registry.js"; import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { resolveBundledCliBackendAuthPolicy, @@ -188,7 +188,7 @@ const prepareDeps = { prepareClaudeCliSkillsPlugin, claudeCliSessionTranscriptHasContent, claudeCliSessionTranscriptHasOrphanedToolUse, - getClaudeLiveSessionGenerationForOwner, + getClaudeGeneration, readExternalCliBootstrapCredential, resolveApiKeyForProfile, }; @@ -1371,7 +1371,7 @@ export async function prepareCliRunContext( preparedBackendFinal.backend.liveSession === "claude-stdio" && preparedBackendFinal.backend.output === "jsonl" && preparedBackendFinal.backend.input === "stdin" && - prepareDeps.getClaudeLiveSessionGenerationForOwner({ + prepareDeps.getClaudeGeneration({ backendId: backendResolved.id, agentAccountId: params.agentAccountId, agentId: params.agentId, diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index ca71a5f01d55..9cf100aa3807 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -325,7 +325,7 @@ function makeRunAgentAttemptParams(overrides: RunAgentAttemptOverrides): RunAgen const runCliAgentMock = vi.hoisted(() => vi.fn()); const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); -const hasClaudeLiveSessionForOwnerMock = vi.hoisted(() => vi.fn(() => false)); +const hasClaudeSessionMock = vi.hoisted(() => vi.fn(() => false)); const providerAuthAliasMocks = vi.hoisted(() => ({ resolveProviderAuthAliasMap: vi.fn(() => ({})), resolveProviderIdForAuth: vi.fn( @@ -352,9 +352,9 @@ vi.mock("../cli-runner.js", () => ({ runCliAgent: runCliAgentMock, })); -vi.mock("../cli-runner/claude-live-session.js", () => ({ - getClaudeLiveSessionGenerationForOwner: vi.fn(() => undefined), - hasClaudeLiveSessionForOwner: hasClaudeLiveSessionForOwnerMock, +vi.mock("../cli-runner/claude-live-registry.js", () => ({ + getClaudeGeneration: vi.fn(() => undefined), + hasClaudeSession: hasClaudeSessionMock, })); vi.mock("../model-selection.js", () => ({ @@ -647,8 +647,8 @@ describe("CLI attempt execution", () => { runCliAgentMock.mockReset(); runEmbeddedAgentMock.mockReset(); resetGeneratedMediaTaskActivityForTests(); - hasClaudeLiveSessionForOwnerMock.mockReset(); - hasClaudeLiveSessionForOwnerMock.mockReturnValue(false); + hasClaudeSessionMock.mockReset(); + hasClaudeSessionMock.mockReturnValue(false); providerAuthAliasMocks.resolveProviderAuthAliasMap.mockClear(); providerAuthAliasMocks.resolveProviderIdForAuth.mockClear(); cliBackendsTesting.setDepsForTest({ @@ -1454,7 +1454,7 @@ describe("CLI attempt execution", () => { const sessionEntry = makeClaudeCliSessionEntry("openclaw-sid", cliSessionId); const sessionStore: Record = { [sessionKey]: sessionEntry }; await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); - hasClaudeLiveSessionForOwnerMock.mockReturnValue(true); + hasClaudeSessionMock.mockReturnValue(true); runCliAgentMock.mockResolvedValueOnce(makeCliResult("ok")); await runClaudeCliAttempt({ @@ -1473,7 +1473,7 @@ describe("CLI attempt execution", () => { sessionId: cliSessionId, authProfileId: "anthropic:claude-cli", }); - expect(hasClaudeLiveSessionForOwnerMock).toHaveBeenCalledWith({ + expect(hasClaudeSessionMock).toHaveBeenCalledWith({ backendId: "claude-cli", agentAccountId: undefined, agentId: "main", diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 18c4d2989b4a..becd2f54750a 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -65,7 +65,7 @@ import { resolveCliExecutionAuthProfileId, } from "../cli-execution-auth.js"; import { runCliAgent } from "../cli-runner.js"; -import { hasClaudeLiveSessionForOwner } from "../cli-runner/claude-live-session.js"; +import { hasClaudeSession } from "../cli-runner/claude-live-registry.js"; import { resolveCliRuntimeToolsAllow } from "../cli-runner/tool-policy.js"; import { getCliSessionBinding, @@ -852,7 +852,7 @@ export function runAgentAttempt(params: { const hasManagedClaudeLiveSession = Boolean( isClaudeCliProvider(cliExecutionProvider) && cliSessionBinding?.sessionId && - hasClaudeLiveSessionForOwner({ + hasClaudeSession({ backendId: cliExecutionProvider, agentAccountId: params.runContext.accountId, agentId: params.sessionAgentId, diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 16d61370594e..a58faebe8b30 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveCliBackendConfig, resolveCliBackendLiveTest } from "../agents/cli-backends.js"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; -import { getClaudeLiveSessionGenerationForOwner } from "../agents/cli-runner/claude-live-session.js"; +import { getClaudeGeneration } from "../agents/cli-runner/claude-live-registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { shouldSkipLiveProviderDrift } from "../agents/live-test-provider-drift.js"; import { parseModelRef } from "../agents/model-selection.js"; @@ -672,9 +672,7 @@ describeLive("gateway live (cli backend)", () => { ).toBe(true); } else if (CLI_RESUME) { logCliBackendLiveStep("agent-resume:start", { sessionKey, resumeNonce }); - let continuityOwner: - | Parameters[0] - | undefined; + let continuityOwner: Parameters[0] | undefined; let expectedLiveSessionGeneration: string | undefined; if (resumeContinuityProbe) { const nativeHistory = await activeClient.request<{ @@ -695,7 +693,7 @@ describeLive("gateway live (cli backend)", () => { sessionId: continuitySessionId, sessionKey, }; - expectedLiveSessionGeneration = getClaudeLiveSessionGenerationForOwner(continuityOwner); + expectedLiveSessionGeneration = getClaudeGeneration(continuityOwner); expect(expectedLiveSessionGeneration).toBeTruthy(); } const resumePayload = await requestWithCodexTimeoutRetry( @@ -735,9 +733,7 @@ describeLive("gateway live (cli backend)", () => { if (!continuityOwner || !expectedLiveSessionGeneration) { throw new Error("Claude CLI continuity probe lost its live-session generation"); } - expect(getClaudeLiveSessionGenerationForOwner(continuityOwner)).toBe( - expectedLiveSessionGeneration, - ); + expect(getClaudeGeneration(continuityOwner)).toBe(expectedLiveSessionGeneration); } else { expect( matchesCliBackendReply(resumeText, `CLI backend RESUME OK ${resumeNonce}.`),