fix: scope held Claude processes and honor MCP cancellation

This commit is contained in:
amosblomqvist
2026-10-28 17:19:51 +02:00
parent 6ca431644d
commit 64a36bebd2
7 changed files with 192 additions and 37 deletions
+48 -5
View File
@@ -45,6 +45,7 @@ const HOLD = JSON.stringify({ fake: "hold" });
const REPLY = JSON.stringify({ fake: "reply" });
const CLEANUP_TIMEOUT_MS = 2000;
const SESSION_ID = "stream-test-chat";
const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } };
const HI: Message = { role: "user", content: "hi", timestamp: 0 };
@@ -156,7 +157,7 @@ async function runScenario(
messages: [{ role: "user", content: "hi", timestamp: 0 }],
tools: [READ_TOOL, { name: "get_weather", description: "Weather", parameters: { type: "object", properties: {} } }],
});
const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, options));
const events = await collectEvents(createClaudeCodeStreamFunction(runClaude)(model, context, { sessionId: SESSION_ID, ...options }));
return { events, calls };
}
@@ -210,7 +211,7 @@ async function runTurn(
): Promise<AssistantMessage> {
const context = normalizeContext({ systemPrompt: "You are a tutor.", messages, tools: [READ_TOOL] });
const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, options)));
const done = getLastEvent(await collectEvents(streamFunction(getModel(), context, { sessionId: SESSION_ID, ...options })));
assert.ok(done.type === "done", `turn ended with ${done.type}`);
@@ -852,7 +853,7 @@ test("a parallel identity failure after an admitted slice is reported on continu
const message = await runTurn(stream, [HI]);
getOnlyCall(calls).child.send("continue");
await waitForExit(getOnlyCall(calls).child);
const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] })));
const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(first, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID }));
assert.match(getErrorMessage(events) ?? "", /compatibility error.*claudecode\/toolUseId/);
assert.equal(calls.length, 1, "must not hide the compatibility failure by spawning again");
await assertCleanedUp(getOnlyCall(calls));
@@ -867,7 +868,7 @@ test("output after MCP admission fails continuation instead of silently replayin
const message = await runTurn(stream, [HI]);
getOnlyCall(calls).child.send("continue");
await waitForExit(getOnlyCall(calls).child);
const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] })));
const events = await collectEvents(stream(getModel(), normalizeContext({ systemPrompt: "You are a tutor.", messages: [HI, message, toToolResult(call, "A")], tools: [READ_TOOL] }), { sessionId: SESSION_ID }));
assert.match(getErrorMessage(events) ?? "", /moved on while.*waiting for results/);
assert.equal(calls.length, 1);
await assertCleanedUp(getOnlyCall(calls));
@@ -927,13 +928,55 @@ for (const change of ["model", "effort", "system prompt", "tool schema", "tool d
}],
});
const model = change === "model" ? CLAUDE_CODE_MODELS[1]! : getModel();
const events = await collectEvents(stream(model, context, change === "effort" ? { reasoning: "high" } : undefined));
const events = await collectEvents(stream(model, context, { sessionId: SESSION_ID, ...(change === "effort" ? { reasoning: "high" as const } : {}) }));
assert.equal(getLastEvent(events).type, "done");
assert.equal(fresh.calls.length, 1, "must start a fresh CLI rather than silently use stale configuration");
await assertCleanedUp(getOnlyCall(waiting.calls));
});
}
for (const identicalHistory of [false, true]) test(`one registered stream isolates two sessions with colliding tool IDs and ${identicalHistory ? "identical" : "different"} histories`, async (t) => {
const call: ToolUse = { id: "toolu_shared_singleton", name: "read", arguments: { path: "same" } };
const one = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]);
const two = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]);
let spawns = 0;
const stream = createClaudeCodeStreamFunction((args, options) => {
const run = [one, two][spawns++];
assert.ok(run, "a valid continuation must not spawn another CLI");
return run.runClaude(args, options);
});
const historyOne = [HI];
const historyTwo: Message[] = identicalHistory ? [HI] : [{ role: "user", content: "another conversation", timestamp: 0 }];
const messageOne = await runTurn(stream, historyOne, { sessionId: "chat-one" });
const messageTwo = await runTurn(stream, historyTwo, { sessionId: "chat-two" });
// B returns first while A is earlier in the registry: IDs and even full
// history must not let B resolve A's authenticated MCP request.
const replyTwo = await runTurn(stream, [...historyTwo, messageTwo, toToolResult(call, "from two")], { sessionId: "chat-two" });
const replyOne = await runTurn(stream, [...historyOne, messageOne, toToolResult(call, "from one")], { sessionId: "chat-one" });
assert.equal(getText(replyTwo), "from two");
assert.equal(getText(replyOne), "from one");
assert.equal(spawns, 2);
await assertCleanedUp(getOnlyCall(one.calls));
await assertCleanedUp(getOnlyCall(two.calls));
});
test("a caller without a session ID uses fresh replay and retains no held process", async (t) => {
const call: ToolUse = { id: "toolu_no_scope", name: "read", arguments: { path: "a" } };
const waiting = createFakeRun(t, [...toToolUseMessage([call]), callTool(call), REPLY]);
const stdinDir = mkdtempSync(join(tmpdir(), "claude-unscoped-replay-"));
t.after(() => rmSync(stdinDir, { recursive: true, force: true }));
const stdinFile = join(stdinDir, "stdin.jsonl");
const fresh = createFakeRun(t, readFixtureLines("text.jsonl"), { FAKE_CLAUDE_STDIN: stdinFile });
const stream = createClaudeCodeStreamFunction((args, options) => (waiting.calls.length === 0 ? waiting : fresh).runClaude(args, options));
const message = await runTurn(stream, [HI], { sessionId: undefined });
await assertCleanedUp(getOnlyCall(waiting.calls));
const reply = await runTurn(stream, [HI, message, toToolResult(call, "the preserved answer")], { sessionId: undefined });
assert.equal(reply.stopReason, "stop");
assert.equal(fresh.calls.length, 1);
assert.match(readFileSync(stdinFile, "utf8"), /the preserved answer/);
await assertCleanedUp(getOnlyCall(fresh.calls));
});
for (const sharedIdentity of [false, true]) test(`two concurrent stream owners keep separate processes${sharedIdentity ? " even with identical call IDs and arguments" : ""}`, async (t) => {
const readOne: ToolUse = { id: "toolu_concurrent_one", name: "read", arguments: { path: "one" } };
const readTwo: ToolUse = sharedIdentity ? { ...readOne } : { id: "toolu_concurrent_two", name: "read", arguments: { path: "two" } };