import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
type StartedServer = { url: string; server: Server; shutdown?: () => Promise<void> | void };
type RunStatus = { id: string; status: string; exitCode: number | null };
describe('claude sub-agent turn_end false success', () => {
const savedEnv: Record<string, string | undefined> = {};
for (const k of ['POSTHOG_KEY','POSTHOG_HOST','LANGFUSE_PUBLIC_KEY','LANGFUSE_SECRET_KEY','LANGFUSE_BASE_URL','OPEN_DESIGN_TELEMETRY_RELAY_URL']) savedEnv[k] = process.env[k];
let started: StartedServer | null = null;
let binDir: string | null = null;
afterEach(async () => {
await Promise.resolve(started?.shutdown?.());
if (started?.server) await new Promise<void>((r) => started?.server.close(() => r()));
started = null;
if (binDir) await rm(binDir, { recursive: true, force: true });
binDir = null;
for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
});
it('a sub-agent end_turn must not classify a crashed main turn as succeeded', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-sidechain-claude-bin-'));
const controlBin = await writeCrashClaude(binDir, 'claude-control', { withSubagent: false });
const sidechainBin = await writeCrashClaude(binDir, 'claude-sidechain', { withSubagent: true });
for (const k of Object.keys(savedEnv)) delete process.env[k];
started = await startServer({ port: 0, returnServer: true }) as StartedServer;
await putConfig(started.url, { agentId: 'claude', agentCliEnv: { claude: { CLAUDE_BIN: controlBin } }, telemetry: { metrics: true, content: false, artifactManifest: false }, privacyDecisionAt: Date.now() });
const controlRun = await createAndWaitForRun(started.url);
expect(controlRun.exitCode).toBe(1);
expect(controlRun.status).toBe('failed'); // baseline: crash without sub-agent frame is a failure
await putConfig(started.url, { agentId: 'claude', agentCliEnv: { claude: { CLAUDE_BIN: sidechainBin } }, telemetry: { metrics: true, content: false, artifactManifest: false }, privacyDecisionAt: Date.now() });
const sidechainRun = await createAndWaitForRun(started.url);
expect(sidechainRun.exitCode).toBe(1);
// INVARIANT: a sub-agent (parent_tool_use_id != null) turn boundary must not vouch for the main turn.
expect(sidechainRun.status).toBe('failed');
});
});
async function writeCrashClaude(dir: string, name: string, opts: { withSubagent: boolean }): Promise<string> {
const bin = path.join(dir, name);
const subagentFrame = opts.withSubagent
? `w(JSON.stringify({ type: 'assistant', parent_tool_use_id: 'tu_task', message: { id: 'msg-sub', content: [{ type: 'text', text: 'sub-agent internal scratch' }], stop_reason: 'end_turn' } }) + '\\n');`
: '';
await writeFile(bin, `#!/usr/bin/env node
const fs = require('node:fs');
function w(s) { fs.writeSync(1, s); }
if (process.argv.includes('--version')) { w('claude-code 1.0.0-sidechain-test\\n'); process.exit(0); }
if (process.argv.includes('--help')) { w('Usage: claude -p [--include-partial-messages] [--add-dir DIR]\\n'); process.exit(0); }
w(JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-sidechain-test', session_id: 's-sidechain' }) + '\\n');
w(JSON.stringify({ type: 'assistant', parent_tool_use_id: null, message: { id: 'msg-main', content: [{ type: 'tool_use', id: 'tu_task', name: 'Task', input: { prompt: 'do work' } }], stop_reason: 'tool_use' } }) + '\\n');
${subagentFrame}
setTimeout(() => process.exit(1), 30); // main agent dies mid-synthesis: non-zero, no result frame
`, 'utf8');
await chmod(bin, 0o755);
return bin;
}
async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
const r = await fetch(`${url}/api/app-config`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) });
expect(r.status).toBe(200);
}
async function createAndWaitForRun(url: string): Promise<RunStatus> {
const projectId = `sidechain_claude_${randomUUID()}`;
const p = await fetch(`${url}/api/projects`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: projectId, name: 'Sidechain repro', metadata: { kind: 'prototype' }, skipDiscoveryBrief: true }) });
expect(p.status).toBe(200);
const { conversationId } = await p.json() as { conversationId: string };
const r = await fetch(`${url}/api/runs`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-od-analytics-device-id': 'd', 'x-od-analytics-session-id': 's', 'x-od-analytics-client-type': 'web' }, body: JSON.stringify({ projectId, conversationId, assistantMessageId: `a_${randomUUID()}`, clientRequestId: `c_${randomUUID()}`, agentId: 'claude', message: 'repro', currentPrompt: 'repro' }) });
expect(r.status).toBe(202);
const { runId } = await r.json() as { runId: string };
const start = Date.now();
while (Date.now() - start < 10_000) {
const s = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
const run = await s.json() as RunStatus;
if (['failed', 'succeeded', 'canceled'].includes(run.status)) return run;
await new Promise((res) => setTimeout(res, 100));
}
throw new Error(`run ${runId} did not finish`);
}
Summary
The Claude stream-json parser treats a sub-agent (Task) assistant frame exactly like a main-turn frame.
apps/daemon/src/runtimes/claude-stream.ts:406-456readsmessage.stop_reasonoff ANYtype:'assistant'wrapper and, when it is not'tool_use', emitsturn_end— it never inspects the top-levelparent_tool_use_id(claude-code sets this on every message a sub-agent emits; main-turn messages carryparent_tool_use_id: null). Claude is spawned with--verbose(apps/daemon/src/runtimes/defs/claude.ts:59), so sub-agent frames appear inline in the stream.Consequence: when a Task sub-agent finishes its own internal turn with
stop_reason: 'end_turn',applyClaudeStreamJsonRunBookkeeping(apps/daemon/src/runtimes/chat-run-lifecycle.ts:98-118) setsrun.turnCompletedCleanly = trueand closes stdin while the MAIN turn is still running. If the main agent then terminates non-zero WITHOUT a result frame (hard crash / OOM kill / connection-drop abort mid-synthesis), the close handler findsturnCompletedCleanly === trueandclassifyChatRunCloseStatus(chat-run-lifecycle.ts:70) translates the non-zero exit intosucceeded— a silent false success that hides the failure, skips the same-run retry, and surfaces the sub-agent's internal scratch as the "answer".User-visible symptom
A user asks something that makes Claude spawn a Task sub-agent. The sub-agent finishes; the main agent then crashes while synthesizing the final answer (dropped/killed/OOM — non-zero exit, no result frame). This should be reported as a retryable failure; instead the run is marked
succeededand the sub-agent's internal draft is delivered as the answer.Root cause (file:line)
apps/daemon/src/runtimes/claude-stream.ts:450—if (stopReason)emitsturn_endwithout checkingobj.parent_tool_use_id, and the emitted event drops that field entirely.apps/daemon/src/runtimes/chat-run-lifecycle.ts:98-118— a non-tool_useturn_endsetsturnCompletedCleanly = true+ closes stdin.apps/daemon/src/runtimes/chat-run-lifecycle.ts:70—if (turnCompletedCleanly) return 'succeeded'translates the non-zero exit into success.parent_tool_use_idappears nowhere in the daemon source.Reproduction (live — real daemon + production HTTP API)
Two fake
claudebins whose ONLY difference is the presence of a sub-agentend_turnframe; bothexit 1with no result frame. Driven entirely overPOST /api/projects,POST /api/runs,GET /api/runs/:id;CLAUDE_BINoverridden via app-config; frames written withfs.writeSync; no source backdoor.turnCompletedCleanlystays false →failed✓ (this control assertion passes first)end_turn): flag flips true →succeeded❌Red on clean
origin/main(expected 'succeeded' to be 'failed'); both source files are byte-identical toorigin/main. The prototype fix (claude-stream.ts:450+&& obj.parent_tool_use_id == null) turns it green, with no regression in the neighboring parser suites (structured-streams,claude-stream-thinking,retry-stale-turn-completed-flag).Red spec (drop into
apps/daemon/tests/)Proposed direction + open question
Invariant: a sub-agent's turn boundary must not vouch for the whole run. Two shapes:
claude-stream.ts:450+&& obj.parent_tool_use_id == nullso a sub-agent frame never emitsturn_end. One place aligns all three chained effects — setting the clean flag, closing stdin, AND resetting the per-turn artifact-echo dedup state (recentWriteContents/wroteHtmlFileThisTurn, which sits in the same block and equally should not fire for a sub-agent).parent_tool_use_idinto theturn_endevent and let the bookkeeping layer decide. Downside: the artifact-dedup reset lives in the parser right next to theturn_endemit, so (B) needs a second guard there or a sub-agent frame still wrongly resets main-turn state.I'd like a maintainer steer on A vs B (leaning A) before opening a PR.
Dedup
No collision. #4197 only handled the main turn's
tool_usenot closing stdin; #5340/#5341 (same-run retry inheriting attempt 1's clean flag) is a cross-attempt issue fixed viatearDownAttemptForRetryand does not go through this path; #3372 is a SessionEnd-hook non-zero exit, a different family. All adjacent, none duplicate.Severity + honest caveats
exit 0, and a crash carrying anis_errorresult, both still classify correctly.parent_tool_use_idand ending withend_turn— this matches the documented--verbose stream-jsonformat, but I reproduced it with a fake CLI emitting such a frame (proving the daemon parser mishandles it), not end-to-end against a real claude binary.