openclaw / src /agents /command /attempt-execution.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
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. */