Download src/agents/command/attempt-execution.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/command/attempt-execution.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/command/attempt-execution.test.ts
-
curl -L -o attempt-execution.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/command/attempt-execution.test.ts
37.4 kB
| // Covers attempt-execution helper behavior around retries, Claude CLI | |
| // transcripts, and ACP visible text accumulation. | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { createDeferred } from "../../../test/helpers/promise.js"; | |
| import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; | |
| import { waitForSessionTranscriptIndexReconcile } from "../../config/sessions/session-transcript-reconcile.js"; | |
| import { closeOpenClawAgentDatabaseByPath } from "../../state/openclaw-agent-db.js"; | |
| import { SessionManager } from "../sessions/session-manager.js"; | |
| import { buildAssistantMessage, buildUsageWithNoCost } from "../stream-message-shared.js"; | |
| const mocks = vi.hoisted(() => ({ | |
| readClaudeCliFallbackSeed: vi.fn(), | |
| })); | |
| vi.mock("../cli-runner/log.js", () => ({ | |
| cliBackendLog: { warn: vi.fn() }, | |
| })); | |
| vi.mock("../../gateway/cli-session-history.js", () => ({ | |
| readClaudeCliFallbackSeed: mocks.readClaudeCliFallbackSeed, | |
| })); | |
| import { cliBackendLog } from "../cli-runner/log.js"; | |
| import { | |
| buildClaudeCliFallbackContextPrelude, | |
| claudeCliSessionTranscriptHasContent, | |
| claudeCliSessionTranscriptHasOrphanedToolUse, | |
| createAcpVisibleTextAccumulator, | |
| resolveFallbackRetryPrompt, | |
| sessionTranscriptHasContent, | |
| } from "./attempt-execution.helpers.js"; | |
| import { | |
| claudeCliSessionTranscriptPath, | |
| formatClaudeCliFallbackPrelude, | |
| } from "./attempt-execution.helpers.test-support.js"; | |
| import { resolveClaudeCliProjectDirForWorkspace } from "./claude-cli-project-dir.js"; | |
| describe("resolveFallbackRetryPrompt", () => { | |
| const originalBody = "Summarize the quarterly earnings report and highlight key trends."; | |
| it("returns original body on first attempt (isFallbackRetry=false)", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: false, | |
| }), | |
| ).toBe(originalBody); | |
| }); | |
| it("prepends recovery prefix to original body on fallback retry with existing session history", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: true, | |
| sessionHasHistory: true, | |
| }), | |
| ).toBe(`[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`); | |
| }); | |
| it("preserves original body for fallback retry when sessionHasHistory is undefined", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: true, | |
| }), | |
| ).toBe(originalBody); | |
| }); | |
| it("returns original body on first attempt regardless of sessionHasHistory", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: false, | |
| sessionHasHistory: true, | |
| }), | |
| ).toBe(originalBody); | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: false, | |
| sessionHasHistory: false, | |
| }), | |
| ).toBe(originalBody); | |
| }); | |
| it("prepends priorContextPrelude before the retry marker on fallback retry", () => { | |
| const prelude = "## Prior session context (from claude-cli)\nuser: prior question"; | |
| // Claude fallback prelude must come before the retry marker so the model | |
| // receives prior CLI context before the instruction about failure recovery. | |
| const result = resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: true, | |
| sessionHasHistory: true, | |
| priorContextPrelude: prelude, | |
| }); | |
| expect(result).toBe( | |
| `${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`, | |
| ); | |
| }); | |
| it("emits the retry prompt with prelude even when sessionHasHistory is false (claude-cli case)", () => { | |
| const prelude = "## Prior session context (from claude-cli)\nuser: prior question"; | |
| const result = resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: true, | |
| sessionHasHistory: false, | |
| priorContextPrelude: prelude, | |
| }); | |
| expect(result).toBe( | |
| `${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`, | |
| ); | |
| }); | |
| it("ignores empty/whitespace priorContextPrelude", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: true, | |
| sessionHasHistory: false, | |
| priorContextPrelude: " \n ", | |
| }), | |
| ).toBe(originalBody); | |
| }); | |
| it("does not prepend prelude on non-fallback first attempts", () => { | |
| expect( | |
| resolveFallbackRetryPrompt({ | |
| body: originalBody, | |
| isFallbackRetry: false, | |
| sessionHasHistory: true, | |
| priorContextPrelude: "anything", | |
| }), | |
| ).toBe(originalBody); | |
| }); | |
| }); | |
| describe("formatClaudeCliFallbackPrelude", () => { | |
| it("returns empty string when seed has neither summary nor turns", () => { | |
| expect(formatClaudeCliFallbackPrelude({ recentTurns: [] })).toBe(""); | |
| }); | |
| it("emits summary alone when no turns are available", () => { | |
| const out = formatClaudeCliFallbackPrelude({ | |
| summaryText: "User wants to ship a billing-aware fallback.", | |
| recentTurns: [], | |
| }); | |
| expect(out).toContain("## Prior session context (from claude-cli)"); | |
| expect(out).toContain("Summary of earlier conversation:"); | |
| expect(out).toContain("User wants to ship a billing-aware fallback."); | |
| expect(out).not.toContain("Recent turns:"); | |
| }); | |
| it("formats user/assistant turns and tags tool blocks with compact hints", () => { | |
| // Tool-use blocks are represented as compact hints because fallback prompts | |
| // should preserve intent without replaying full tool schemas or outputs. | |
| const out = formatClaudeCliFallbackPrelude({ | |
| recentTurns: [ | |
| { | |
| role: "user", | |
| content: "Earlier user question", | |
| }, | |
| { | |
| role: "assistant", | |
| content: [ | |
| { type: "text", text: "Earlier assistant reply" }, | |
| { type: "toolcall", name: "Bash" }, | |
| ], | |
| }, | |
| { | |
| role: "user", | |
| content: [ | |
| { | |
| type: "tool_result", | |
| tool_use_id: "toolu_x", | |
| content: "Earlier tool output", | |
| }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| expect(out).toContain("## Prior session context (from claude-cli)"); | |
| expect(out).toContain("Recent turns:"); | |
| expect(out).toContain("user: Earlier user question"); | |
| expect(out).toContain("assistant: Earlier assistant reply"); | |
| expect(out).toContain("(tool call: Bash)"); | |
| expect(out).toContain("(tool result: Earlier tool output)"); | |
| }); | |
| it("truncates an oversized summary instead of dropping it silently", () => { | |
| const huge = "x ".repeat(10_000).trim(); | |
| const out = formatClaudeCliFallbackPrelude( | |
| { summaryText: huge, recentTurns: [] }, | |
| { charBudget: 600 }, | |
| ); | |
| expect(out).toContain("Summary of earlier conversation (truncated):"); | |
| expect(out.length).toBeLessThan(800); | |
| expect(out).toMatch(/…$/); | |
| }); | |
| it.each([ | |
| ["a surrogate boundary", `${"x".repeat(21)}😀${"y".repeat(100)}`, "x".repeat(21)], | |
| ["the ASCII budget", "x".repeat(100), "x".repeat(22)], | |
| ])("preserves %s when truncating an oversized summary", (_label, summaryText, expected) => { | |
| const out = formatClaudeCliFallbackPrelude( | |
| { summaryText, recentTurns: [] }, | |
| { charBudget: 128 }, | |
| ); | |
| expect(out).toContain(`Summary of earlier conversation (truncated):\n${expected} …`); | |
| }); | |
| it("drops oldest turns first when the budget cannot fit all of them", () => { | |
| const turns = Array.from({ length: 10 }, (_, i) => ({ | |
| role: "user" as const, | |
| content: `turn ${i + 1} ${"x".repeat(80)}`, | |
| })); | |
| const out = formatClaudeCliFallbackPrelude({ recentTurns: turns }, { charBudget: 350 }); | |
| // Newest turn must be present; oldest turns are the first budget casualty. | |
| expect(out).toContain("turn 10"); | |
| expect(out).not.toContain("turn 1 "); | |
| }); | |
| it("keeps the recent turn window contiguous when an adjacent turn is oversized", () => { | |
| const out = formatClaudeCliFallbackPrelude( | |
| { | |
| recentTurns: [ | |
| { role: "user", content: "older small turn" }, | |
| { role: "assistant", content: `oversized adjacent turn ${"x".repeat(500)}` }, | |
| { role: "user", content: "newest small turn" }, | |
| ], | |
| }, | |
| { charBudget: 260 }, | |
| ); | |
| expect(out).toContain("newest small turn"); | |
| expect(out).not.toContain("oversized adjacent turn"); | |
| expect(out).not.toContain("older small turn"); | |
| }); | |
| }); | |
| describe("buildClaudeCliFallbackContextPrelude", () => { | |
| beforeEach(() => { | |
| mocks.readClaudeCliFallbackSeed.mockReset(); | |
| }); | |
| it("returns empty string when no sessionId is provided", () => { | |
| expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: undefined })).toBe(""); | |
| expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: " " })).toBe(""); | |
| expect(mocks.readClaudeCliFallbackSeed).not.toHaveBeenCalled(); | |
| }); | |
| it("returns empty string when the Claude session loader finds no seed", () => { | |
| mocks.readClaudeCliFallbackSeed.mockReturnValue(undefined); | |
| expect( | |
| buildClaudeCliFallbackContextPrelude({ | |
| cliSessionId: "missing-session", | |
| homeDir: "/tmp/test-home", | |
| }), | |
| ).toBe(""); | |
| expect(mocks.readClaudeCliFallbackSeed).toHaveBeenCalledWith({ | |
| cliSessionId: "missing-session", | |
| homeDir: "/tmp/test-home", | |
| }); | |
| }); | |
| it("formats the Claude session loader seed into a labeled fallback prelude", () => { | |
| mocks.readClaudeCliFallbackSeed.mockReturnValue({ | |
| recentTurns: [ | |
| { | |
| role: "user", | |
| content: "prior question about deploys", | |
| }, | |
| { | |
| role: "assistant", | |
| content: [ | |
| { type: "text", text: "prior answer about blue-green" }, | |
| { type: "toolcall", name: "Bash" }, | |
| ], | |
| }, | |
| ], | |
| }); | |
| const prelude = buildClaudeCliFallbackContextPrelude({ | |
| cliSessionId: " e2e-session ", | |
| homeDir: "/tmp/test-home", | |
| }); | |
| expect(mocks.readClaudeCliFallbackSeed).toHaveBeenCalledWith({ | |
| cliSessionId: "e2e-session", | |
| homeDir: "/tmp/test-home", | |
| }); | |
| expect(prelude).toContain("## Prior session context (from claude-cli)"); | |
| expect(prelude).toContain("user: prior question about deploys"); | |
| expect(prelude).toContain("assistant: prior answer about blue-green"); | |
| expect(prelude).toContain("(tool call: Bash)"); | |
| }); | |
| }); | |
| describe("sessionTranscriptHasContent", () => { | |
| let tmpDir: string; | |
| let target: { | |
| agentId: string; | |
| sessionId: string; | |
| sessionKey: string; | |
| storePath: string; | |
| }; | |
| beforeEach(async () => { | |
| tmpDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "oc-transcript-probe-"))); | |
| target = { | |
| agentId: "audit", | |
| sessionId: "fallback-history", | |
| sessionKey: "agent:audit:main", | |
| storePath: path.join(tmpDir, "openclaw-agent.sqlite"), | |
| }; | |
| await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); | |
| }); | |
| afterEach(async () => { | |
| await waitForSessionTranscriptIndexReconcile({ | |
| agentId: target.agentId, | |
| path: target.storePath, | |
| }); | |
| closeOpenClawAgentDatabaseByPath(target.storePath); | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| const assistantMessage = () => | |
| buildAssistantMessage({ | |
| model: { api: "test", provider: "test", id: "test-assistant-model" }, | |
| content: [{ type: "text", text: "persisted answer" }], | |
| stopReason: "stop", | |
| usage: buildUsageWithNoCost({}), | |
| timestamp: 2, | |
| }); | |
| it("marks fallback history only after SQLite contains an assistant turn", async () => { | |
| expect(await sessionTranscriptHasContent(undefined)).toBe(false); | |
| expect(await sessionTranscriptHasContent(target)).toBe(false); | |
| const manager = SessionManager.open(target, tmpDir); | |
| manager.appendMessage({ role: "user", content: "x".repeat(300 * 1024), timestamp: 1 }); | |
| manager.flushPendingPersistence(); | |
| expect(await sessionTranscriptHasContent(target)).toBe(false); | |
| manager.appendMessage(assistantMessage()); | |
| manager.flushPendingPersistence(); | |
| const sessionHasHistory = await sessionTranscriptHasContent(target); | |
| expect(sessionHasHistory).toBe(true); | |
| expect( | |
| resolveFallbackRetryPrompt({ body: "continue", isFallbackRetry: true, sessionHasHistory }), | |
| ).toBe("[Retry after the previous model attempt failed or timed out]\n\ncontinue"); | |
| }); | |
| it("ignores abandoned assistants and clears history at reset boundaries", async () => { | |
| const manager = SessionManager.open(target, tmpDir); | |
| const root = manager.appendMessage({ role: "user", content: "root", timestamp: 1 }); | |
| manager.appendMessage(assistantMessage()); | |
| manager.branch(root); | |
| manager.appendMessage({ role: "user", content: "active branch", timestamp: 3 }); | |
| manager.flushPendingPersistence(); | |
| expect(await sessionTranscriptHasContent(target)).toBe(false); | |
| manager.appendMessage(assistantMessage()); | |
| manager.flushPendingPersistence(); | |
| expect(await sessionTranscriptHasContent(target)).toBe(true); | |
| manager.appendResetBoundary("new"); | |
| manager.appendMessage({ role: "user", content: "fresh turn", timestamp: 4 }); | |
| manager.flushPendingPersistence(); | |
| expect(await sessionTranscriptHasContent(target)).toBe(false); | |
| }); | |
| }); | |
| describe("claudeCliSessionTranscriptPath", () => { | |
| it("returns null for malformed session ids", () => { | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "../escape", | |
| workspaceDir: "/tmp/ws", | |
| homeDir: "/home/x", | |
| }), | |
| ).toBeNull(); | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "", | |
| workspaceDir: "/tmp/ws", | |
| homeDir: "/home/x", | |
| }), | |
| ).toBeNull(); | |
| }); | |
| it("returns null when workspaceDir is empty or whitespace", () => { | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "abc", | |
| workspaceDir: "", | |
| homeDir: "/home/x", | |
| }), | |
| ).toBeNull(); | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "abc", | |
| workspaceDir: " ", | |
| homeDir: "/home/x", | |
| }), | |
| ).toBeNull(); | |
| }); | |
| it("uses the canonical Claude project dir resolver", () => { | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "11111111-2222-3333-4444-555555555555", | |
| workspaceDir: "/home/faris/.openclaw/workspace", | |
| homeDir: "/home/faris", | |
| }), | |
| ).toBe( | |
| path.join( | |
| "/home/faris", | |
| ".claude", | |
| "projects", | |
| "-home-faris--openclaw-workspace", | |
| "11111111-2222-3333-4444-555555555555.jsonl", | |
| ), | |
| ); | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "session-x", | |
| workspaceDir: "/tmp/foo_bar.baz", | |
| homeDir: "/home/x", | |
| }), | |
| ).toBe(path.join("/home/x", ".claude", "projects", "-tmp-foo-bar-baz", "session-x.jsonl")); | |
| }); | |
| it("canonicalizes symlinked workspaces before resolving the Claude project dir", async () => { | |
| const root = await fs.mkdtemp(path.join(os.tmpdir(), "oc-claude-project-link-")); | |
| try { | |
| const workspaceDir = path.join(root, "workspace"); | |
| const linkDir = path.join(root, "workspace-link"); | |
| await fs.mkdir(workspaceDir); | |
| await fs.symlink(workspaceDir, linkDir, "dir"); | |
| expect( | |
| claudeCliSessionTranscriptPath({ | |
| sessionId: "session-link", | |
| workspaceDir: linkDir, | |
| homeDir: root, | |
| }), | |
| ).toBe( | |
| path.join( | |
| resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir: root }), | |
| "session-link.jsonl", | |
| ), | |
| ); | |
| } finally { | |
| await fs.rm(root, { recursive: true, force: true }); | |
| } | |
| }); | |
| }); | |
| describe("claudeCliSessionTranscriptHasContent", () => { | |
| let tmpDir: string; | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oc-claude-session-test-")); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| async function makeWorkspace() { | |
| const workspaceDir = await fs.mkdtemp(path.join(tmpDir, "ws-")); | |
| return workspaceDir; | |
| } | |
| async function writeClaudeProjectFile(workspaceDir: string, sessionId: string, content: string) { | |
| const projectDir = resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir: tmpDir }); | |
| await fs.mkdir(projectDir, { recursive: true }); | |
| const file = path.join(projectDir, `${sessionId}.jsonl`); | |
| await fs.writeFile(file, content, "utf-8"); | |
| return file; | |
| } | |
| const GRACE_MS = 250; | |
| it("returns true when the Claude project transcript has an assistant message", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| await writeClaudeProjectFile( | |
| workspaceDir, | |
| "session-with-assistant", | |
| `${JSON.stringify({ | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "text", text: "hello" }], | |
| }, | |
| })}\n`, | |
| ); | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId: "session-with-assistant", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("rejects path-like session ids instead of escaping the Claude projects tree", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| await writeClaudeProjectFile(workspaceDir, "safe-session", ""); | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId: "../safe-session", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns false when workspaceDir is missing (path cannot be computed)", async () => { | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId: "any-session", | |
| workspaceDir: undefined, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns true immediately when the assistant message is already flushed (no grace sleep)", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| await writeClaudeProjectFile( | |
| workspaceDir, | |
| "already-flushed", | |
| `${JSON.stringify({ | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "ack" }] }, | |
| })}\n`, | |
| ); | |
| const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); | |
| try { | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId: "already-flushed", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| const graceCalls = setTimeoutSpy.mock.calls.filter(([, delay]) => delay === GRACE_MS); | |
| expect(graceCalls).toHaveLength(0); | |
| } finally { | |
| setTimeoutSpy.mockRestore(); | |
| } | |
| }); | |
| it("returns true on the second scan when the assistant message lands during the grace window", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| const sessionId = "fs-flush-latency"; | |
| const file = await writeClaudeProjectFile( | |
| workspaceDir, | |
| sessionId, | |
| `${JSON.stringify({ | |
| type: "user", | |
| message: { role: "user", content: [{ type: "text", text: "hi" }] }, | |
| })}\n`, | |
| ); | |
| const graceStarted = createDeferred(); | |
| const releaseGrace = createDeferred(); | |
| const schedule = globalThis.setTimeout; | |
| let graceFires = 0; | |
| const setTimeoutSpy = vi | |
| .spyOn(globalThis, "setTimeout") | |
| .mockImplementation((handler, delay, ...args) => { | |
| if (delay !== GRACE_MS) { | |
| return schedule(handler, delay, ...args); | |
| } | |
| // Hold only this probe's grace sleep; worker completion must retain native timers. | |
| setTimeoutSpy.mockRestore(); | |
| graceFires += 1; | |
| graceStarted.resolve(); | |
| return schedule(() => { | |
| void releaseGrace.promise.then(() => handler(...args)); | |
| }, delay); | |
| }); | |
| const probe = claudeCliSessionTranscriptHasContent({ | |
| sessionId, | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }); | |
| try { | |
| await Promise.race([graceStarted.promise, probe]); | |
| expect(graceFires).toBe(1); | |
| await fs.appendFile( | |
| file, | |
| `${JSON.stringify({ | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "ack" }] }, | |
| })}\n`, | |
| "utf-8", | |
| ); | |
| releaseGrace.resolve(); | |
| expect(await probe).toBe(true); | |
| } finally { | |
| setTimeoutSpy.mockRestore(); | |
| releaseGrace.resolve(); | |
| await probe; | |
| } | |
| }); | |
| it("returns false and emits a structured v4 warn when the JSONL never appears", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| const warnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); | |
| try { | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId: "ghost-session", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| const v4Warnings = warnSpy.mock.calls.filter( | |
| ([msg]) => typeof msg === "string" && msg.startsWith("claude-cli transcript probe v4 miss"), | |
| ); | |
| expect(v4Warnings).toHaveLength(1); | |
| const message = v4Warnings[0]?.[0]; | |
| expect(message).toContain("sessionId=ghost-session"); | |
| expect(message).toContain(`grace ${GRACE_MS}ms`); | |
| expect(message).toContain("fileExists=false"); | |
| expect(message).toContain( | |
| `expectedPath=${claudeCliSessionTranscriptPath({ | |
| sessionId: "ghost-session", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| })}`, | |
| ); | |
| } finally { | |
| warnSpy.mockRestore(); | |
| } | |
| }); | |
| it("returns false and emits a v4 warn flagging fileExists=true when the JSONL stays headerless", async () => { | |
| const workspaceDir = await makeWorkspace(); | |
| const sessionId = "user-header-only"; | |
| await writeClaudeProjectFile( | |
| workspaceDir, | |
| sessionId, | |
| `${JSON.stringify({ | |
| type: "user", | |
| message: { role: "user", content: [{ type: "text", text: "hi" }] }, | |
| })}\n`, | |
| ); | |
| const warnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); | |
| try { | |
| expect( | |
| await claudeCliSessionTranscriptHasContent({ | |
| sessionId, | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| const v4Warnings = warnSpy.mock.calls.filter( | |
| ([msg]) => typeof msg === "string" && msg.startsWith("claude-cli transcript probe v4 miss"), | |
| ); | |
| expect(v4Warnings).toHaveLength(1); | |
| expect(v4Warnings[0]?.[0]).toContain("fileExists=true"); | |
| } finally { | |
| warnSpy.mockRestore(); | |
| } | |
| }); | |
| }); | |
| describe("claudeCliSessionTranscriptHasOrphanedToolUse", () => { | |
| let tmpDir: string; | |
| let workspaceDir: string; | |
| beforeEach(async () => { | |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oc-claude-orphan-test-")); | |
| workspaceDir = await fs.mkdtemp(path.join(tmpDir, "ws-")); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tmpDir, { recursive: true, force: true }); | |
| }); | |
| async function writeJsonlSession(sessionId: string, lines: object[]) { | |
| const projectDir = resolveClaudeCliProjectDirForWorkspace({ | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }); | |
| await fs.mkdir(projectDir, { recursive: true }); | |
| const file = path.join(projectDir, `${sessionId}.jsonl`); | |
| await fs.writeFile(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf-8"); | |
| return file; | |
| } | |
| it("returns false when the transcript is missing", async () => { | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "no-such-session", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns false when the last assistant message has no tool_use", async () => { | |
| await writeJsonlSession("text-only", [ | |
| { | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "all done" }] }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "text-only", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns false when every tool_use in the last assistant message has a matching tool_result", async () => { | |
| await writeJsonlSession("answered", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| { | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "answered", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns true when the last assistant message has a trailing tool_use without tool_result", async () => { | |
| await writeJsonlSession("orphan", [ | |
| { | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "let me run that" }] }, | |
| }, | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_unanswered", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "orphan", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("returns true when a Claude server tool use is unanswered", async () => { | |
| await writeJsonlSession("server-tool-orphan", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { type: "server_tool_use", id: "srvtoolu_unanswered", name: "web_search", input: {} }, | |
| ], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "server-tool-orphan", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("returns false when Claude-specific tool uses have matching tool results", async () => { | |
| await writeJsonlSession("claude-specific-answered", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} }, | |
| { type: "mcp_tool_use", id: "mcptoolu_1", name: "mcp__demo", input: {} }, | |
| ], | |
| }, | |
| }, | |
| { | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [ | |
| { type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [] }, | |
| { type: "mcp_tool_result", tool_use_id: "mcptoolu_1", content: "ok" }, | |
| ], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "claude-specific-answered", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns false when Claude hosted tool results are in the assistant message", async () => { | |
| await writeJsonlSession("assistant-hosted-tool-result", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { type: "server_tool_use", id: "srvtoolu_inline", name: "web_search", input: {} }, | |
| { type: "web_search_tool_result", tool_use_id: "srvtoolu_inline", content: [] }, | |
| { type: "text", text: "found it" }, | |
| ], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "assistant-hosted-tool-result", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns true when the last assistant has multiple tool_use and at least one is orphaned", async () => { | |
| await writeJsonlSession("partial", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [ | |
| { type: "tool_use", id: "toolu_a", name: "Bash", input: {} }, | |
| { type: "tool_use", id: "toolu_b", name: "Read", input: {} }, | |
| ], | |
| }, | |
| }, | |
| { | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [{ type: "tool_result", tool_use_id: "toolu_a", content: "ok" }], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "partial", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("returns false when an earlier assistant tool_use is unanswered but the last assistant message resolved cleanly", async () => { | |
| await writeJsonlSession("buried", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_old", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| { | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "moving on" }] }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "buried", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("returns false when a later string assistant message supersedes an old orphan", async () => { | |
| await writeJsonlSession("buried-string", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_old_string", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| { | |
| type: "assistant", | |
| message: { role: "assistant", content: "moving on" }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "buried-string", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("rejects path-like session ids instead of escaping the Claude projects tree", async () => { | |
| await writeJsonlSession("safe", []); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "../safe", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("ignores sidechain entries when deciding orphans (matches main-history importer's skip rule)", async () => { | |
| await writeJsonlSession("sidechain-trailing", [ | |
| { | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "ok" }] }, | |
| }, | |
| { | |
| isSidechain: true, | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_subagent", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "sidechain-trailing", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| it("still flags a main-conversation orphan even when sidechain entries exist alongside", async () => { | |
| await writeJsonlSession("main-orphan-with-sidechain", [ | |
| { | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_main_orphan", name: "Bash", input: {} }], | |
| }, | |
| }, | |
| { | |
| isSidechain: true, | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [ | |
| { type: "tool_result", tool_use_id: "toolu_main_orphan", content: "ignored sidechain" }, | |
| ], | |
| }, | |
| }, | |
| ]); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "main-orphan-with-sidechain", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("inspects the transcript tail past 500 records (does not inherit the content-probe cap)", async () => { | |
| const lines: object[] = []; | |
| for (let i = 0; i < 600; i++) { | |
| lines.push({ | |
| type: "user", | |
| message: { role: "user", content: [{ type: "text", text: `ping ${i}` }] }, | |
| }); | |
| } | |
| lines.push({ | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_resolved_late", name: "Bash", input: {} }], | |
| }, | |
| }); | |
| lines.push({ | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [{ type: "tool_result", tool_use_id: "toolu_resolved_late", content: "ok" }], | |
| }, | |
| }); | |
| lines.push({ | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_trailing_orphan", name: "Bash", input: {} }], | |
| }, | |
| }); | |
| await writeJsonlSession("long-with-trailing-orphan", lines); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "long-with-trailing-orphan", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(true); | |
| }); | |
| it("does not falsely flag a long transcript whose orphan was resolved past record 500", async () => { | |
| const lines: object[] = []; | |
| lines.push({ | |
| type: "assistant", | |
| message: { | |
| role: "assistant", | |
| content: [{ type: "tool_use", id: "toolu_resolved_far_later", name: "Bash", input: {} }], | |
| }, | |
| }); | |
| for (let i = 0; i < 600; i++) { | |
| lines.push({ | |
| type: "user", | |
| message: { role: "user", content: [{ type: "text", text: `ping ${i}` }] }, | |
| }); | |
| } | |
| lines.push({ | |
| type: "user", | |
| message: { | |
| role: "user", | |
| content: [{ type: "tool_result", tool_use_id: "toolu_resolved_far_later", content: "ok" }], | |
| }, | |
| }); | |
| lines.push({ | |
| type: "assistant", | |
| message: { role: "assistant", content: [{ type: "text", text: "moving on" }] }, | |
| }); | |
| await writeJsonlSession("long-with-resolved-far-later", lines); | |
| expect( | |
| await claudeCliSessionTranscriptHasOrphanedToolUse({ | |
| sessionId: "long-with-resolved-far-later", | |
| workspaceDir, | |
| homeDir: tmpDir, | |
| }), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe("createAcpVisibleTextAccumulator", () => { | |
| it("preserves cumulative raw snapshots after stripping a glued NO_REPLY prefix", () => { | |
| const acc = createAcpVisibleTextAccumulator(); | |
| expect(acc.consume("NO_REPLYThe user")).toEqual({ | |
| text: "The user", | |
| delta: "The user", | |
| }); | |
| expect(acc.consume("NO_REPLYThe user is saying")).toEqual({ | |
| text: "The user is saying", | |
| delta: " is saying", | |
| }); | |
| expect(acc.finalize()).toBe("The user is saying"); | |
| expect(acc.finalizeRaw()).toBe("The user is saying"); | |
| }); | |
| it("keeps append-only deltas working after stripping a glued NO_REPLY prefix", () => { | |
| const acc = createAcpVisibleTextAccumulator(); | |
| expect(acc.consume("NO_REPLYThe user")).toEqual({ | |
| text: "The user", | |
| delta: "The user", | |
| }); | |
| expect(acc.consume(" is saying")).toEqual({ | |
| text: "The user is saying", | |
| delta: " is saying", | |
| }); | |
| }); | |
| it("preserves punctuation-start text that begins with NO_REPLY-like content", () => { | |
| const acc = createAcpVisibleTextAccumulator(); | |
| expect(acc.consume("NO_REPLY: explanation")).toEqual({ | |
| text: "NO_REPLY: explanation", | |
| delta: "NO_REPLY: explanation", | |
| }); | |
| expect(acc.finalize()).toBe("NO_REPLY: explanation"); | |
| expect(acc.finalizeReplySnapshot()).toEqual({ | |
| disposition: "visible", | |
| text: "NO_REPLY: explanation", | |
| }); | |
| }); | |
| it("buffers chunked NO_REPLY prefixes before emitting visible text", () => { | |
| const acc = createAcpVisibleTextAccumulator(); | |
| expect(acc.consume("NO")).toBeNull(); | |
| expect(acc.consume("NO_")).toBeNull(); | |
| expect(acc.consume("NO_RE")).toBeNull(); | |
| expect(acc.consume("NO_REPLY")).toBeNull(); | |
| expect(acc.consume("Actual answer")).toEqual({ | |
| text: "Actual answer", | |
| delta: "Actual answer", | |
| }); | |
| }); | |
| it.each([ | |
| { | |
| name: "visible output", | |
| chunks: ["Final answer"], | |
| expected: { disposition: "visible", text: "Final answer" }, | |
| }, | |
| { name: "exact silence", chunks: ["NO_REPLY"], expected: { disposition: "silent" } }, | |
| { name: "clean empty output", chunks: [], expected: { disposition: "empty" } }, | |
| { name: "partial control prefix", chunks: ["NO_RE"], expected: { disposition: "empty" } }, | |
| { | |
| name: "punctuation-wrapped silence", | |
| chunks: ["NO_REPLY:"], | |
| expected: { disposition: "silent" }, | |
| }, | |
| { | |
| name: "ellipsis-wrapped silence", | |
| chunks: ["NO_REPLY..."], | |
| expected: { disposition: "silent" }, | |
| }, | |
| { | |
| name: "chunked punctuation-wrapped silence", | |
| chunks: ["NO_REPLY", ":"], | |
| expected: { disposition: "silent" }, | |
| }, | |
| { | |
| name: "glued visible continuation", | |
| chunks: ["NO_REPLYVisible continuation"], | |
| expected: { disposition: "visible", text: "Visible continuation" }, | |
| }, | |
| ])("classifies $name at ACP finalization", ({ chunks, expected }) => { | |
| const acc = createAcpVisibleTextAccumulator(); | |
| for (const chunk of chunks) { | |
| acc.consume(chunk); | |
| } | |
| expect(acc.finalizeReplySnapshot()).toEqual(expected); | |
| }); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |