diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 92cac7cfe..626cc621d 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -22,8 +22,13 @@ // `freshellContext` names the pane the CLI's environment carries. // One sidecar hosts one session: a second create is refused. // { type:'configure', sessionId, requestId, settings } -// { type:'send', sessionId, text, images? } -// { type:'interrupt', sessionId } +// { type:'send', sessionId, uuid, text, images?, checkpointAttempt?:{logicalAttemptId, requestId} } +// — `uuid` is the id the server minted for this send (required): the +// SDKUserMessage carries it, so the CLI reports the command's lifecycle +// under it and writes the user line with it; a checkpoint attempt binds +// under the same uuid (checkpoint-identity.mjs) +// { type:'interrupt', sessionId, stopId } +// — the user's Stop; `stopId` is echoed on its receipt // { type:'permission.respond', sessionId, requestId, decision } // decision forwarded VERBATIM // { type:'question.respond', sessionId, requestId, answers } // { type:'shutdown' } @@ -39,7 +44,8 @@ // forwarded (its error result is the refusal's evidence, not a // turn), and the sidecar then exits with code 2. // { type:'sdk.configured', sessionId, requestId, ok, settings?, message? } -// { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools } // durable Claude UUID +// { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools, capabilities? } // durable Claude UUID; +// capabilities: the CLI's (msg_lifecycle_v1 says it reports every sent command's lifecycle) // { type:'sdk.assistant', sessionId, content, model, uuid, messageId, parentToolUseId } // — one per completed content block (blocks of one API message share // messageId); parentToolUseId is set inside a subagent, else null @@ -58,13 +64,6 @@ // that stops the turn; its quiet info/notice levels are not sent), // COMPACTION_FAILED (the CLI's reason); never a turn end // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } -// { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except -// the stopped turn's own aborted result (an accepted user interrupt's mark -// silences only that — turn-complete-gate.mjs); also minted from consumeStream's finally -// when a turn ends with NO result frame (the CLI died or the stream ended -// with a send still pending) — the abort/sdk.exit requested-teardown path -// stays silent -// { type:'sdk.turn.waiting', sessionId, at } // 0->>=1 pending edge (written by this sidecar; codex controls emits its own waiting edge outside the sidecar lane) // { type:'sdk.session.changed', sessionId, reason:'session-commands', commands } // — the command catalog without the terminal-only commands (the pinned // CLI's until a turn's init names its own): from the boot, then @@ -74,21 +73,37 @@ // local command output) to its transcript under `uuid`, about 100 ms // later; sent right after the message's own frames // (sdk-message-frames.mjs) -// { type:'sdk.status', sessionId, status } // compacting; running when a -// compaction ends while a send still waits for its result (interim, core -// N2); an ended stream sends no status: the server reports it as exited +// { type:'sdk.status', sessionId, status } // compacting or null; the +// server's turn ledger owns the effective status // { type:'sdk.session.metadata', sessionId, permissionMode } // the CLI changed its own // permission mode (an ExitPlanMode approval); the session adopts it first -// { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result -// (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop +// { type:'sdk.error', sessionId, message } // a request-level failure only // { type:'sdk.stream.ended', sessionId, reason, message } // the session's SDK stream ended // unrequested (reason cli_exited | stream_error | stream_eof, stream-end.mjs): // always the LAST line; the sidecar then exits with code 3 (G02) -// { type:'sdk.exit', sessionId } // a requested teardown (abort) // { type:'sdk.permission.request', sessionId, requestId, subtype:'can_use_tool', tool:{name,input}, toolUseID, suggestions, blockedPath, decisionReason } // { type:'sdk.question.request', sessionId, requestId, questions } // { type:'sdk.permission.cancelled', sessionId, requestId } // { type:'sdk.question.cancelled', sessionId, requestId } +// { type:'sdk.interrupt_settled', sessionId, stopId, ok, stillQueued?, message? } +// — the interrupt() receipt, echoing the Stop's id: `stillQueued` lists the +// sent commands the CLI says will still run (T12, T13; absent when the CLI +// sends no receipt); ok:false when the session is not hosted here or the +// CLI refused +// { type:'sdk.send.rejected', sessionId, uuid, code:'INVALID_IMAGE'|'INVALID_SEND'|'SESSION_NOT_FOUND', message } +// — the send never reached the CLI (a bad image, no valid uuid, a session +// this sidecar does not host); the session lives on +// Lifecycle facts for the server's turn accounting (lifecycle-frames.mjs, never client content): +// { type:'sdk.command', sessionId, uuid, state } // a sent command's lifecycle (T8): +// queued | started | completed | cancelled | discarded, or replayed (its first replay) +// { type:'sdk.session.state', sessionId, state } // running | idle | requires_action (T9; +// the sidecar sets CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 for the CLI) +// { type:'sdk.status', sessionId, status:null, compactResult?, compactError?, permissionMode? } +// — a status ended (T15); { status:'requesting' } is a model request +// { type:'sdk.heartbeat', sessionId, kind:'tool'|'task'|'retry', toolUseId?, elapsedMs? } +// — liveness only (T17), at most one per kind every 5 s +// `sdk.result` also carries subtype, isError, terminalReason?, numTurns? and errorText? (the CLI's own +// error text without diagnostics), the facts the server's result verdict reads. // // sidecar → Rust (stderr): log records only, one JSON object per line // ({ severity, component, event, ...ids }, log.mjs). Rust re-emits each @@ -99,12 +114,10 @@ // envelope — so this sidecar is the faithful analog of server/sdk-bridge.ts's // SdkBridge (it emits the SAME `sdk.*` shapes SdkBridge broadcasts). // -// A sidecar death mid-turn can therefore NEVER produce a false completion -// from this process's own stdout: `sdk.turn.complete` rides every SDK -// `result` (the unified needs-attention edge — only the stopped turn's own -// aborted result is silenced, by the accepted-interrupt mark) plus the -// consumeStream finally mint for a result-less turn end (the CLI died or the -// stream ended with a send pending; never the aborted/sdk.exit teardown). +// This sidecar makes no turn or attention decision (design §3.1, C18): it +// forwards facts, and the server owns busy, the user's Stop, and every +// attention edge (`freshAgent.turn.complete`, `freshAgent.turn.waiting`) and +// failure banner, minted from those facts. Nothing here mints an edge. // // Process lifetime: one sidecar hosts one session. A boot the CLI refused ends // the sidecar with `create.failed` and exit code 2. When the session's SDK @@ -112,7 +125,7 @@ // and exits with code 3, so the Rust end-of-output path ends the session. A // requested teardown (shutdown, SIGTERM, stdin closing) exits 0 and reports // nothing. If the PROCESS itself dies mid-turn, stdout simply ends with no -// `sdk.stream.ended` — the one end the Rust side rings for itself. +// `sdk.stream.ended`; the server's end-of-output path handles both ends. // // The Freshell MCP server (G19): the create line names the recipe, the hosted // one or the ordinary one the Rust server resolved while @@ -132,9 +145,7 @@ import { createInterface } from 'node:readline' import { randomBytes } from 'node:crypto' import { configureSession, userMessageContent } from './session-settings.mjs' import { createFrameState, frameLogRecord, framesForSdkMessage, resultFrames } from './sdk-message-frames.mjs' -import { createTurnCompleteGate } from './turn-complete-gate.mjs' -import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' -import { nextMonotonic } from './monotonic-clock.mjs' +import { checkpointAttemptOf, isUuid, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { managedSdkSpawn } from './exec-profile.mjs' import { mcpServersFor, withFreshellContext } from './mcp-servers.mjs' import { cliStderrLogger, errorText, log } from './log.mjs' @@ -142,6 +153,7 @@ import { isHostedSidecar } from './hosted.mjs' import { STREAM_ENDED_EXIT_CODE, streamEnd } from './stream-end.mjs' import { BOOT_FAILED_EXIT_CODE, bootOptions, classifyBootError, SIDECAR_PROTOCOL } from './boot.mjs' import { usableCommands } from './terminal-commands.mjs' +import { createLifecycleState, lifecycleFrames, rememberSend, stillQueuedOf } from './lifecycle-frames.mjs' import { createClaudeSdkCleanEnv } from './sdk-env.mjs' import { canUseTool as routeCanUseTool, @@ -188,41 +200,19 @@ function nanoid(size = 21) { } // ── streaming input stream (server/sdk-bridge.ts:274-316) ──────────────────── -// `onHandoff(isCompact)` fires on EVERY permanent handoff into the SDK -// consumer — BOTH the same-tick push-into-a-waiting-consumer path and the -// later `next()` queue-shift path (ep4-r4: the latter equally crosses the -// un-cancellable boundary; an absorbed compact pulled mid-window must arm the -// busy verdict just as the immediate handoff does). -function createInputStream(onHandoff) { - // Items are { msg, isCompact } — the wrapper lets rollback's quiesce drain - // never-yet-handed compacts (kata 1wxv ep4-r3: the SDK's queued-input - // surface cannot cancel UUID-less items, so cancellation authority lives at - // THIS queue, before handoff). +function createInputStream() { const queue = [] let waiting = null let done = false const handle = { - push: (msg, isCompact = false) => { + push: (msg) => { if (waiting) { const r = waiting; waiting = null; r({ value: msg, done: false }) - // A push into an AWAITING SDK consumer hands the item over in this - // same tick — un-cancellable from here (the SDK has it). - if (isCompact) onHandoff(true) } else { - queue.push({ msg, isCompact }) + queue.push(msg) } }, - // kata 1wxv ep4-r1 F1 → rollback quiesce: every queued compact is - // provably never handed to the SDK (the handoffs above/below are the only - // pulls) — dropping them here cancels them permanently. - drainCompacts: () => { - let cancelled = 0 - for (let i = queue.length - 1; i >= 0; i--) { - if (queue[i].isCompact) { queue.splice(i, 1); cancelled += 1 } - } - return cancelled - }, end: () => { done = true if (waiting) { const r = waiting; waiting = null; r({ value: undefined, done: true }) } @@ -232,13 +222,7 @@ function createInputStream(onHandoff) { [Symbol.asyncIterator]() { return { next() { - if (queue.length > 0) { - const item = queue.shift() - // ep4-r4: a later pull of a QUEUED compact is the same - // un-cancellable handoff as the same-tick push — arm it too. - if (item.isCompact) onHandoff(true) - return Promise.resolve({ value: item.msg, done: false }) - } + if (queue.length > 0) return Promise.resolve({ value: queue.shift(), done: false }) if (done) return Promise.resolve({ value: undefined, done: true }) return new Promise((resolve) => { waiting = resolve }) }, @@ -248,11 +232,7 @@ function createInputStream(onHandoff) { return { iterable, handle } } -// ── per-session monotonic turn-complete/waiting clock ──────────────────────── -// nextMonotonic lives in monotonic-clock.mjs (shared with the e2e fake so the -// fixture clamps identically — ONE implementation of the clamp). - -/** @type {Map, pendingQuestions?:Map, turnCompleteGate?:{noteInterruptRequest:Function,noteInterruptSettled:Function,resultEmitsAttention:Function}, pendingResults?:number}>} */ +/** @type {Map, pendingQuestions?:Map}>} */ const sessions = new Map() function normalizeCommands(rows, strict = false) { @@ -300,27 +280,10 @@ function handleSdkMessage(sessionId, msg) { if (msg.type === 'result' && Array.isArray(msg.errors)) st.bootEvidence.resultErrors.push(...msg.errors) return } - - // ep4-r6 quiesce bookkeeping (fail-closed approximations; the discharge rule - // is tight per ep4-r5 F1): - // - a `result` closes whatever turn was open (`turnOpen`); - // - an assistant/system frame marks a TURN boundary (`turnOpen`); - // - ONLY a `compacting` status discharges `handedCompactLikely`: the handed - // compact's OWN evidence finally carrying the busy truth (an unrelated - // earlier result — the SDK drains the iterable independently — must never - // drop it, or a rollback probe between that result and the compact's - // status sees a false all-clear; the reviewer repro). - if (msg.type === 'result') { - st.turnOpen = false - } else if ( - msg.type === 'assistant' || - (msg.type === 'system' && msg.subtype === 'status') - ) { - st.turnOpen = true - if (msg.type === 'system' && msg.subtype === 'status' && msg.status === 'compacting') { - st.handedCompactLikely = false - } - } + // The lifecycle facts this message carries, for the server's turn + // accounting (lifecycle-frames.mjs): command lifecycles, session state, + // status ends, heartbeats. + for (const frame of lifecycleFrames(sessionId, msg, st.lifecycle)) emit(frame) switch (msg.type) { case 'user': { @@ -343,6 +306,8 @@ function handleSdkMessage(sessionId, msg) { model: msg.model, cwd: msg.cwd, tools: Array.isArray(msg.tools) ? msg.tools.map((t) => ({ name: t })) : undefined, + // The server's turn ledger reads msg_lifecycle_v1 from them (T8). + ...(Array.isArray(msg.capabilities) ? { capabilities: msg.capabilities.filter((cap) => typeof cap === 'string') } : {}), }) publishCommandsIfChanged(sessionId, st) } else if (msg.subtype === 'commands_changed') { @@ -355,21 +320,9 @@ function handleSdkMessage(sessionId, msg) { break } case 'result': { - // The awaited turn's terminal frame arrived: drop one pending send - // (clamped at 0 — an unsolicited result must not underflow the counter). - st.pendingResults = Math.max(0, st.pendingResults - 1) - // The gate's verdict comes first: the stopped turn's own (aborted) - // result is the user's Stop, so it carries neither a bell nor a failure - // banner (G03). Any other turn end rings, including one a Stop missed - // (G06); a failed one also reports why. No outcome rides the edge — the - // client treats all ends identically. - const rings = st.turnCompleteGate.resultEmitsAttention(msg) - for (const frame of resultFrames(sessionId, msg, { userStopped: !rings })) emit(frame) - if (rings) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId, at }) - } + // The facts the server's verdict reads (sdk-message-frames.mjs); the + // server decides whether the turn rings and whether it failed. + for (const frame of resultFrames(sessionId, msg)) emit(frame) break } default: @@ -381,7 +334,6 @@ function handleSdkMessage(sessionId, msg) { const frames = framesForSdkMessage(sessionId, msg, { stream: st.frameState, permissionMode: st.settings.permissionMode, - turnPending: st.pendingResults > 0, }) for (const frame of frames) { // The CLI changed its own permission mode (an ExitPlanMode approval): the @@ -422,21 +374,7 @@ async function consumeStream(sessionId, sdkQuery) { // chain → unhandled rejection → crash under Node 22 throw-mode). if (st) cancelPending(st, emit, sessionId, { resolveDeny: false }) sessions.delete(sessionId) - if (requested) { - emit({ type: 'sdk.exit', sessionId }) - } else { - // Interim, until Rust mints every edge (C18): a turn that ends with NO - // result frame — the CLI died, or the stream ended, with a result still - // pending — is a turn end the user didn't witness, so it rings here. - // pendingResults covers the accept→first-message window and counts - // queued sends. A pending Stop mark does not silence it: only an aborted - // result is the stopped turn's own (turn-complete-gate.mjs). Rust rings - // only for a death that reports no stream end, so this edge is the one. - if (st && st.pendingResults > 0) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId, at }) - } + if (!requested) { // The end of the stream is the end of this sidecar (G02): report how // the stream ended, last, and exit, so the server's end-of-output path // ends the session instead of keeping a sidecar with no session. @@ -503,11 +441,6 @@ function handleCreate(req) { const sessionId = nanoid() const abort = new AbortController() - // ep4-r3/ep4-r4 quiesce state (declared first so the stream can arm it): - // turnOpen — an SDK turn is mid-flight (cleared at its result); - // handedCompactLikely — a compact crossed the un-cancellable SDK handoff - // (same-tick push OR queued pull — either path); cleared at the next - // result or observed status frame (its evidence has by then reached Rust). const state = { abort, permissionMode: req.permissionMode, @@ -516,22 +449,13 @@ function handleCreate(req) { // bootEvidence collects what a refusal is named from (boot.mjs). booted: false, bootEvidence: { resultErrors: [], stderr: '', streamEnded: undefined }, - turnOpen: false, - handedCompactLikely: false, - // Unified attention bookkeeping: the gate suppresses exactly the - // interrupted turn's own result (ordering-scoped, NO reset-at-send — - // see turn-complete-gate.mjs); pendingResults counts ACCEPTED sends - // still awaiting their terminal result frame, so consumeStream's - // finally can mint the edge for a result-less turn end. - turnCompleteGate: createTurnCompleteGate(), - pendingResults: 0, + // The commands this session sent and its heartbeat allowance (lifecycle-frames.mjs). + lifecycle: createLifecycleState(), pendingCheckpointAttempts: new Map(), // The content mapping's own per-session state (sdk-message-frames.mjs). frameState: createFrameState(), } - const { iterable, handle } = createInputStream((isCompact) => { - if (isCompact) state.handedCompactLikely = true - }) + const { iterable, handle } = createInputStream() state.inputStream = handle // Liveness IS session-map membership: consumeStream's finally removes the // session synchronously after cancelPending (LB-04 — a parked canUseTool @@ -571,7 +495,9 @@ function handleCreate(req) { // sidecar, so checkpoint identity must remain unbound. extraArgs: { 'replay-user-messages': null }, abortController: abort, - env: withFreshellContext(createClaudeSdkCleanEnv(process.env), req.freshellContext), + // The CLI reports when the session starts and stops working (T9): + // the server's turn accounting reads it (sdk.session.state). + env: withFreshellContext({ ...createClaudeSdkCleanEnv(process.env), CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' }, req.freshellContext), settingSources: ['user', 'project', 'local'], spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr), stderr: sdkStderr, @@ -581,7 +507,7 @@ function handleCreate(req) { // AskUserQuestion routes first (even under bypassPermissions), then the // bypass fast-path, else park-and-surface a pending request — legacy // ordering, sdk-bridge.ts:203-214. - return routeCanUseTool({ session: s, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) + return routeCanUseTool({ session: s, emit, nanoid, sessionId, toolName, input, options }) }, }, }) @@ -647,34 +573,50 @@ async function bootSession({ req, sessionId, state, sdkQuery, streamDone }) { } } +/** Why a malformed send is refused (the server mints a uuid for every send, and sends text). */ +const SEND_WITHOUT_UUID = 'This message has no valid message id, so it was not given to Claude.' +const SEND_WITHOUT_TEXT = 'This message has no text, so it was not given to Claude.' +const SESSION_NOT_HOSTED = 'Claude session is no longer available.' + +/** + * Refuse a send before it reaches the CLI. Nothing was queued, so the session + * goes on as it was; the server takes the send back (sdk.send.rejected). + */ +function rejectSend(req, code, message) { + const uuid = req.uuid ?? null + log('warn', 'send.rejected', { sessionId: req.sessionId, uuid, code, error: message }) + emit({ type: 'sdk.send.rejected', sessionId: req.sessionId, uuid, code, message }) +} + function handleSend(req) { const st = sessions.get(req.sessionId) - // ep3-r2 F2: the signed frame — when the JS session is gone but stdout stays - // open (consumeStream deleted it), NO terminal edge or EOF follows; the Rust - // busy tracker must fold this specific failure as provider-session death. - if (!st) { emit({ type: 'sdk.error', sessionId: req.sessionId, message: 'session not found', sessionNotFound: true }); return } - // ep4-r3: /compact is the only dispatch rollback ever absorbs; mark it so - // the quiesce handler can drain never-handed compacts from the input queue - // and so a same-tick handoff arms the BUSY-forcing handed flag. - const isCompact = /^\s*\/compact(\s|$)/.test(String(req.text ?? '')) - const checkpointAttempt = checkpointAttemptOf(req.checkpointAttempt) + // Not a session this sidecar hosts: refused like any send the CLI never + // saw, so the server takes it back. + if (!st) { rejectSend(req, 'SESSION_NOT_FOUND', SESSION_NOT_HOSTED); return } + // Every send carries the uuid the server minted for it. The SDKUserMessage + // carries it, so the CLI reports the command's lifecycle under it (T8) and + // writes the user line with it: it is the turn id the server announced. + if (!isUuid(req.uuid)) { rejectSend(req, 'INVALID_SEND', SEND_WITHOUT_UUID); return } + if (typeof req.text !== 'string') { rejectSend(req, 'INVALID_SEND', SEND_WITHOUT_TEXT); return } + let content + try { + content = userMessageContent(req.text, req.images) + } catch (error) { + rejectSend(req, error?.code ?? 'INVALID_SEND', errorText(error)) + return + } + // A checkpoint attempt binds under the send's own uuid. + const checkpointAttempt = checkpointAttemptOf(req.checkpointAttempt && { ...req.checkpointAttempt, uuid: req.uuid }) const userMessage = { - type: 'user', - message: { role: 'user', content: userMessageContent(req.text, req.images) }, - parent_tool_use_id: null, - session_id: st.cliSessionId || 'default', - ...(checkpointAttempt ? { uuid: checkpointAttempt.uuid } : {}), - } + type: 'user', + message: { role: 'user', content }, + parent_tool_use_id: null, + session_id: st.cliSessionId || 'default', + uuid: req.uuid, + } if (checkpointAttempt) rememberCheckpointAttempt(st.pendingCheckpointAttempts, checkpointAttempt) - st.inputStream.push(userMessage, isCompact) - // Unified attention bookkeeping: this send is now ACCEPTED (the input-stream - // push succeeded) and awaits exactly one terminal frame — a COUNTER, not a - // boolean: two queued sends leave it at 2 and the first result must drop it - // to 1, not 0 (a boolean would lose the second queued turn and its - // stream-exception ring). Deliberately NOT turnOpen, which only arms after - // the first assistant/system frame and cannot see the accept→first-message - // window. The gate is NOT reset here — see turn-complete-gate.mjs. - st.pendingResults += 1 + rememberSend(st.lifecycle, req.uuid) + st.inputStream.push(userMessage) } async function handleConfigure(req) { @@ -684,9 +626,9 @@ async function handleConfigure(req) { return } try { - const settings = await configureSession(st, req.settings ?? {}, { - busy: req.busy === true || st.turnOpen || st.handedCompactLikely, - }) + // The server refuses a settings change while the session is busy (its + // turn ledger); a request that says it is busy is refused here too. + const settings = await configureSession(st, req.settings ?? {}, { busy: req.busy === true }) emit({ type: 'sdk.configured', sessionId: req.sessionId, requestId: req.requestId, ok: true, settings }) } catch (err) { log('warn', 'configure.failed', { sessionId: req.sessionId, requestId: req.requestId, error: errorText(err) }) @@ -694,76 +636,32 @@ async function handleConfigure(req) { } } -// kata 1wxv ep4 (rollback quiesce probe): rollback pre-teardown sends this -// request instead of a bare interrupt — the answer is stream-ordered AFTER -// every already-emitted frame and carries this sidecar's OWN queue truth: -// - cancelledQueue: compact items DROPPED from the still-unhanded input -// queue (they provably never start — the SDK owns no reference to them); -// - inFlightTurn / handedCompactLikely: evidence that provider work already -// crossed the un-cancellable handoff — rollback must refuse (BUSY). -// The probeId echos for correlation ONLY: a quiesced frame fires exclusively -// the rollback probe that registered it (stale/mismatched receipts never -// close a live probe). -function handleRollbackQuiesce(req) { - const st = sessions.get(req.sessionId) - if (!st) { emit({ type: 'sdk.error', sessionId: req.sessionId, message: 'session not found', sessionNotFound: true }); return } - const cancelledQueue = st.inputStream.drainCompacts() - emit({ - type: 'sdk.rollback.quiesced', - sessionId: req.sessionId, - probeId: req.probeId ?? null, - cancelledQueue, - inFlightTurn: st.turnOpen === true, - handedCompactLikely: st.handedCompactLikely === true, - }) -} - -// Faithful port of `server/sdk-bridge.ts:785-793`'s `interrupt(sessionId)`: -// `sp.query.interrupt().catch((err) => log.warn(...))` -- fire-and-forget, no reply on -// success (the Rust side mirrors this: no confirmation frame is broadcast either). +// The user's Stop (design §5.4). The server decides when to send it (never for +// an idle session) and whether the stopped turn rings; the sidecar calls the +// SDK's interrupt() and reports its receipt, echoing the Stop's id so the +// server reads only its own receipt. function handleInterrupt(req) { const st = sessions.get(req.sessionId) - if (!st) { emit({ type: 'sdk.error', sessionId: req.sessionId, message: 'session not found', sessionNotFound: true }); return } + const settled = (fields) => emit({ type: 'sdk.interrupt_settled', sessionId: req.sessionId, stopId: req.stopId ?? null, ...fields }) + if (!st) { settled({ ok: false, message: SESSION_NOT_HOSTED }); return } // The transport is still open here (interrupt only signals), so resolving the // parked requests with deny is safe — and required so the SDK's canUseTool // await settles instead of hanging the interrupted turn. cancelPending(st, emit, req.sessionId, { resolveDeny: true }) - // kata 1wxv focused ep4-r1 F1: a fire-and-forget interrupt() write proved - // NOTHING about completion — the gate's retirement at the request site once - // admitted rollback while the provider turn still ran (a delayed or REJECTED - // interrupt). The retirement evidence must be the SETTLED outcome: await the - // SDK call and emit a signed settle event in either case (rejection = the - // turn provably still running → the gate stays closed). if (!st.query?.interrupt) { - emit({ type: 'sdk.interrupt_settled', sessionId: req.sessionId, ok: false, message: 'no in-flight SDK query' }) + settled({ ok: false, message: 'no in-flight SDK query' }) return } - // Unified attention gate: arm the mark BEFORE the interrupt call — and ONLY - // while a turn is plausibly awaiting a terminal frame (pendingResults > 0). - // The SDK contract documents RESOLUTION — not rejection — for interrupting - // with NOTHING in flight (sdk.d.ts:2384-2394: "Interrupt the current query - // execution ... Older CLIs resolve to `undefined`"), so an idle-session - // interrupt (a late Stop click racing the busy-clear, or a double-click's - // second Stop) must arm NOTHING: a stray mark there would survive and eat - // the NEXT unrelated turn's result, silently suppressing exactly the ring - // this feature exists to produce. While a turn IS pending, the settle - // receipt (sdk.d.ts:3765) lands BEFORE the interrupted turn's own result, - // so the mark is consumed by exactly that result, and silences it only if - // the Stop landed (the result was aborted). A Stop that missed — the CLI was - // still starting (T13), or the turn finished first — leaves a result that - // rings. A REJECTED interrupt (the turn kept running) clears the mark; - // noteInterruptSettled stays in both promise arms — settle(true) without a - // mark is a no-op. - if (st.pendingResults > 0) st.turnCompleteGate.noteInterruptRequest() st.query.interrupt() - .then(() => { - st.turnCompleteGate.noteInterruptSettled(true) - emit({ type: 'sdk.interrupt_settled', sessionId: req.sessionId, ok: true }) + .then((receipt) => { + // The receipt names the sent commands that survive the Stop and will + // still run (T12; a cold Stop may list the command it missed, T13). + const stillQueued = stillQueuedOf(receipt, st.lifecycle) + settled({ ok: true, ...(stillQueued ? { stillQueued } : {}) }) }) .catch((err) => { - st.turnCompleteGate.noteInterruptSettled(false) - log('warn', 'interrupt.failed', { sessionId: req.sessionId, error: errorText(err) }) - emit({ type: 'sdk.interrupt_settled', sessionId: req.sessionId, ok: false, message: String(err?.message || err) }) + log('warn', 'interrupt.failed', { sessionId: req.sessionId, stopId: req.stopId ?? null, error: errorText(err) }) + settled({ ok: false, message: String(err?.message || err) }) }) } @@ -797,7 +695,6 @@ rl.on('line', (line) => { break } case 'interrupt': handleInterrupt(req); break - case 'rollback.quiesce': handleRollbackQuiesce(req); break case 'permission.respond': { const st = sessions.get(req.sessionId) if (!st) break diff --git a/crates/freshell-claude-sidecar/lifecycle-frames.mjs b/crates/freshell-claude-sidecar/lifecycle-frames.mjs new file mode 100644 index 000000000..d420031ec --- /dev/null +++ b/crates/freshell-claude-sidecar/lifecycle-frames.mjs @@ -0,0 +1,139 @@ +// The pure mapping from the CLI's lifecycle messages to the sidecar's fact +// frames (docs/plans/2026-10-22-freshclaude-core-runtime.md §5.1, C16). The +// server folds these facts into its turn accounting; none of them is content +// for the client. The core runtime owns these kinds (§9.1 N6): per-command +// lifecycle and replays, session state, the end of a status, and liveness +// heartbeats. Content (assistant, stream events, transcript announcements, +// notices) is sdk-message-frames.mjs's. +// +// Facts are forwarded only for commands this sidecar sent: the CLI also +// queues and replays uuids it minted itself (setModel's /model command, cron +// triggers, auto-resume continuations, T8), which the server never knew. +// +// sdk.command {sessionId, uuid, state: queued|started|completed|cancelled|discarded|replayed} +// — the command's lifecycle (T8); `replayed` is its first replay +// sdk.session.state {sessionId, state: running|idle|requires_action} (T9) +// sdk.status {sessionId, status: null, compactResult?, compactError?, permissionMode?} +// — a status ended (T15); {status: 'requesting'} is a model request +// sdk.heartbeat {sessionId, kind: tool|task|retry, toolUseId?, elapsedMs?} +// — liveness only, at most one per kind every HEARTBEAT_INTERVAL_MS (T17) + +/** At most one heartbeat of each kind is forwarded in this many milliseconds. */ +export const HEARTBEAT_INTERVAL_MS = 5000 + +const COMMAND_STATES = new Set(['queued', 'started', 'completed', 'cancelled', 'discarded']) +const TERMINAL_COMMAND_STATES = new Set(['completed', 'cancelled', 'discarded']) +const SESSION_STATES = new Set(['running', 'idle', 'requires_action']) +const TASK_SUBTYPES = new Set(['task_started', 'task_progress', 'task_updated', 'task_notification']) + +/** Leading-edge rate limit per heartbeat kind: the first beat passes, then one per interval. */ +export function createHeartbeatLimiter(intervalMs = HEARTBEAT_INTERVAL_MS) { + const lastAdmitted = new Map() + return { + admit(kind, now) { + const last = lastAdmitted.get(kind) + if (last !== undefined && now - last < intervalMs) return false + lastAdmitted.set(kind, now) + return true + }, + } +} + +/** One session's lifecycle state: its outstanding sent commands and heartbeat allowance. */ +export function createLifecycleState() { + return { sent: new Map(), heartbeats: createHeartbeatLimiter() } +} + +/** The sidecar is about to send this command to the CLI; keep it until terminal lifecycle. */ +export function rememberSend(state, uuid) { + state.sent.delete(uuid) + state.sent.set(uuid, { replayed: false }) +} + +/** The facts one SDK message carries (none for any other kind). `now` is in milliseconds. */ +export function lifecycleFrames(sessionId, msg, state, now = Date.now()) { + switch (msg?.type) { + case 'command_lifecycle': return commandFrames(sessionId, msg, state) + case 'user': return replayFrames(sessionId, msg, state) + case 'tool_progress': return heartbeatFrames(sessionId, state, now, toolHeartbeat(msg)) + case 'system': return systemFrames(sessionId, msg, state, now) + default: return [] + } +} + +/** + * The sent commands an interrupt receipt says will still run (T12, T13), or + * undefined when the CLI sent no receipt (older CLIs resolve undefined). + */ +export function stillQueuedOf(receipt, state) { + if (!Array.isArray(receipt?.still_queued)) return undefined + return receipt.still_queued.filter((uuid) => state.sent.has(uuid)) +} + +function commandFrames(sessionId, msg, state) { + if (!state.sent.has(msg.command_uuid) || !COMMAND_STATES.has(msg.state)) return [] + const frame = { type: 'sdk.command', sessionId, uuid: msg.command_uuid, state: msg.state } + // Only outstanding commands are needed to recognize future lifecycle and + // interrupt-receipt facts. Retire terminal ids here so long-lived sessions + // do not accumulate completed history, while an arbitrarily large queue + // remains fully attributable to the server's turn ledger. + if (TERMINAL_COMMAND_STATES.has(msg.state)) state.sent.delete(msg.command_uuid) + return [frame] +} + +/** The first replay of a sent command: the CLI took it as a user message (T8). */ +function replayFrames(sessionId, msg, state) { + if (msg.isReplay !== true) return [] + const sent = state.sent.get(msg.uuid) + if (!sent || sent.replayed) return [] + sent.replayed = true + return [{ type: 'sdk.command', sessionId, uuid: msg.uuid, state: 'replayed' }] +} + +function systemFrames(sessionId, msg, state, now) { + if (msg.subtype === 'session_state_changed') { + return SESSION_STATES.has(msg.state) ? [{ type: 'sdk.session.state', sessionId, state: msg.state }] : [] + } + if (msg.subtype === 'status') return statusFrames(sessionId, msg) + if (msg.subtype === 'api_retry') return heartbeatFrames(sessionId, state, now, { kind: 'retry' }) + if (TASK_SUBTYPES.has(msg.subtype)) { + return heartbeatFrames(sessionId, state, now, { kind: 'task', ...(typeof msg.tool_use_id === 'string' ? { toolUseId: msg.tool_use_id } : {}) }) + } + return [] +} + +/** + * A status ended (`null`: a compaction's outcome or a new permission mode, + * T15) or a model request started (`requesting`). `compacting` is content: + * the client shows it, and sdk-message-frames.mjs forwards it. + */ +function statusFrames(sessionId, msg) { + if (msg.status === 'requesting') return [{ type: 'sdk.status', sessionId, status: 'requesting' }] + if (msg.status !== null) return [] + return [{ + type: 'sdk.status', + sessionId, + status: null, + ...(msg.compact_result !== undefined ? { compactResult: msg.compact_result } : {}), + ...(msg.compact_error !== undefined ? { compactError: msg.compact_error } : {}), + ...(msg.permissionMode !== undefined ? { permissionMode: msg.permissionMode } : {}), + }] +} + +/** + * A running tool's progress. A heartbeat is stamped with an id of its own and + * names the tool it belongs to as its parent (T17). + */ +function toolHeartbeat(msg) { + const toolUseId = msg.heartbeat === true ? (msg.parent_tool_use_id ?? msg.tool_use_id) : msg.tool_use_id + return { + kind: 'tool', + ...(typeof toolUseId === 'string' ? { toolUseId } : {}), + ...(Number.isFinite(msg.elapsed_time_seconds) ? { elapsedMs: msg.elapsed_time_seconds * 1000 } : {}), + } +} + +function heartbeatFrames(sessionId, state, now, beat) { + if (!state.heartbeats.admit(beat.kind, now)) return [] + return [{ type: 'sdk.heartbeat', sessionId, ...beat }] +} diff --git a/crates/freshell-claude-sidecar/monotonic-clock.mjs b/crates/freshell-claude-sidecar/monotonic-clock.mjs deleted file mode 100644 index 38e3af2ed..000000000 --- a/crates/freshell-claude-sidecar/monotonic-clock.mjs +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Per-session monotonic edge clock (freshell-claude-sidecar). - * - * The client folds `sdk.turn.complete` / `sdk.turn.waiting` edges under an - * `at`-monotonic dedupe — an edge whose `at` is older-or-equal to the last - * one seen for its session is silently dropped - * (src/store/turnCompletionSlice.ts) — so every minted edge must be STRICTLY - * greater than the previous one for that session even when two edges land in - * the same millisecond or the wall clock stalls. This is the sidecar's - * analog of the client's `nextMonotonicTurnCompleteAt` - * (server/fresh-agent/turn-complete-clock.ts): ONE implementation of the - * clamp, shared by every mint site — index.mjs's result/finally turn-complete - * mints, the waiting edge injected into permission-channel.mjs, and the e2e - * fake sidecar (imported next to turn-complete-gate.mjs) so the fixture - * clamps identically to the real sidecar. - */ -export function nextMonotonic(last, now) { - return last != null && now <= last ? last + 1 : now -} diff --git a/crates/freshell-claude-sidecar/permission-channel.mjs b/crates/freshell-claude-sidecar/permission-channel.mjs index a43ed6547..da387650d 100644 --- a/crates/freshell-claude-sidecar/permission-channel.mjs +++ b/crates/freshell-claude-sidecar/permission-channel.mjs @@ -6,8 +6,9 @@ // respondPermission :771-783; pending map shapes sdk-bridge-types.ts:113-126). // // Pure-ish: state lives on the per-session record; every function takes -// explicit dependencies (emit / nanoid / nextMonotonic) so the module never -// imports index.mjs's sessions Map or module state. +// explicit dependencies (emit / nanoid) so the module never imports index.mjs's +// sessions Map or module state. The waiting edge (0 -> >=1 pending) is the +// server's: it folds the request frames and mints the edge itself (C18). // Lazily attach the two pending maps (sdk-bridge-types.ts:113-126 shapes). export function ensurePending(session) { @@ -16,15 +17,6 @@ export function ensurePending(session) { return session } -// Server-authoritative 0 -> >=1 pending edge (SdkBridge.emitWaitingEdge :515-519). -// Monotonic per session via the injected nextMonotonic, independent clock from -// the turn-complete edge. -function emitWaitingEdge({ session, emit, nextMonotonic, sessionId }) { - const at = nextMonotonic(session.lastWaitingAt, Date.now()) - session.lastWaitingAt = at - emit({ type: 'sdk.turn.waiting', sessionId, at }) -} - // Provider-originated cancellation (options.signal; fresh-eyes round-2 F1): on // abort the pending entry is deleted, the cancelled frame clears the card, and // the parked promise resolves deny — the query is still open in this state, so @@ -34,14 +26,13 @@ function subscribeSignal({ signal, clear }) { if (signal && !signal.aborted) signal.addEventListener('abort', clear, { once: true }) } -export function raisePermissionRequest({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) { +export function raisePermissionRequest({ session, emit, nanoid, sessionId, toolName, input, options }) { // Already-aborted provider: nothing to park, no card to raise — deny at once. if (options?.signal?.aborted) { return Promise.resolve({ behavior: 'deny', message: 'Aborted by provider' }) } ensurePending(session) const requestId = nanoid() - const wasIdle = session.pendingPermissions.size === 0 && session.pendingQuestions.size === 0 return new Promise((resolve) => { session.pendingPermissions.set(requestId, { toolName, @@ -71,11 +62,10 @@ export function raisePermissionRequest({ session, emit, nanoid, nextMonotonic, s blockedPath: options?.blockedPath, decisionReason: options?.decisionReason, }) - if (wasIdle) emitWaitingEdge({ session, emit, nextMonotonic, sessionId }) }) } -export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId, input, signal }) { +export function raiseQuestionRequest({ session, emit, nanoid, sessionId, input, signal }) { ensurePending(session) const rawQuestions = input?.questions if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { @@ -108,7 +98,6 @@ export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, ses return Promise.resolve({ behavior: 'deny', message: 'Aborted by provider' }) } const requestId = nanoid() - const wasIdle = session.pendingPermissions.size === 0 && session.pendingQuestions.size === 0 return new Promise((resolve) => { session.pendingQuestions.set(requestId, { originalInput: input, questions, resolve }) subscribeSignal({ @@ -120,7 +109,6 @@ export function raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, ses }, }) emit({ type: 'sdk.question.request', sessionId, requestId, questions }) - if (wasIdle) emitWaitingEdge({ session, emit, nextMonotonic, sessionId }) }) } @@ -174,12 +162,12 @@ export function cancelPending(session, emit, sessionId, { resolveDeny }) { // canUseTool-shaped adapter (legacy ordering, sdk-bridge.ts:203-214): the // AskUserQuestion route precedes the bypass fast-path, so a bypass session // still answers interactive questions. -export function canUseTool({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) { +export function canUseTool({ session, emit, nanoid, sessionId, toolName, input, options }) { if (toolName === 'AskUserQuestion') { - return raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId, input, signal: options?.signal }) + return raiseQuestionRequest({ session, emit, nanoid, sessionId, input, signal: options?.signal }) } if (session.permissionMode === 'bypassPermissions') { return { behavior: 'allow', updatedInput: input } } - return raisePermissionRequest({ session, emit, nanoid, nextMonotonic, sessionId, toolName, input, options }) + return raisePermissionRequest({ session, emit, nanoid, sessionId, toolName, input, options }) } diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs index 0e91963b2..d44d4358c 100644 --- a/crates/freshell-claude-sidecar/sdk-message-frames.mjs +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -1,7 +1,6 @@ // The pure mapping from an SDK message to the sidecar's stdout frames // (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.2 and §9.1 N6). One -// function per SDK message kind; index.mjs keeps the side effects (the turn -// gate, pendingResults, the attention clock, emit). The core runtime owns the +// function per SDK message kind; index.mjs keeps the side effects (emit). The // `result` mapping below; the transcript work owns the content kinds // (framesForSdkMessage and the functions it dispatches to, at the end). // @@ -9,41 +8,23 @@ // claude-cli-contract.test.ts): a Stop ends the turn with // `error_during_execution`, `terminal_reason: aborted_streaming|aborted_tools` // and an `[ede_diagnostic] …` error (T11); an API error is a `success` with -// `is_error` (T18), which the transcript shows, so it gets no failure text. - -/** terminal_reason values of a turn that was aborted (a Stop landed on it). */ -export const ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) +// `is_error` (T18). The server reads these facts for its verdict (design §5.3): +// whether the turn rings, and the failure banner it shows (C18). The sidecar +// decides neither. const DIAGNOSTIC_PREFIX = '[ede_diagnostic]' -const ABORTED_COPY = 'Claude stopped the turn before it finished.' -const SUBTYPE_COPY = { - error_max_turns: 'Claude reached its turn limit. Send a message to continue.', - error_max_budget_usd: 'Claude reached the configured spending limit.', - error_max_structured_output_retries: 'Claude could not produce the requested response format.', -} -const DEFAULT_COPY = 'Claude could not complete this turn. Try sending your message again.' - -/** The CLI ended this turn because it was aborted (its terminal_reason says so). */ -export function isAbortedResult(result) { - return ABORTED_TERMINAL_REASONS.has(result?.terminal_reason) -} - /** - * The text a failed turn shows the user, or undefined for a successful result. - * The CLI's internal `[ede_diagnostic]` lines are never shown; when nothing - * else is left, the text explains the result kind in plain words. + * The CLI's own error text for a result, one error per line, without its + * internal `[ede_diagnostic]` lines; undefined when none is left. */ -export function resultErrorText(result) { - if (result.subtype === 'success') return undefined +export function resultErrors(result) { const errors = Array.isArray(result.errors) ? result.errors : [] const messages = errors .filter((message) => typeof message === 'string') .map((message) => message.trim()) .filter((message) => message && !message.startsWith(DIAGNOSTIC_PREFIX)) - if (messages.length) return messages.join('\n') - if (isAbortedResult(result)) return ABORTED_COPY - return SUBTYPE_COPY[result.subtype] ?? DEFAULT_COPY + return messages.length ? messages.join('\n') : undefined } function resultUsage(usage) { @@ -57,23 +38,27 @@ function resultUsage(usage) { } /** - * The frames for an SDK `result`: `sdk.result`, then, for a failed turn, the - * `sdk.error{turnFailure}` the client shows as its "Agent error" banner. - * `userStopped` is the turn gate's verdict that this result is the user's own - * Stop: that is not a failure, so it carries no error frame (G03). + * The frames for an SDK `result`: one `sdk.result` carrying the facts the + * server's verdict reads (design §5.3): `subtype` (also as the older + * `result`), `isError`, `terminalReason` and `numTurns` when the CLI reports + * them, and `errorText`, the CLI's own error text without diagnostics + * (resultErrors). */ -export function resultFrames(sessionId, result, { userStopped }) { - const frames = [{ +export function resultFrames(sessionId, result) { + const errorText = resultErrors(result) + return [{ type: 'sdk.result', sessionId, result: result.subtype, + subtype: result.subtype, + isError: typeof result.is_error === 'boolean' ? result.is_error : result.subtype !== 'success', + ...(typeof result.terminal_reason === 'string' ? { terminalReason: result.terminal_reason } : {}), + ...(Number.isInteger(result.num_turns) ? { numTurns: result.num_turns } : {}), + ...(errorText !== undefined ? { errorText } : {}), durationMs: result.duration_ms, costUsd: result.total_cost_usd, usage: resultUsage(result.usage), }] - const failure = userStopped ? undefined : resultErrorText(result) - if (failure) frames.push({ type: 'sdk.error', sessionId, message: failure, turnFailure: true }) - return frames } // ── content kinds (docs/plans/2026-10-22-freshclaude-transcript-live.md §4.2) ── @@ -106,9 +91,8 @@ export function createFrameState() { /** * The frames for one SDK message of a content kind (none for any other kind). * `session` is what the sidecar knows about the session when the message - * arrives: `{ stream, permissionMode, turnPending }` — the session's - * createFrameState(), the permission mode it believes the CLI is in, and - * whether a send still waits for its result. + * arrives: `{ stream, permissionMode }` — the session's createFrameState() + * and the permission mode it believes the CLI is in. */ export function framesForSdkMessage(sessionId, msg, session) { switch (msg?.type) { @@ -209,17 +193,16 @@ const COMPACTION_FAILED_COPY = 'Claude could not compact the conversation.' * is a notice with the CLI's reason (the transcript keeps the same reason as * a command error); it never ends the turn. Either way, while a send still * waits for its result the session is running again, not compacting. - * (Interim: core's turn ledger will compute this itself, docs/plans/ - * 2026-10-22-freshclaude-core-runtime.md §9.1 N2.) + * The server's turn ledger computes whether the session remains busy from + * the raw status:null fact (lifecycle-frames.mjs). */ -export function compactionOutcomeFrames(sessionId, msg, { turnPending }) { +export function compactionOutcomeFrames(sessionId, msg) { if (msg.status !== null || msg.compact_result === undefined) return [] const frames = [] if (msg.compact_result === 'failed') { const reason = typeof msg.compact_error === 'string' ? msg.compact_error.trim() : '' frames.push({ type: 'sdk.notice', sessionId, level: 'error', code: 'COMPACTION_FAILED', message: reason || COMPACTION_FAILED_COPY }) } - if (turnPending) frames.push({ type: 'sdk.status', sessionId, status: 'running' }) return frames } diff --git a/crates/freshell-claude-sidecar/session-settings.mjs b/crates/freshell-claude-sidecar/session-settings.mjs index 8d2cd6c3f..037b7d446 100644 --- a/crates/freshell-claude-sidecar/session-settings.mjs +++ b/crates/freshell-claude-sidecar/session-settings.mjs @@ -30,16 +30,42 @@ export async function configureSession(session, requested, { busy = false } = {} return { ...next } } -export function userMessageContent(text, images = []) { - const content = [{ type: 'text', text }] - for (const image of images ?? []) { - if (image.kind === 'data' || image.kind == null) { - content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data } }) - } else if (image.kind === 'url') { - content.push({ type: 'image', source: { type: 'url', url: image.url } }) - } else { - throw new Error('This image attachment cannot be read by Claude. Attach the file again.') +/** The send-rejection code for an image the sidecar will not hand to the CLI. */ +export const INVALID_IMAGE = 'INVALID_IMAGE' + +/** The image types the Claude Messages API reads. */ +const READABLE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ +const UNREADABLE_IMAGE = 'This image attachment cannot be read by Claude. Attach the file again.' + +function invalidImage(message) { + const error = new Error(message) + error.code = INVALID_IMAGE + return error +} + +/** One image as a content block; throws an INVALID_IMAGE error for one Claude cannot read. */ +function imageBlock(image) { + if (image?.kind === 'data' || (image != null && image.kind == null)) { + if (typeof image.mediaType !== 'string' || !READABLE_IMAGE_TYPES.has(image.mediaType)) { + const what = typeof image.mediaType === 'string' && image.mediaType ? image.mediaType : 'this attachment' + throw invalidImage(`Claude cannot read ${what} as an image. Attach a PNG, JPEG, GIF or WebP image.`) } + if (typeof image.data !== 'string' || !BASE64.test(image.data)) throw invalidImage(UNREADABLE_IMAGE) + return { type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data } } } - return content + if (image?.kind === 'url' && typeof image.url === 'string' && /^https?:\/\//i.test(image.url)) { + return { type: 'image', source: { type: 'url', url: image.url } } + } + throw invalidImage(UNREADABLE_IMAGE) +} + +/** + * The content of a user message: the text, then each image. An image Claude + * cannot read (an unsupported type, missing or non-base64 data, an unknown + * kind, a URL that is not http or https) throws an error whose `code` is + * INVALID_IMAGE, before anything reaches the CLI. + */ +export function userMessageContent(text, images = []) { + return [{ type: 'text', text }, ...(images ?? []).map(imageBlock)] } diff --git a/crates/freshell-claude-sidecar/turn-complete-gate.mjs b/crates/freshell-claude-sidecar/turn-complete-gate.mjs deleted file mode 100644 index 07d2a5739..000000000 --- a/crates/freshell-claude-sidecar/turn-complete-gate.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Unified "needs attention" turn-complete gate (freshell-claude-sidecar). - * Interim: the Rust Stop latch replaces it (docs/plans/ - * 2026-10-22-freshclaude-core-runtime.md §5.3-5.4, C18). - * - * Every SDK `result` — success, error_during_execution, error_max_turns, - * error_max_budget_usd, error_max_structured_output_retries, and - * success-with-is_error — ends a turn the user may not have been watching, - * so every result emits `sdk.turn.complete`. The ONE exception is the - * result of the turn the user stopped. The arm site (index.mjs - * handleInterrupt) arms the mark ONLY while a turn is plausibly awaiting a - * terminal frame — `if (st.pendingResults > 0) - * st.turnCompleteGate.noteInterruptRequest()` — because the SDK RESOLVES an - * interrupt with nothing in flight (sdk.d.ts:2384-2394; no result follows). - * A rejected interrupt (ok:false — the turn kept running) clears the mark. - * - * The next result consumes the mark, and it is silenced only when it was - * aborted (terminal_reason aborted_streaming/aborted_tools, T11; or, on a - * CLI that reports no terminal_reason, error_during_execution after at least - * one turn — a boot refusal (T5, num_turns 0) is never a stopped turn). A Stop that - * missed its turn — one sent while the CLI was still starting, whose receipt - * comes back ok while the turn then runs to success/completed (T13) — must - * not silence that turn (G06): the user believes it stopped, and it did not. - * Consuming the mark on every result also means a stale mark (armed for a - * send the CLI absorbed into an earlier turn, T10) never swallows a later - * turn's bell. - * - * There is DELIBERATELY no reset-on-send: the SDK serializes turns within - * one query (a queued send is input pushed onto the same stream, and - * results arrive in turn order), so the stopped turn's result is the FIRST - * result after the accepted settle. A reset-at-send would race a queued send - * against the stopped turn's late result and let the user's own Stop ring. - */ - -import { isAbortedResult } from './sdk-message-frames.mjs' - -/** The result is the turn a Stop landed on. */ -function wasStopped(result) { - if (isAbortedResult(result)) return true - // A CLI that predates terminal_reason reports a stopped turn only as - // error_during_execution. A boot refusal has the same shape on the pinned - // CLI (T5) but ran no turn (num_turns 0), so no Stop ended it: it rings and - // keeps its refusal text. - return result?.terminal_reason == null - && result?.subtype === 'error_during_execution' - && result?.num_turns > 0 -} - -export function createTurnCompleteGate() { - let userInterruptPending = false - return { - noteInterruptRequest() { userInterruptPending = true }, - noteInterruptSettled(ok) { if (!ok) userInterruptPending = false }, - /** Whether this SDK `result` rings; consumes any pending Stop mark. */ - resultEmitsAttention(result) { - const stopped = userInterruptPending && wasStopped(result) - userInterruptPending = false - return !stopped - }, - } -} diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index 464c90953..0123fe209 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -108,7 +108,9 @@ tracing = "0.1" libc = "0.2" [dev-dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +# `test-util` unlocks `#[tokio::test(start_paused = true)]`, so the shared quiet +# deadman's waiter tests drive its window on a paused clock. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] } tower = { version = "0.5", features = ["util"] } # DIAG-01 test facility: a capturing `Layer` + `tracing::subscriber::set_default` # to assert lifecycle events fire, without depending on the global subscriber diff --git a/crates/freshell-freshagent/src/attention.rs b/crates/freshell-freshagent/src/attention.rs new file mode 100644 index 000000000..e77074e83 --- /dev/null +++ b/crates/freshell-freshagent/src/attention.rs @@ -0,0 +1,198 @@ +//! The per-session attention clock and edge builders every fresh-agent lane +//! shares (design 2026-10-22 §3.2, §5.5; moved out of `codex.rs`). +//! +//! Every `freshAgent.turn.complete` / `freshAgent.turn.waiting` edge carries an +//! `at` that is strictly monotonic per session: the client folds edges under an +//! `at`-monotonic dedupe, so two edges in one millisecond, or an edge minted +//! after a backward wall-clock step, must still land strictly above the last +//! one. One [`AttentionClock`] per session gives every minting source (an +//! ordinary completion, a crash, a deadman fire) that single timeline, so the +//! sources can never collide or regress against each other. The clock lives in +//! memory only: it starts fresh after a server restart, the wall-clock regime +//! the AGENTS.md alert contract already accepts. + +use std::sync::{Arc, Mutex as StdMutex}; + +use freshell_codex::next_monotonic_turn_complete_at; +use serde_json::{json, Value}; + +// `Date.now()`: epoch milliseconds, the clock's wall-clock input. +use crate::rollback_record::now_ms; + +/// One session's strictly-monotonic attention clock (`Default` is a clock that +/// has minted nothing). Cheap to clone; every clone is the SAME timeline, so +/// the clock carries across a re-key or a death and resume by handing the +/// clone to the next record. +#[derive(Clone, Debug, Default)] +pub(crate) struct AttentionClock { + /// The last `at` minted or observed (the floor every later mint clears). + /// Shared as a bare cell because codex's subscription (the + /// `freshell-codex` crate, which cannot name this type) mints its + /// ordinary completions on the same cell. + last: Arc>>, +} + +impl AttentionClock { + /// Mint the next `at`: the wall clock, raised to one above the floor + /// when the wall clock has not passed it. + pub(crate) fn mint(&self) -> i64 { + self.mint_with(now_ms()) + } + + fn mint_with(&self, now: i64) -> i64 { + let mut last = self.last.lock().expect("attention clock lock"); + let at = next_monotonic_turn_complete_at(*last, now); + *last = Some(at); + at + } + + /// Raise the floor to an `at` minted elsewhere for this session, so the + /// next mint lands strictly above it. Never lowers the floor. + #[allow(dead_code)] // consumed by the claude edge hub (design §5.5, C18) + pub(crate) fn observe(&self, at: i64) { + let mut last = self.last.lock().expect("attention clock lock"); + if last.is_none_or(|floor| at > floor) { + *last = Some(at); + } + } + + /// The last `at` minted or observed; `None` for a fresh clock. + #[allow(dead_code)] // consumed by the claude edge hub (design §5.5, C18) + pub(crate) fn floor(&self) -> Option { + *self.last.lock().expect("attention clock lock") + } +} + +/// Adopt an existing clock cell, sharing its timeline both ways. Codex keeps +/// each session's clock as the bare cell its `CodexSubscription` mints on. +impl From>>> for AttentionClock { + fn from(last: Arc>>) -> Self { + Self { last } + } +} + +/// The client's `freshAgent.turn.complete` edge (the inner event every lane +/// wraps in its `freshAgent.event` envelope). +pub(crate) fn turn_complete_event(session_id: &str, at: i64) -> Value { + json!({ "type": "freshAgent.turn.complete", "sessionId": session_id, "at": at }) +} + +/// Mint the `at` of a `freshAgent.turn.complete` edge synthesized outside a +/// lane's ordinary completion path (a sidecar crash with a turn in flight, a +/// quiet-deadman fire) on the session's clock, and log the edge at info. +/// DIAG-01: ids, source and `at` only, never the turn's content. The caller +/// broadcasts the edge in its own envelope. +pub(crate) fn mint_synthesized_edge( + clock: &AttentionClock, + provider: &str, + session_id: &str, + source: &'static str, +) -> i64 { + let at = clock.mint(); + tracing::info!(provider, session_id, source, at, "freshagent.turn.complete"); + at +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + use serde_json::json; + + /// Design §10 C15: an `at` a clock has observed is a floor -- the next + /// mint lands strictly above it even when the wall clock has not caught + /// up, so an edge minted after a forwarded one never collides with it. + #[test] + fn mint_after_observe_lands_strictly_above_the_observed_at() { + let clock = AttentionClock::default(); + let observed = now_ms() + 60_000; + clock.observe(observed); + assert_eq!(clock.mint(), observed + 1, "wall clock is behind T"); + assert_eq!(clock.floor(), Some(observed + 1)); + + let clock = AttentionClock::default(); + clock.observe(5_000); + assert_eq!(clock.mint_with(5_000), 5_001, "now == T"); + assert_eq!(clock.mint_with(4_990), 5_002, "now < T"); + assert_eq!(clock.mint_with(9_000), 9_000, "a later now is taken as is"); + } + + /// Design §11.2: strictly increasing across mints within one millisecond + /// and after a backward wall-clock step. + #[test] + fn mints_strictly_increase_within_a_millisecond_and_across_a_backward_step() { + let clock = AttentionClock::default(); + assert_eq!(clock.floor(), None, "a fresh clock has minted nothing"); + assert_eq!(clock.mint_with(1_000), 1_000); + assert_eq!(clock.mint_with(1_000), 1_001, "same millisecond"); + assert_eq!(clock.mint_with(1_000), 1_002, "same millisecond again"); + assert_eq!(clock.mint_with(400), 1_003, "the wall clock stepped back"); + assert_eq!(clock.mint_with(2_000), 2_000); + assert_eq!(clock.floor(), Some(2_000)); + } + + #[test] + fn observe_never_lowers_the_floor() { + let clock = AttentionClock::default(); + clock.observe(700); + clock.observe(300); + assert_eq!(clock.floor(), Some(700)); + assert_eq!(clock.mint_with(500), 701); + } + + /// Design §11.2: the clock carries across re-keys and death/resume -- a + /// clone handed to the next record is the SAME timeline, not a copy. + #[test] + fn a_cloned_clock_continues_the_same_timeline() { + let clock = AttentionClock::default(); + let carried = clock.clone(); + assert_eq!(clock.mint_with(10), 10); + assert_eq!(carried.mint_with(10), 11); + carried.observe(50); + assert_eq!(clock.mint_with(10), 51); + } + + /// Codex's subscription (the `freshell-codex` crate) mints its ordinary + /// completions directly on the session's clock cell; a clock adopted from + /// that cell shares its timeline both ways. + #[test] + fn a_clock_adopted_from_a_shared_cell_shares_its_timeline() { + let cell = Arc::new(StdMutex::new(None)); + let clock = AttentionClock::from(Arc::clone(&cell)); + *cell.lock().unwrap() = Some(100); + assert_eq!(clock.mint_with(100), 101); + assert_eq!(*cell.lock().unwrap(), Some(101)); + } + + #[test] + fn turn_complete_event_is_the_client_edge_shape() { + assert_eq!( + turn_complete_event("s-1", 42), + json!({ "type": "freshAgent.turn.complete", "sessionId": "s-1", "at": 42 }) + ); + } + + /// A synthesized edge (crash, deadman) mints on the session's clock and + /// logs the edge at info with ids, source and `at` (DIAG-01: never + /// content). + #[test] + fn mint_synthesized_edge_mints_on_the_clock_and_logs_the_edge() { + let (events, _guard) = capture(); + let clock = AttentionClock::default(); + let observed = now_ms() + 60_000; + clock.observe(observed); + + let at = mint_synthesized_edge(&clock, "codex", "thread-1", "quiet_deadman"); + + assert_eq!(at, observed + 1); + assert_eq!(clock.floor(), Some(at)); + let logged = with_message(&events, "freshagent.turn.complete"); + assert_eq!(logged.len(), 1, "{logged:?}"); + let event = &logged[0]; + assert_eq!(event.level, tracing::Level::INFO); + assert_eq!(event.field("provider"), Some("codex")); + assert_eq!(event.field("session_id"), Some("thread-1")); + assert_eq!(event.field("source"), Some("quiet_deadman")); + assert_eq!(event.field("at"), Some(at.to_string().as_str())); + } +} diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 00f0c758a..63edb3c15 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -15,33 +15,31 @@ //! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | spawn the Node sidecar (ownership-tagged, isolated HOME inherited) → SDK `query()` → the SDK bridge's **BARE nanoid** placeholder id (NO placeholder→durable materialization — claude's send returns void), broadcast `freshAgent.created`, start the stdout consumer | //! | `freshAgent.send {sessionId,text}` | push the user turn into the sidecar's SDK input stream, broadcast `freshAgent.send.accepted` (NO `submittedTurnId` — claude) | //! -//! ## Events + the completion edge +//! ## Events + the attention edges //! //! The sidecar emits the SAME `sdk.*` shapes `SdkBridge` broadcasts. The stdout consumer //! normalizes each `sdk.* → freshAgent.*` (a port of `server/fresh-agent/sdk-events.ts`) //! and wraps it in a `freshAgent.event` envelope: `sdk.session.init` → `freshAgent.session.init` -//! (durable Claude UUID via `cliSessionId`), `sdk.stream`/`sdk.assistant`/`sdk.result`, and -//! the discrete `freshAgent.turn.complete` attention edge. Task 4 made the edge UNIFIED: -//! the sidecar rings it on EVERY turn end except a user-initiated interrupt (its -//! result-gate + consumeStream finally mint); the consumer never mints one from a live -//! stream. That edge is the T2 `provider.emits-completion-signal` invariant. The `.jsonl` -//! transcript the claude CLI persists under the isolated `/projects/…` -//! corroborates it. +//! (durable Claude UUID via `cliSessionId`), `sdk.stream`/`sdk.assistant`/`sdk.result`. +//! The `.jsonl` transcript the claude CLI persists under the isolated +//! `/projects/…` corroborates them. //! -//! ## Sidecar death (ADR Decision 2.1, revised by Task 5) — an in-flight death rings +//! The server mints every attention edge itself (design §5, C18; the sidecar mints +//! none): the stdout consumer folds each line into the session's turn ledger +//! (`claude_turns.rs`), whose verdict on each result says whether it rings, and every +//! `freshAgent.turn.complete` / `freshAgent.turn.waiting` leaves through the edge hub +//! (`claude_edges.rs`) on the session's one attention clock. Only the user's own Stop is +//! silent, and only for the turn it targeted (the Stop latch, re-sent when it missed a +//! command the CLI was still starting); a failed result also shows why. //! -//! The sidecar's own edge covers every turn end its stream can report. The ONE end it -//! cannot report is an UNREQUESTED process death mid-turn (SIGKILL — stdout just ends, -//! the sidecar's finally never runs): the consumer's EOF tail then synthesizes the SAME -//! `freshAgent.turn.complete` edge for the armed state captured at EOF entry, stamped by -//! the session's Rust-side monotonic clock -//! ([`ClaudeSession::last_synthesized_complete_at`] — the sidecar's own clock died with -//! the process; wall-clock advancement orders the two timelines, the accepted regime). -//! Requested deaths (kill/shutdown/teardown/rollback fork) never reach the death arm (the -//! map entry is removed first) and stay silent, and an IDLE death mints nothing — the -//! no-FALSE-completion property holds. Verified by -//! [`tests::sidecar_death_with_turn_in_flight_rings_the_unified_edge`] and its idle twin -//! [`tests::sidecar_death_while_idle_emits_no_completion`]. +//! ## Sidecar death — an in-flight death rings +//! +//! When the sidecar's output ends unrequested (it reported its SDK stream's end and +//! exited, or the Node process died), the consumer's end-of-output path rings once if +//! the ledger owed the user a turn end, through the same hub and clock. Requested +//! deaths (kill/shutdown/teardown/rollback fork) never reach the death arm (the map +//! entry is removed first) and stay silent, and an IDLE death mints nothing — the +//! no-FALSE-completion property holds. //! //! ## Safety //! @@ -66,7 +64,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio::sync::Mutex as TokioMutex; -use freshell_codex::next_monotonic_turn_complete_at; use freshell_protocol::{ ErrorCode, ErrorMsg, FreshAgentApprovalRespond, FreshAgentAttach, FreshAgentCompact, FreshAgentConfigure, FreshAgentCreate, FreshAgentCreated, FreshAgentEvent, FreshAgentInterrupt, @@ -78,18 +75,21 @@ use freshell_protocol::{ use crate::claude_handshake::{read_created, BootContext}; use crate::claude_launch::{ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; +use turn_status::LedgerStep; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). pub(crate) const PROVIDER: &str = "claude"; /// Build the sidecar-only correlation record for one checkpointed Claude -/// send. The UUID is supplied on SDKUserMessage and later accepted only when -/// the SDK replays the persisted user row carrying that same UUID. +/// send. The send's UUID is supplied on SDKUserMessage and the attempt is +/// later accepted only when the SDK replays the persisted user row carrying +/// that same UUID. fn checkpoint_attempt_for_send(msg: &FreshAgentSend) -> Option { let intent = msg.checkpoint_intent.as_ref()?; let request_id = msg.request_id.as_deref().filter(|id| !id.is_empty())?; + // The attempt binds under the send's own uuid (the send line's `uuid`, C16), + // which is also the turn id the send is accepted with. Some(json!({ - "uuid": uuid::Uuid::new_v4().to_string(), "logicalAttemptId": intent.logical_attempt_id, "requestId": request_id, })) @@ -304,6 +304,9 @@ pub struct FreshClaudeState { /// `exited` and a send to it is refused visibly. Resuming or closing the /// session forgets the record. See [`crate::claude_dead_sessions`]. dead_sessions: crate::claude_dead_sessions::SharedDeadSessions, + /// The one place a claude attention edge leaves the server (design §5.5, + /// C18): edges, the attention observer and the typed turn events. + edges: crate::claude_edges::ClaudeEdgeHub, /// Test holds on the session lifecycle ([`crate::claude_test_holds`]); none /// is set in production. test_holds: Arc, @@ -570,11 +573,20 @@ struct PendingQuestionEntry { questions: Value, } +/// Handles a response needs after releasing the shared sessions-map lock. +struct ResponseSession { + map_key: String, + ownership_id: String, + stdin: crate::claude_sidecar_io::SidecarWriter, + sidecar_session_id: String, + pending: Arc>, +} + /// One live freshclaude session: the Node sidecar it drives + its stdout consumer. struct ClaudeSession { configuration: ClaudeConfiguration, - /// stdin of the Node sidecar (write `create`/`send`/`shutdown` requests). - stdin: ChildStdin, + /// Per-runtime stdin writer; await only after dropping the sessions map lock. + stdin: crate::claude_sidecar_io::SidecarWriter, /// The owned Node sidecar child (SIGKILL backstop; `kill_on_drop`). child: Child, /// G31: owns the sidecar's whole process tree (node AND the SDK-spawned @@ -625,59 +637,21 @@ struct ClaudeSession { broadcast_id: Arc>, /// The folded pending approval/question set (Task 2) — see [`ClaudePending`]. pending: Arc>, - /// kata 1wxv Task 4: the busy truth for the rollback `BUSY_TURN` gate — set by - /// `handle_send` UNDER `turn_lock` BEFORE the sidecar write (the check-then-set - /// window against `handle_rollback`'s busy check is closed); cleared on EXACTLY - /// the four contract edges (`sdk.result` ANY subtype, `sdk.status` idle, sidecar - /// EOF/death, a completed `handle_interrupt`) — fail-closed otherwise (a missing - /// arm wedges BUSY_TURN refusals forever). Carried across the rollback's - /// kill+respawn into the replacing record (the fork continues the same logical - /// session lifetime). - in_turn: Arc, - /// Focused-review ep1-r1 F1 / ep1-r2 F2 / ep2-r2: the per-session FIFO - /// turn tracker — the EXPLICIT, order-expressive replacement for the - /// counter quartet (op counts could not encode queue order; the ep2-r2 - /// interleaving review repro had a wholesale-extinguish defect that a - /// `VecDeque` makes structurally impossible). A `/compact` - /// written to the sidecar WHILE a turn is active queues BEHIND it - /// ([`arm_turn_op`]); only EACH op's OWN terminal edge retires its entry - /// ([`fold_terminal_edge`]) — so the busy gate reads `in_turn`, recomputed - /// from [`TurnTracker::busy`] inside every mutation's critical section, - /// and stays closed through every OTHER op's edges. Arms happen UNDER the - /// session turn lock BEFORE the sidecar write await (ep1-r3 F3: the - /// consumer's terminal-edge fold never takes that lock, so a post-await - /// arm races it); a no-write failure SYNCHRONOUSLY undoes exactly the - /// arm's own entry ([`undo_turn_op_arm`]). Disarm set (the sidecar input - /// queue is FIFO; a later send queues BEHIND a compact and - /// `query.interrupt()` does not drain it, so NEITHER is a disarm): - /// (a) the terminal edge of a compact whose run was CONFIRMED manual - /// (promoted to `running` by [`confirm_compact_candidate`]) — retires - /// that ONE op and holds busy while anything remains outstanding - /// (ep1-r3 F1); - /// (b) the FIFO-drop peel — a terminal edge with NO running op whose - /// oldest queued op is an UNPROMOTED compact: the compact provably - /// dropped, evidenced by a queued Turn that follows it (peel - /// leading unpromoted compacts up to that Turn; a compact queued - /// BEHIND the send needs no evidence handling — it genuinely - /// remains queued, ep2-r2 F1); - /// (c) any in-stream `sdk.error` frame, or sidecar EOF/death (the queue - /// is gone). - /// Carried across the rollback's kill+respawn exactly like `in_turn`. - turn_tracker: Arc>, - /// Task 5 (unified needs-attention signal): the session's Rust-side - /// completion clock — an UNREQUESTED sidecar death with a turn in flight - /// synthesizes the `freshAgent.turn.complete` edge the dead sidecar could - /// not emit, stamped strictly-monotonically per session via - /// [`next_monotonic_turn_complete_at`]. The sidecar's own clock died with - /// its process (and resets across a rollback fork, same as the sidecar's - /// own per-session state); wall-clock advancement orders the Rust-minted - /// `at`s against the sidecar-minted ones — the accepted regime, same as - /// Task 3. Minted ONLY by the consumer's unrequested-death arm, and only - /// for a death that reported no stream end (`sdk.stream.ended`: the - /// sidecar already rang that one itself — the interim rule until C18): - /// requested deaths (kill/shutdown/teardown/rollback fork) never reach it, - /// and an idle death (no armed turn) mints nothing. - last_synthesized_complete_at: Arc>>, + /// The session's busy truth (design §5.2, C17): every command the server + /// sent and the CLI has not ended, the CLI's own turns and its compactions, + /// and the status last published. The handlers arm it under `turn_lock` + /// before each write; the stdout consumer folds the sidecar's lines into it + /// and publishes the status; the rollback, fork and configure gates read + /// [`crate::claude_turns::ClaudeTurnLedger::busy`]. An undo's replacement + /// takes it over (the same logical session). + ledger: crate::claude_turns::SharedLedger, + /// The session's attention clock (design §5.5, C18): every edge the server + /// mints for it (a result that rings, a death while a turn was owed, a + /// waiting edge) takes its strictly increasing `at` from here, so edges + /// from different sources never collide or regress (G24). An undo's + /// replacement and a resume after an unrequested death take it over (the + /// same logical session). + clock: crate::attention::AttentionClock, /// kata 1wxv Task 4 (r2 serialization discipline): ONE per-session async turn /// lock. `handle_rollback` holds it across the WHOLE handler (busy-check → /// reads → record pre-write → pending-cancel → kill+spawn+adoption → reply); @@ -686,26 +660,6 @@ struct ClaudeSession { /// an undo or redo registers takes this lock over, so a request that waited /// on it follows the replacement (`live_key_after_turn_wait`). turn_lock: Arc>, - /// Focused ep4-r2/ep4-r3 (probe protocol): rollback's pre-teardown quiesce - /// probe registers its probeId + a oneshot here before writing the - /// `rollback.quiesce` request; the consumer's `sdk.rollback.quiesced` fold - /// fires it ONLY on a probeId match (ep4-r3 F2: a stale receipt from an - /// ordinary interrupt or an earlier timed-out probe can never close a - /// live probe). Because the consumer folds lines IN STREAM ORDER, the - /// quiesced frame provably lands after every already-emitted piece of - /// evidence — and because the sidecar answers from its OWN input queue, it - /// reports cancellation of never-handed compacts and in-flight/handed - /// truth rollback cannot observe from wire frames alone. - #[allow(clippy::type_complexity)] - rollback_probe_slot: - Arc)>>>, - /// The session's tracked status (the stdout consumer's fold: the reference - /// bridge's turn lifecycle — `running` on `sdk.assistant`, `idle` on every - /// `sdk.result` — plus the raw `sdk.status` wire values folded on top). - /// Read by the attach-ack sites so a reconnect ack tells the truth instead of - /// the hardcoded "idle" that used to wedge stale-busy/stale-idle panes. - /// Starts "idle" — a fresh/just-resumed session has announced nothing else. - last_status: Arc>, /// The genesis this runtime was launched from when it had no transcript yet /// (an undo's replacement, a fork's child; design §4.1), stamped with the /// launch time. The snapshot serves it when the ledger records none: a lane @@ -714,20 +668,6 @@ struct ClaudeSession { launch_genesis: Option, } -/// The sidecar's signed answer to a `rollback.quiesce` probe (ep4-r3). All -/// three fields are sidecar-owned truth the wire frames alone cannot carry: -/// `cancelled_queue` — compacts dropped from the SDK-input queue (they -/// provably never start); `in_flight_turn` — an SDK turn is mid-flight; -/// `handed_compact_likely` — a compact already crossed the un-cancellable -/// same-tick handoff to an awaiting SDK consumer. Either busy signal fails -/// the probe closed (BUSY_TURN + compensating ledger rewrite). -#[derive(Debug, Clone, Copy)] -struct QuiesceVerdict { - cancelled_queue: u64, - in_flight_turn: bool, - handed_compact_likely: bool, -} - /// What a claude `freshAgent.session.metadata` frame states (see /// [`FreshClaudeState::stated_settings`]). #[derive(Debug, Default, PartialEq, Eq)] @@ -750,10 +690,15 @@ struct ClaudeConfiguration { } impl ClaudeSession { - /// The session's tracked status (the stdout consumer's turn-lifecycle + - /// `sdk.status` fold) — the truth the attach acks speak. + /// The session's effective status (design §5.2) — the truth the attach + /// acknowledgements and the snapshot overlay speak. fn current_status(&self) -> String { - self.last_status.lock().expect("last status lock").clone() + self.ledger + .lock() + .expect("turn ledger lock") + .effective_status(false, false) + .as_str() + .to_string() } } @@ -812,6 +757,7 @@ impl FreshClaudeState { hosted_mcp: false, freshell_mcp: Default::default(), sidecar_environment: None, + edges: crate::claude_edges::ClaudeEdgeHub::new(Arc::clone(&broadcast_tx)), broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), sessions: Arc::new(TokioMutex::new(HashMap::new())), @@ -854,6 +800,27 @@ impl FreshClaudeState { self } + /// Report every claude attention edge, pending drain and session identity + /// change to `observer` (design §9.2 I-1). Every clone of this state + /// reports to it. + pub fn with_attention_observer( + self, + observer: Arc, + ) -> Self { + self.edges.set_observer(observer); + self + } + + /// Every claude turn event from now on (design §9.3 I3): each settled turn + /// with the requests it retired (a turn the user's Stop silenced too, with + /// no `at`), each waiting edge and each unrequested session end. A + /// dedicated low-volume channel: stream traffic never fills it. + pub fn subscribe_turn_events( + &self, + ) -> tokio::sync::broadcast::Receiver { + self.edges.subscribe() + } + /// Wire where every sidecar's CLI reaches this server (G19): its /// environment carries `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`. /// Set-once; `false` when an endpoint was already wired. @@ -1990,45 +1957,6 @@ impl FreshClaudeState { } } - /// The one way a claude attention edge leaves the server: every site that - /// forwards or mints a `freshAgent.turn.complete` / `freshAgent.turn.waiting` - /// calls this (design §9.2 I-1; C18's `ClaudeEdgeHub::emit` replaces it). - /// It logs the edge at info (G14, design §5.5: ids, type, source and `at`; - /// never content), then broadcasts its client frame. `stamp` is the - /// envelope id clients address the session by; `sidecar_session_id` is the - /// id inside the event, as the sidecar names the session. - pub(crate) fn emit_attention_edge( - &self, - edge: AttentionEdge, - stamp: &str, - sidecar_session_id: &str, - session_type: &str, - at: i64, - source: AttentionEdgeSource, - ) { - let source = source.as_str(); - match edge { - AttentionEdge::TurnComplete => tracing::info!( - provider = PROVIDER, session_id = %stamp, session_type = %session_type, - source, at, "freshagent.turn.complete" - ), - AttentionEdge::Waiting => tracing::info!( - provider = PROVIDER, session_id = %stamp, session_type = %session_type, - source, at, "freshagent.turn.waiting" - ), - } - self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { - event: json!({ - "type": edge.wire_type(), - "sessionId": sidecar_session_id, - "at": at, - }), - provider: PROVIDER.to_string(), - session_id: stamp.to_string(), - session_type: session_type.to_string(), - })); - } - async fn broadcast_checkpoint_frame( &self, frame: String, @@ -2561,6 +2489,9 @@ impl FreshClaudeState { name_ref, session_name, })); + // C17: the server says the new session's status itself (no sidecar + // status reaches a client any more). + self.announce_claude_status(&created, session_type).await; ClaudeCreateOutcome::Created { session_id: created, session_ref, @@ -2663,6 +2594,7 @@ impl FreshClaudeState { name_ref, session_name, })); + self.announce_claude_status(durable, session_type).await; ClaudeCreateOutcome::Created { session_id: durable.to_string(), session_ref: Some(claude_session_ref(durable)), @@ -3590,6 +3522,7 @@ impl FreshClaudeState { } } + self.edges.forgotten(&session_id); self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -4131,17 +4064,17 @@ impl FreshClaudeState { // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── - /// Handle a `freshAgent.interrupt` for claude/kilroy: forward an `interrupt` - /// request to the owned sidecar, which calls the SDK's `query.interrupt()` -- - /// mirrors `server/fresh-agent/adapters/claude/adapter.ts:163-168`'s - /// `interrupt(sessionId) { mapMissingResult(deps.sdkBridge.interrupt(sessionId), ...) }` - /// -> `server/sdk-bridge.ts:785-793`'s `sp.query.interrupt().catch(warn)`. Fire-and- - /// forget on success: legacy's `ws-handler.ts:3503-3517` sends NO confirmation frame - /// when `manager.interrupt(locator)` resolves, only `sendError` on a throw -- so a - /// successful interrupt here broadcasts nothing either. A missing session mirrors - /// the `SESSION_NOT_FOUND` convention [`Self::handle_send`] already established for - /// this provider (and codex/opencode's own `handle_interrupt`), rather than - /// reproducing legacy's adapter-specific message text verbatim. + /// Handle a `freshAgent.interrupt` (the user's Stop) for claude/kilroy (design + /// §5.4, C18). A session with nothing owed has nothing to stop: nothing is + /// sent (logged `freshagent.claude.stop` outcome `idle`). Otherwise the + /// session's turn ledger latches the Stop on one target (the running turn, + /// else the oldest command the CLI has not started, else the turn the CLI + /// runs by itself; commands queued behind the running turn are not targets) + /// and the interrupt is written with the latch's `stopId`. The stopped turn's + /// own result is then silent; every other turn end rings. A Stop that missed + /// a command the CLI was still starting is sent again by the stdout consumer + /// ([`Self::reissue_stop`]). Success broadcasts nothing. A missing session + /// mirrors the `SESSION_NOT_FOUND` convention [`Self::handle_send`] uses. pub async fn handle_interrupt(&self, msg: FreshAgentInterrupt) { let session_id = msg.session_id.clone(); @@ -4150,44 +4083,26 @@ impl FreshClaudeState { self.send_error(&None, "SESSION_NOT_FOUND", "claude session not found"); return; }; - let mut guard = self.sessions.lock().await; - let Some(session) = guard.get_mut(&map_key) else { - drop(guard); + if !self + .stop_live_session(&map_key, session_type_str(msg.session_type)) + .await + { self.send_error(&None, "SESSION_NOT_FOUND", "claude session not found"); - return; - }; - // Address the sidecar by ITS id for this session (== the map key for created - // sessions; differs for resumed-on-attach sessions, Task 6). - let interrupt_req = json!({ "type": "interrupt", "sessionId": session.sidecar_session_id }); - if let Err(err) = write_line(&mut session.stdin, &interrupt_req).await { - drop(guard); - self.send_error(&None, "CLAUDE_INTERRUPT_FAILED", &err); } - // Focused ep4-r1/ep4-r2 F1: NOTHING retires here and NOTHING retires at - // the settle ack either — per the SDK contract the control receipt (the - // sidecar's `sdk.interrupt_settled`) provably lands BEFORE the - // interrupted op's terminal `sdk.result`, so retiring at the receipt - // would misattribute that trailing result to the NEXT queued op. The - // interrupted op's own result owns its fold (rejected interrupts leave - // everything untouched by construction). The settle frame's sole job - // is closing rollback's quiesce probe (see handle_rollback). Success: - // no broadcast (mirrors legacy's silent fire-and-forget interrupt). } // ── freshAgent.send (WS) ───────────────────────────────────────────────────────── /// The session `wire_id` names, with what a turn-locked request needs from - /// it: its map key, turn lock, busy truth (`in_turn`, `turn_tracker`) and - /// durable id. `None` when no live session answers to `wire_id`. - #[allow(clippy::type_complexity)] // the handles every turn-locked request reads + /// it: its map key, turn lock, turn ledger and durable id. `None` when no + /// live session answers to `wire_id`. async fn turn_locked_handles( &self, wire_id: &str, ) -> Option<( String, Arc>, - Arc, - Arc>, + crate::claude_turns::SharedLedger, Option, )> { let key = self.resolve_session_key(wire_id).await?; @@ -4196,8 +4111,7 @@ impl FreshClaudeState { Some(( key, session.turn_lock.clone(), - session.in_turn.clone(), - session.turn_tracker.clone(), + session.ledger.clone(), session.cli_session_id.clone(), )) } @@ -4253,11 +4167,19 @@ impl FreshClaudeState { } /// Handle a `freshAgent.send` for claude: push the user turn into the sidecar's SDK - /// input stream, then broadcast `freshAgent.send.accepted`. The stdout consumer surfaces - /// the unified attention edge (sidecar `sdk.turn.complete` — minted for EVERY result - /// subtype except a user-interrupted turn's own → `freshAgent.turn.complete`). - /// Claude's send returns void, so NO `submittedTurnId` and NO materialization. - pub async fn handle_send(&self, msg: FreshAgentSend) { + /// input stream, then broadcast `freshAgent.send.accepted`. The stdout consumer rings + /// the turn's end itself (`freshAgent.turn.complete` for every result except the one + /// the user's own Stop landed on: the turn ledger's verdict, C18). + /// + /// C16: the send carries a uuid minted here. The sidecar puts it on the SDK user + /// message, so the CLI writes the user line with it: it is the `submittedTurnId` of + /// `freshAgent.send.accepted` and the `turnId` the snapshot shows for the message + /// (a send absorbed into a running tool call has no user line of its own, T10). + /// The answer is also returned ([`ClaudeSendOutcome`], design §9.3 I2): `Accepted` + /// once the sidecar has the send, `Refused` with the request error's code otherwise. + /// A send the sidecar refuses later (a bad image) is taken back by the stdout + /// consumer ([`Self::take_back_rejected_send`]). + pub async fn handle_send(&self, msg: FreshAgentSend) -> ClaudeSendOutcome { let request_id = msg.request_id.clone(); let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); @@ -4269,10 +4191,7 @@ impl FreshClaudeState { let send_fence = match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { Ok(fence) => fence, - Err(err) => { - self.send_error(&request_id, err.code(), err.message()); - return; - } + Err(err) => return self.refuse_send(&request_id, err.code(), err.message()), }; // Task 10b: a durable claude UUID resolves through `cli_index` to the live @@ -4280,11 +4199,10 @@ impl FreshClaudeState { // An undo or redo never hides the session from this resolution (C13: // the replacement registers, and the original's ids alias it, in one // step under the map locks), so a miss is a session that is gone. - let Some((map_key, turn_lock, in_turn, turn_tracker, durable_before_wait)) = + let Some((map_key, turn_lock, ledger, durable_before_wait)) = self.turn_locked_handles(&session_id).await else { - self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); - return; + return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); }; // r2 serialization discipline: hold the session turn lock across the // busy-set AND the sidecar write — the check-then-set window vs @@ -4306,8 +4224,7 @@ impl FreshClaudeState { ) .await else { - self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); - return; + return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); }; // Arm the coordinator guard only after the send has waited behind any // earlier turn. A lifecycle transition during that wait advances the @@ -4340,8 +4257,7 @@ impl FreshClaudeState { session_id = %session_id, owner_key = %owner_key, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - self.send_error(&request_id, "SESSION_RESERVED", &message); - return; + return self.refuse_send(&request_id, "SESSION_RESERVED", &message); } } } else { @@ -4381,8 +4297,7 @@ impl FreshClaudeState { .configure_for_send(&map_key, settings, session_type) .await { - self.send_error(&request_id, "CLAUDE_SETTINGS_FAILED", &err); - return; + return self.refuse_send(&request_id, "CLAUDE_SETTINGS_FAILED", &err); } let settings_after = self.stated_settings(&map_key).await; if settings_before != settings_after { @@ -4413,8 +4328,11 @@ impl FreshClaudeState { }), None => { drop(guard); - self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); - return; + return self.refuse_send_to_missing_session( + &request_id, + &session_id, + msg.session_type, + ); } } }; @@ -4434,45 +4352,47 @@ impl FreshClaudeState { tracing::warn!(error = %err, session = %durable, "freshagent.claude.destroy_redo_on_submit_failed"); } } - // Set the busy truth UNDER the lock, BEFORE the sidecar write — and now - // ORDER-EXPLICITLY (ep2-r2): this send enqueues as a distinct tracked - // op behind every outstanding op (or takes the running slot from - // idle), so its OWN terminal edge — never the prior op's — retires it. - // ep1-r2 F2: there is NO "belt" disarm of queued compacts here: the - // sidecar's FIFO input queue merely queues this send BEHIND a pending - // compact, so a send acceptance proves nothing about queue drainage - // (the disarm set lives in the in-stream edge fold: observed-compacting - // terminal edge, the drop peel, sdk.error/EOF). - let send_was_busy = arm_turn_op(&in_turn, &turn_tracker, TrackedOp::Turn); - let mut guard = self.sessions.lock().await; - let Some(session) = guard.get_mut(&map_key) else { + // C17: arm the ledger UNDER the turn lock, BEFORE the sidecar write + // (the CLI's lifecycle for the send, or the sidecar's refusal, can come + // back before this handler runs again; a rollback holding this lock + // observes either no send or an armed one). The session reads + // `running` from the accept on. + let submitted_turn_id = crate::claude_turns::mint_send_uuid(); + let stamp = self.envelope_stamp(&map_key).await; + ledger.lock().expect("turn ledger lock").arm( + submitted_turn_id.clone(), + request_id.clone(), + crate::claude_turns::SendKind::Turn, + ); + self.publish_claude_status(&ledger, &stamp, session_type, false); + let guard = self.sessions.lock().await; + let Some(session) = guard.get(&map_key) else { drop(guard); - undo_turn_op_arm(&in_turn, &turn_tracker, send_was_busy); - self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); - return; + self.take_back_unwritten_send(&ledger, &stamp, session_type, &submitted_turn_id); + return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); }; // Address the sidecar by ITS id for this session (== the map key for created // sessions; differs for resumed-on-attach sessions, Task 6). - let checkpoint_attempt = checkpoint_attempt_for_send(&msg); - let mut send_req = - json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": msg.text }); - if let Some(attempt) = checkpoint_attempt { + let mut send_req = json!({ + "type": "send", + "sessionId": session.sidecar_session_id, + "uuid": submitted_turn_id, + "text": msg.text, + }); + let writer = session.stdin.clone(); + drop(guard); + if let Some(attempt) = checkpoint_attempt_for_send(&msg) { send_req["checkpointAttempt"] = attempt; } if let Some(images) = msg.images { send_req["images"] = json!(images); } - if let Err(err) = write_line(&mut session.stdin, &send_req).await { - drop(guard); - // The write never went out — undo EXACTLY our own arm (ep2-r2: - // recomputed from the surviving tracker, so a still-queued earlier - // op keeps the busy truth instead of a blanket clear wedging or - // falsely freeing the gate). - undo_turn_op_arm(&in_turn, &turn_tracker, send_was_busy); - self.send_error(&request_id, "CLAUDE_SEND_FAILED", &err); - return; + if let Err(err) = writer.write(&send_req).await { + // The write never went out: take back exactly this send (whatever + // else is owed stays). + self.take_back_unwritten_send(&ledger, &stamp, session_type, &submitted_turn_id); + return self.refuse_send(&request_id, "CLAUDE_SEND_FAILED", &err); } - drop(guard); drop(ownership_guard); // Unified agent names (Task 4): the shared accepted-input callback — @@ -4553,9 +4473,13 @@ impl FreshClaudeState { session_id, session_type: session_type.to_string(), cwd: msg.cwd, - submitted_turn_id: None, + submitted_turn_id: Some(submitted_turn_id.clone()), }, )); + ClaudeSendOutcome::Accepted { + session_key: map_key, + submitted_turn_id, + } } /// The caller owns the session turn lock. Apply changed settings before any @@ -4567,7 +4491,7 @@ impl FreshClaudeState { session_type: &str, ) -> Result<(), String> { let request_id = uuid::Uuid::new_v4().to_string(); - let (response, next) = { + let (response, next, writer, frame) = { let mut sessions = self.sessions.lock().await; let session = sessions .get_mut(map_key) @@ -4593,7 +4517,7 @@ impl FreshClaudeState { if next == session.configuration.settings && !session.configuration.needs_configure { return Ok(()); } - if session.in_turn.load(std::sync::atomic::Ordering::SeqCst) { + if session.ledger.lock().expect("turn ledger lock").busy() { return Err( "Wait for the current turn to finish before changing agent settings.".into(), ); @@ -4607,12 +4531,21 @@ impl FreshClaudeState { "permissionMode": next.permission_mode, "cwd": next.cwd, }, }); - if let Err(err) = write_line(&mut session.stdin, &frame).await { - session.configuration.pending = None; - return Err(err); - } - (rx, next) + (rx, next, session.stdin.clone(), frame) }; + if let Err(err) = writer.write(&frame).await { + if let Some(session) = self.sessions.lock().await.get_mut(map_key) { + if session + .configuration + .pending + .as_ref() + .is_some_and(|(id, _)| id == &request_id) + { + session.configuration.pending = None; + } + } + return Err(err); + } let applied = match tokio::time::timeout(SIDECAR_CREATE_BUDGET, response).await { Ok(Ok(result)) => result, Ok(Err(_)) => { @@ -4663,7 +4596,7 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = msg.request_id.clone(); - let Some((map_key, turn_lock, _, _, durable_before_wait)) = + let Some((map_key, turn_lock, _, durable_before_wait)) = self.turn_locked_handles(&session_id).await else { self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); @@ -4762,27 +4695,31 @@ impl FreshClaudeState { /// The shared Task 2 handler prologue: resolve the client-addressed id to the /// sessions-map key (`resolve_session_key` discipline — map key OR durable UUID via - /// `cli_index`) and take a session-scoped guard for the sidecar write. A miss at + /// `cli_index`) and clone session handles for the sidecar write. A miss at /// either step broadcasts the nested `INVALID_SESSION_ID` lost-session envelope /// (engaging the client's recovery) and yields `None`. - async fn respond_session_guard( + async fn response_session( &self, session_id: &str, session_type: SessionType, - ) -> Option> { + ) -> Option { let Some(map_key) = self.resolve_session_key(session_id).await else { self.broadcast(&lost_session_frame(session_id, session_type)); return None; }; let guard = self.sessions.lock().await; - if !guard.contains_key(&map_key) { + let Some(session) = guard.get(&map_key) else { drop(guard); self.broadcast(&lost_session_frame(session_id, session_type)); return None; - } - Some(tokio::sync::MutexGuard::map(guard, |map| { - map.get_mut(&map_key).expect("key checked above") - })) + }; + Some(ResponseSession { + map_key, + ownership_id: session.ownership_id.clone(), + stdin: session.stdin.clone(), + sidecar_session_id: session.sidecar_session_id.clone(), + pending: session.pending.clone(), + }) } /// Handle a `freshAgent.approval.respond` for claude/kilroy: forward a @@ -4802,10 +4739,7 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = request_id_string(&msg.request_id); - let Some(mut session) = self - .respond_session_guard(&session_id, msg.session_type) - .await - else { + let Some(session) = self.response_session(&session_id, msg.session_type).await else { return; }; if !session @@ -4877,17 +4811,13 @@ impl FreshClaudeState { "requestId": request_id, "decision": msg.decision, }); - if let Err(err) = write_line(&mut session.stdin, &respond_req).await { + if let Err(err) = session.stdin.write(&respond_req).await { drop(session); self.emit_fresh_agent_error(&session_id, session_type, "INTERNAL_ERROR", &err); return; } - session - .pending - .lock() - .expect("pending lock") - .permissions - .retain(|p| p.request_id != request_id); + self.complete_pending_response(&session, &request_id, PendingResponse::Approval) + .await; } /// Handle a `freshAgent.question.respond` for claude/kilroy: identical ordering @@ -4899,10 +4829,7 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = request_id_string(&msg.request_id); - let Some(mut session) = self - .respond_session_guard(&session_id, msg.session_type) - .await - else { + let Some(session) = self.response_session(&session_id, msg.session_type).await else { return; }; if !session @@ -4933,17 +4860,13 @@ impl FreshClaudeState { "requestId": request_id, "answers": Value::Object(answers), }); - if let Err(err) = write_line(&mut session.stdin, &respond_req).await { + if let Err(err) = session.stdin.write(&respond_req).await { drop(session); self.emit_fresh_agent_error(&session_id, session_type, "INTERNAL_ERROR", &err); return; } - session - .pending - .lock() - .expect("pending lock") - .questions - .retain(|q| q.request_id != request_id); + self.complete_pending_response(&session, &request_id, PendingResponse::Question) + .await; } /// Handle a `freshAgent.compact` for claude/kilroy: write the legacy adapter's plain @@ -4952,34 +4875,18 @@ impl FreshClaudeState { /// `handle_send`'s send.accepted broadcast; the turn is observable through the /// normal `sdk.*` stream). /// - /// Delta-r1 F1 (busy discipline): a compact IS a turn — the handler mirrors - /// [`Self::handle_send`] exactly: take the session `turn_lock` (an undo's - /// replacement takes it over, and the compact follows the replacement, so - /// the check-then-set window against the rollback busy gate is closed), and - /// set the busy truth - /// (`in_turn`) UNDER the lock BEFORE the sidecar write. A compact turn ends at - /// the SDK's `sdk.result` frame (any subtype) — the existing four-edge clear - /// set terminates it (plus `sdk.status:idle`, sidecar EOF, or a completed - /// interrupt); a FAILED write clears what we set (nothing was submitted). - /// - /// Focused-review ep1-r1 F1 (queued-compact busy truth; ep1-r2 F2's - /// FIFO-aware disarm; ep1-r3 F3's pre-await arm; ep2-r2's order-explicit - /// re-model): a compact submitted WHILE a turn is active queues BEHIND it - /// on the provider, so the handler arms the session's - /// [`ClaudeSession::turn_tracker`] with a distinct queued entry UNDER the - /// turn lock, BEFORE the sidecar write await — the consumer's - /// terminal-edge fold never takes that lock, so only a pre-await arm - /// closes the mid-window race. The pane stays busy until the compact's OWN - /// terminal edge lands, or a disarm case (drop peel / sdk.error / EOF) - /// proves the queue ended differently. A no-write failure SYNCHRONOUSLY - /// undoes exactly the arm's own entry ([`undo_turn_op_arm`]) and recomputes - /// `in_turn` from the surviving tracker — never a stale whole-set restore. + /// A compact is a command like any send (T15): the handler mirrors + /// [`Self::handle_send`]. It takes the session `turn_lock` (an undo's + /// replacement takes it over, and the compact follows the replacement) and + /// arms the turn ledger with the compact's own uuid under the lock, before + /// the sidecar write, so the rollback gate sees it from then on. The + /// compact's own result settles it; a failed write takes back exactly it. pub async fn handle_compact(&self, msg: FreshAgentCompact) { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); // The `handle_send` resolve discipline: a miss is a session that is gone. - let Some((map_key, turn_lock, in_turn, turn_tracker, durable_before_wait)) = + let Some((map_key, turn_lock, ledger, durable_before_wait)) = self.turn_locked_handles(&session_id).await else { self.broadcast(&lost_session_frame(&session_id, msg.session_type)); @@ -4991,7 +4898,7 @@ impl FreshClaudeState { let _turn = turn_lock.lock().await; // An undo or redo that held this lock may have replaced the runtime // (C13): the compact runs on the replacement the pane now shows, which - // took over this lock and the busy truth (`in_turn`, `turn_tracker`). + // took over this lock and the turn ledger. let Some(live_key) = self .live_key_after_turn_wait( TurnLockedRequest::Compact, @@ -5011,25 +4918,19 @@ impl FreshClaudeState { live_key.clone() }; let map_key = live_key; - // ep1-r3 F3 (the arm/await race): with a prior turn active this compact is - // QUEUED — arm the tracker NOW, BEFORE the sidecar write await. The stdout - // consumer's terminal-edge fold NEVER takes the turn lock, so an arm - // landing only after the await lets the prior turn's terminal edge fold - // past the unarmed tracker mid-write (busy dies with the compact still - // owed). A no-write failure leg SYNCHRONOUSLY undoes exactly this arm's - // own entry below ([`undo_turn_op_arm`]): a compact the blocked sidecar - // never received must not hold the pane busy. ep2-r2: the arm is a - // distinct FIFO entry — N queued compacts are N entries retiring at their - // own observed terminal edges (ep1-r5 F1), and a compact queued BEHIND a - // garlanded send survives that send's drop peel (ep2-r2 F1). - let was_busy = arm_turn_op(&in_turn, &turn_tracker, TrackedOp::Compact); - let mut guard = self.sessions.lock().await; - let Some(session) = guard.get_mut(&map_key) else { + // Arm before the write (as a send does): the compact is owed from now on. + let uuid = crate::claude_turns::mint_send_uuid(); + let stamp = self.envelope_stamp(&map_key).await; + ledger.lock().expect("turn ledger lock").arm( + uuid.clone(), + None, + crate::claude_turns::SendKind::Compact, + ); + self.publish_claude_status(&ledger, &stamp, session_type, false); + let guard = self.sessions.lock().await; + let Some(session) = guard.get(&map_key) else { drop(guard); - // Nothing was submitted — undo ONLY what our arm edited (the busy - // truth must never wedge; a prior turn's truth is not ours to - // clear). - undo_turn_op_arm(&in_turn, &turn_tracker, was_busy); + self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); self.broadcast(&lost_session_frame(&session_id, msg.session_type)); return; }; @@ -5040,20 +4941,21 @@ impl FreshClaudeState { _ => "/compact".to_string(), }; // Address the sidecar by ITS id for this session (== the map key for created - // sessions; differs for resumed-on-attach sessions, Task 6). - let send_req = - json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": text }); - if let Err(err) = write_line(&mut session.stdin, &send_req).await { - drop(guard); - // The write never went out — no turn was submitted: SYNCHRONOUSLY - // undo the arm's own entry (the handle_send fail-closed leg extended - // to the tracker; the failure path never holds the blocked - // sidecar's pane busy over a compact it never received). - undo_turn_op_arm(&in_turn, &turn_tracker, was_busy); + // sessions; differs for resumed-on-attach sessions, Task 6). A compact is a + // command like any send: it carries its own uuid (C16). + let send_req = json!({ + "type": "send", + "sessionId": session.sidecar_session_id, + "uuid": uuid, + "text": text, + }); + let writer = session.stdin.clone(); + drop(guard); + if let Err(err) = writer.write(&send_req).await { + // The write never went out: take back exactly this compact. + self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); self.emit_fresh_agent_error(&session_id, session_type, "INTERNAL_ERROR", &err); } - // The compact write was ACCEPTED; its tracker entry (armed pre-write - // above) STAYS — only its own terminal edge (or a disarm case) retires it. } /// The session's live pending approvals + questions as overlay JSON (approvals, @@ -5128,16 +5030,8 @@ impl FreshClaudeState { pub(crate) async fn apply_snapshot_metadata(&self, any_id: &str, snapshot: &mut Value) { let live = match self.resolve_session_key(any_id).await { Some(key) => self.sessions.lock().await.get(&key).map(|session| { - let status = session.current_status(); - let status = if status == "idle" - && session.in_turn.load(std::sync::atomic::Ordering::SeqCst) - { - "running".to_string() - } else { - status - }; ( - status, + session.current_status(), session.configuration.settings.clone(), session.configuration.commands.clone(), ) @@ -5208,10 +5102,9 @@ impl FreshClaudeState { request_id: &Option, session_id: &str, session_type: SessionType, - ) { + ) -> ClaudeSendOutcome { let Some(dead) = self.dead_sessions.find(session_id) else { - self.send_error(request_id, "SESSION_NOT_FOUND", "claude session not found"); - return; + return self.refuse_send(request_id, "SESSION_NOT_FOUND", "claude session not found"); }; tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER, session_id = %session_id, @@ -5224,6 +5117,10 @@ impl FreshClaudeState { ErrorCode::InvalidSessionId, crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE, ); + ClaudeSendOutcome::Refused { + code: "INVALID_SESSION_ID".to_string(), + message: crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE.to_string(), + } } pub(crate) async fn local_snapshot_owner( @@ -5277,6 +5174,7 @@ impl FreshClaudeState { name_ref, session_name, }); + self.edges.rekeyed(old_id, new_id); if let Ok(frame) = serde_json::to_string(&message) { if let Err(error) = self .checkpoint_event_sink @@ -5669,7 +5567,11 @@ impl FreshClaudeState { // ack speaks is the consumer's tracked-status fold — truth at // ack time (a completed turn settles it; a live compaction or // in-flight turn announces truthfully). - *session.broadcast_id.lock().expect("broadcast id lock") = durable.to_string(); + let previous = std::mem::replace( + &mut *session.broadcast_id.lock().expect("broadcast id lock"), + durable.to_string(), + ); + self.edges.rekeyed(&previous, durable); Some(session.current_status()) } None => None, @@ -6704,13 +6606,15 @@ impl FreshClaudeState { /// `pending` (Task 2): the shared pending approval/question handle the consumer /// folds `sdk.permission.*`/`sdk.question.*` lines into BEFORE the normalize/ /// broadcast step (so a respond racing the event never sees stale membership). - /// `last_status`: the shared tracked-status handle the attach acks read; the - /// consumer folds the turn lifecycle (`sdk.assistant` → "running", every - /// `sdk.result` → "idle" — the reference bridge's semantics) and the raw - /// `sdk.status` wire values on top into it BEFORE the broadcast step (so an - /// ack racing a status event never understates the tracked status). The - /// result-edge settle means a mid-turn "compacting" can never wedge the - /// tracker past the turn's completion. + /// `ledger` (C17, design §5.2): the session's turn ledger. The consumer + /// folds every line into it BEFORE anything else reads the line (so a gate + /// or an attach acknowledgement racing the line never understates it) and + /// publishes the session's effective status on change: before a content + /// frame is forwarded (so `running` precedes the first assistant frame), and + /// after a result is forwarded (the result carries `sessionBusy`). C18: the + /// ledger's verdict on each result says whether it rings, and every edge + /// (a result, a waiting edge, a death while a turn was owed) leaves through + /// the edge hub on the session's `clock`. #[allow(clippy::too_many_arguments)] // Session-scoped wiring handed to the detached consumer; four call sites. fn spawn_consumer( &self, @@ -6721,36 +6625,12 @@ impl FreshClaudeState { settings: Option, broadcast_id: Arc>, pending: Arc>, - last_status: Arc>, - // kata 1wxv Task 4: the session's shared busy truth — cleared by the - // EXACTLY-four contract edges (`sdk.result` any subtype, `sdk.status` - // idle, EOF/death below, completed handle_interrupt at the write site). - in_turn: Arc, - // Focused-review ep1-r1 F1 / ep1-r2 F2 / ep2-r2: the session's FIFO turn - // tracker (handed over whole; the arms live in the handlers under the - // turn lock, the promote/retire/disarm folds live here). `in_turn` is - // its DERIVED busy cache — this task's folds recompute it from - // [`TurnTracker::busy`] at every terminal edge: - // (a) result/idle while a compact was PROMOTED (manual-confirmed - // by its completion boundary, ep3-r1 F1) — the - // compact's own terminal edge: retire `running`, hold busy while - // anything remains outstanding (ep1-r3 F1); - // (b) the FIFO-drop peel — result/idle with NO running op and - // an UNPROMOTED compact at the queue head: the compact provably - // dropped, evidenced by the oldest queued Turn behind it (peel - // leading compacts up to that Turn; compacts queued BEHIND the send - // survive — ep2-r2 F1); - // (c) `sdk.error` (the queued ops provably never arrive) clears the - // QUEUE (never `running`/`in_turn` — fail-closed; the running op's - // own terminal edge or EOF retires it), or EOF (the queue died - // with the sidecar) zeroes everything. - turn_tracker: Arc>, - // Task 5 (unified needs-attention signal): the session's Rust-side - // completion clock — see [`ClaudeSession::last_synthesized_complete_at`]. - // Held by the consumer (like the tracker above) so the unrequested-death - // arm below can mint AFTER the eviction has already torn the session - // record out of the map. - last_synthesized_complete_at: Arc>>, + ledger: crate::claude_turns::SharedLedger, + // The session's attention clock (see [`ClaudeSession::clock`]). Held by + // the consumer (like the ledger above) so the unrequested-death arm + // below can mint AFTER the eviction has already torn the session record + // out of the map. + clock: crate::attention::AttentionClock, // D8: the creating connection's provenance stamps, handed to the // in-stream `sdk.session.init` adoption. `None` on conn-less consumers // (rollback replacement, attach-resume) — the ledger merge keeps prior @@ -6777,34 +6657,55 @@ impl FreshClaudeState { stream_ended = Some(ended); continue; } - // kata 1wxv Task 4 busy-truth clear edges — IN-STREAM arms (a) and - // (b): `sdk.result` with ANY subtype ends the turn (r2: the earlier - // success-only wording is void), and `sdk.status:idle`. NO other - // in-stream edge (sdk.error/compacting/assistant never clear — - // fail-closed; a missing arm wedges BUSY_TURN refusals forever). - // Focused ep1-r2 F2 + ep2-r2 (order-explicit FIFO attribution): - // [`fold_terminal_edge`] retires the OLDEST outstanding tracked op - // — the `running` slot first (an observed-promoted compaction or - // the idle-armed op), else a queued send, peeling an unpromoted - // compact ONLY when a following queued send evidences its silent - // drop (a compact queued BEHIND the send survives, ep2-r2 F1); - // an unexplained edge with only compacts outstanding holds - // fail-closed. `in_turn` is stored as the recomputed - // [`TurnTracker::busy`], so the gate releases at EXACTLY the last - // outstanding op's own edge. Any `sdk.error` clears the QUEUE - // outright (the queued ops provably never arrive) while leaving - // the running op / `in_turn` fail-closed untouched. - // A turn ends with its `sdk.result` alone. The real sidecar - // sends no `sdk.status:idle` at all since C09 (its stream's end - // is `sdk.stream.ended` and its exit, G23); only the legacy - // protocol fakes still send one, until C17/C18 delete this arm. - // An idle is never a result's trailing punctuation, so it - // always folds. - // ep4-r2 F2: the fold NEVER serializes behind rollback's - // turn_lock (blocking it let rollback absorb compact debt and - // then miss a compaction that started in the sidecar behind - // its own recheck). Ordering is instead restored at the - // SOURCE by rollback's quiesce probe (see handle_rollback). + // C17: fold the line into the session's turn ledger (design + // §5.2) before anything else reads it: a gate, an attach + // acknowledgement or the status published below sees it. + let step = state.fold_into_ledger(&ledger, &value, &broadcast_id, &session_type); + // C18: a latched Stop that missed a command the CLI was still + // starting is sent again now (design §5.4); a refused one is + // disarmed (the turn keeps running and rings when it ends). + match &step { + LedgerStep::Reissue(reissue) => { + let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + state + .reissue_stop( + &session_id, + &sidecar_session_id, + &stamp, + &session_type, + reissue, + ) + .await; + } + LedgerStep::StopRefused { stop_id } => { + let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + state.log_stop_refused(&stamp, &session_type, stop_id); + } + LedgerStep::Nothing | LedgerStep::Settled(_) => {} + } + // C16: the lifecycle facts the sidecar forwards for turn + // accounting (a command's lifecycle, the session state, a status + // end or a model request, heartbeats) are never client content: + // logged, never broadcast. A send the sidecar refused is taken + // back. C17: nor is any status the sidecar reports: the server + // publishes the session's status itself. + if let Some(fact) = crate::claude_turns::SidecarFact::from_line(&value) { + let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + match fact { + crate::claude_turns::SidecarFact::SendRejected(rejection) => state + .take_back_rejected_send(&ledger, &stamp, &session_type, &rejection), + fact => fact.log(&stamp, &session_type), + } + state.publish_claude_status(&ledger, &stamp, &session_type, false); + continue; + } + if value.get("type").and_then(Value::as_str) == Some("sdk.status") { + let status = value.get("status").map(ToString::to_string); + tracing::debug!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %session_id, status = ?status, + "freshagent.claude.status_dropped: the server publishes the status"); + continue; + } match value.get("type").and_then(Value::as_str) { Some("sdk.session.changed") if value["reason"] == "session-commands" => { if let Some(commands) = value["commands"].as_array() { @@ -6890,178 +6791,42 @@ impl FreshClaudeState { } } Some("sdk.result") => { - fold_terminal_edge(&in_turn, &turn_tracker); // Core design §4.1: a session booted from its genesis // writes its transcript around its first result; clear // the genesis once the transcript is on disk. state.spawn_genesis_clear_after_result(&session_id); } - Some("sdk.status") - if value.get("status").and_then(Value::as_str) == Some("idle") => - { - fold_terminal_edge(&in_turn, &turn_tracker); - } - Some("sdk.status") - if value.get("status").and_then(Value::as_str) == Some("compacting") => - { - // A compaction is observably running NOW — trigger - // unknown (manual or automatic): mark the CANDIDATE - // only (ep3-r1 F1; promotion waits for the manual - // completion boundary). - mark_compact_candidate(&in_turn, &turn_tracker); - } - Some("sdk.compact_boundary") => { - // The compaction's completion boundary is the ONLY - // wire-level witness of its trigger - // (`compact_metadata.trigger`; the sidecar fails toward - // `auto`). A `manual` boundary promotes the OLDEST - // queued Compact into the `running` slot (its coming - // terminal edge then retires it there); `auto` promotes - // nothing. - confirm_compact_candidate( - &in_turn, - &turn_tracker, - value.get("trigger").and_then(Value::as_str) == Some("manual"), - ); - } - Some("sdk.interrupt_settled") => { - // Focused ep4-r1 (repaired at ep4-r2): the settle is a - // QUIESCE ACK, never a retirement — per the SDK contract - // (the control receipt is written BEFORE the - // interrupted op's terminal `sdk.result`) the op's own - // result owns the fold. Its fold-time role here: - // ok:true + a live compact candidate => the - // in-flight compaction IS the interruption's - // subject: PROMOTE the front queued compact into - // `running` (the exact promotion the manual - // boundary would have performed had the compact - // completed) so the trailing result retires - // exactly it and every op queued behind survives. - // A rejected/absent settle (the turn provably - // still running) never promotes and retires - // nothing (warn-log only — fail-closed). - // ep4-r3 F2: it NEVER fires a rollback probe — only a - // probeId-correlated `sdk.rollback.quiesced` does (the - // arm below). - let ok = value.get("ok").and_then(Value::as_bool) == Some(true); - if !ok { - tracing::warn!(session = %session_id, "freshagent.claude.interrupt_rejected_or_unsettled"); - } else { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - if tracker.compact_candidate - && tracker.running.is_none() - && matches!(tracker.queued.front(), Some(TrackedOp::Compact)) - { - tracker.queued.pop_front(); - tracker.running = Some(TrackedOp::Compact); - tracker.compact_candidate = false; - } - // An UNPROMOTABLE candidate (debt already absorbed — - // nothing left in the queue to promote — or a turn - // still in `running`) SURVIVES the settle as the - // recheck-visible revived evidence (ep4-r2 F2). - // in_turn is never re-stored here: promotion - // preserves busy; a live candidate's ep3-r4 re-boost - // must never be clobbered by a bare busy() write. - } - } - Some("sdk.rollback.quiesced") => { - // Focused ep4-r3 (probe protocol): the sidecar answered - // a rollback quiesce from its OWN queue truth. Fire the - // armed probe ONLY on a probeId match — stale receipts - // (an ordinary interrupt's settle, an earlier timed-out - // probe's late answer) never close a live probe, and a - // rejected-shape answer is fail-closed in the handler - // (busy signals abort; a timeout also aborts). - let frame_probe_id = - value.get("probeId").and_then(Value::as_str).unwrap_or(""); - let verdict = QuiesceVerdict { - cancelled_queue: value - .get("cancelledQueue") - .and_then(Value::as_u64) - .unwrap_or(0), - in_flight_turn: value - .get("inFlightTurn") - .and_then(Value::as_bool) - .unwrap_or(true), - handed_compact_likely: value - .get("handedCompactLikely") - .and_then(Value::as_bool) - .unwrap_or(true), - }; - if let Some(session) = sessions.lock().await.get(&session_id) { - let fired = { - let mut slot = session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock"); - match slot.as_ref() { - Some((armed_id, _)) if armed_id == frame_probe_id => { - slot.take() - } - _ => None, - } - }; - if let Some((_, tx)) = fired { - let _ = tx.send(verdict); - } else { - tracing::debug!(session = %session_id, probe_id = %frame_probe_id, "freshagent.claude.quiesced_unmatched"); - } - } - } - Some("sdk.error") if value["turnFailure"].as_bool() != Some(true) => { - // The queued ops provably never arrive as - // their own turns — clear the QUEUE outright (the running - // op and `in_turn` stay fail-closed for its own terminal - // edge or EOF to retire). - let session_dead = - value.get("sessionNotFound").and_then(Value::as_bool) == Some(true); - { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - tracker.queued.clear(); - if session_dead { - // ep3-r2 F2: the sidecar does not host the - // named session (handleSend/handleInterrupt - // answer `sdk.error` with stdout still OPEN), - // so NO terminal edge or EOF will ever retire - // what remains. Every tracked op — - // including `running` — can never complete: - // retire the whole tracker and open the gate - // (else the pane wedges BUSY forever after an - // interrupted/deleted provider session). - tracker.running = None; - tracker.compact_candidate = false; - in_turn.store(false, std::sync::atomic::Ordering::SeqCst); - } - } + // A protocol fake that predates C18 still sends its own + // edges: the server mints every edge itself, so they are + // dropped (the fakes are retired by C30). + Some(legacy @ ("sdk.turn.complete" | "sdk.turn.waiting")) => { + tracing::debug!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %session_id, line_type = legacy, + "freshagent.claude.legacy_edge_dropped: the server mints every edge"); + continue; } _ => {} } - // Task 2: fold the pending approval/question state BEFORE the - // normalize/broadcast step, so a respond racing the event never - // observes a stale membership check. - fold_pending_frame(&pending, &value); - // Fold the session's tracked status BEFORE the broadcast step, so an - // ack racing an event never understates it. Mirror the reference - // bridge's lifecycle (server/sdk-bridge.ts): sdk.assistant marks the - // turn "running" (:426), EVERY sdk.result settles it back to "idle" - // (:445) — so a mid-turn "compacting" can never wedge the tracker - // for the rest of the session's life — and the raw sdk.status wire - // value folds on top (:351-352 compacting announces truthfully - // mid-turn; only the legacy protocol fakes still send an idle). - match value.get("type").and_then(Value::as_str) { - Some("sdk.assistant") => { - *last_status.lock().expect("last status lock") = "running".to_string(); - } - Some("sdk.result") => { - *last_status.lock().expect("last status lock") = "idle".to_string(); - } - Some("sdk.status") => { - if let Some(status) = value.get("status").and_then(Value::as_str) { - *last_status.lock().expect("last status lock") = status.to_string(); - } + // Pending mutation and its request/attention publication are + // one ordered operation, fenced to this runtime. A response + // may otherwise drain the set between the fold and its edge. + if is_pending_frame(&value) { + state + .publish_pending_frame(&session_id, &pending, &value, &session_type) + .await; + continue; + } + // The status a content line changed is published before the line + // is forwarded (`running` precedes the first assistant frame); a + // result instead says whether the session is still busy, and the + // status follows it. + let mut value = value; + match &step { + LedgerStep::Settled(settled) => value["sessionBusy"] = json!(settled.busy), + _ => { + let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + state.publish_claude_status(&ledger, &stamp, &session_type, false); } - _ => {} } // Restart-parity (plan §2.8 item 2): record the durable Claude UUID // through the shared adoption block (cli_index insert is @@ -7095,27 +6860,8 @@ impl FreshClaudeState { // Task 10b: stamp the envelope from the SHARED handle (not the captured // map key) so an attach-by-durable rebind flips live event routing. let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - let line_type = value.get("type").and_then(Value::as_str).unwrap_or(""); crate::claude_live_events::log_provider_event(&value, &stamp, &session_type); - if let Some(edge) = AttentionEdge::from_sidecar_type(line_type) { - // The sidecar's attention edges leave through the one edge - // exit (logged there, G14). - match value.get("at").and_then(Value::as_i64) { - Some(at) => state.emit_attention_edge( - edge, - &stamp, - &sidecar_session_id, - &session_type, - at, - AttentionEdgeSource::Sidecar, - ), - // The client drops an edge without a numeric `at`; say so here. - None => tracing::warn!(target: "freshell_freshagent::claude", - provider = PROVIDER, session_id = %stamp, - session_type = %session_type, edge = edge.wire_type(), - "freshagent.claude.attention_edge_dropped_malformed_at"), - } - } else if let Some(frame) = sdk_line_to_frame(&value, &stamp, &session_type) { + if let Some(frame) = sdk_line_to_frame(&value, &stamp, &session_type) { if value.get("type").and_then(Value::as_str) == Some("sdk.checkpointTurnIdentity") { @@ -7130,41 +6876,48 @@ impl FreshClaudeState { let _ = broadcast_tx.send(frame); } } + let record = crate::claude_edges::EdgeRecord { + session_key: &session_id, + stamp: &stamp, + sidecar_session_id: &sidecar_session_id, + session_type: &session_type, + clock: &clock, + }; + if let LedgerStep::Settled(settled) = step { + state.ring_settled_turn(&record, settled); + state.publish_claude_status(&ledger, &stamp, &session_type, true); + } } - // Task 5 (unified needs-attention signal): capture the armed state - // BEFORE this block clears it. By the time the unrequested-death arm - // below runs, `in_turn` is already false and the session record is - // already evicted — the death mint must read THIS captured value - // (the clock + id handles it needs are consumer-owned locals that - // already survive the eviction). - let turn_was_in_flight = in_turn.load(std::sync::atomic::Ordering::SeqCst); - // kata 1wxv Task 4 busy-truth clear edge (c): sidecar EOF/death clears - // the busy truth BEFORE the eviction verdict below (an unrequested - // death can never hold a rollback BUSY_TURN hostage). The dead - // sidecar's whole input queue died with it — zero the entire FIFO - // tracker. The fold never holds rollback's turn lock (ep4-r2 F2: - // blocking evidence behind teardown's lock re-armed the exact race - // the lock was meant to close; ordering is restored by - // handle_rollback's quiesce probe instead). - in_turn.store(false, std::sync::atomic::Ordering::SeqCst); - { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - tracker.running = None; - tracker.queued.clear(); - tracker.compact_candidate = false; - } + // C17: the end of output ends whatever the CLI owed (its queue died + // with it, and so did any Stop), before the eviction verdict below + // (an unrequested death can never hold a rollback BUSY_TURN + // hostage). Whether a turn was owed is captured here, because by the + // time the unrequested-death arm below runs the ledger is cleared + // and the session record evicted (the clock and id handles it needs + // are consumer-owned locals that survive the eviction). + let turn_was_in_flight = ledger.lock().expect("turn ledger lock").eof(); // Consumer exit == this sidecar's stdout closed == the sidecar ended // (ledger A9): it exits after reporting its SDK stream's end // (`sdk.stream.ended`, G02), or the Node process died. Evict the dead // session and its cli_index entries, identity-guarded: a newer session - // re-registered under the same map key (attach-resume, Task 6) has a - // DIFFERENT sidecar_session_id and must not be evicted by this stale - // consumer. + // re-registered under the same map key has a different pending-set + // allocation, even when it reuses a durable sidecar session id, and + // must not be evicted by this stale consumer. let evicted = { let mut map = sessions.lock().await; match map.get(&session_id) { - Some(s) if s.sidecar_session_id == sidecar_session_id => { - map.remove(&session_id) + Some(s) + if s.sidecar_session_id == sidecar_session_id + && Arc::ptr_eq(&s.pending, &pending) => + { + let stamp = s.broadcast_id.lock().expect("broadcast id lock").clone(); + let removed = map.remove(&session_id); + // The old runtime has no actionable pending cards. Clear + // waiting attention before any teardown await or new + // registration; clearing it after reaping could erase a + // resumed runtime's newly raised request (G25). + state.edges.pending_drained(&stamp); + removed } _ => None, } @@ -7225,6 +6978,7 @@ impl FreshClaudeState { settings: &evicted.configuration.settings, commands: evicted.configuration.commands.as_ref(), reason, + clock: &clock, }); state.test_holds.exit_report().await; // Stop the ended sidecar through the one sidecar stop every @@ -7274,7 +7028,9 @@ impl FreshClaudeState { // UNREQUESTED-end only: `handle_kill`/`shutdown`/attach-teardown all // remove the map entry (and abort this consumer) first, so nothing is // evicted here and it stays silent. - let rings = crate::claude_sidecar_exit::death_rings(turn_was_in_flight, &cause); + // C18: an unrequested end of a turn the user was owed rings, + // however the sidecar ended (it reports no edge of its own). + let rings = turn_was_in_flight; tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER, session_id = %stamp, @@ -7316,327 +7072,75 @@ impl FreshClaudeState { "freshagent.claude.exit_report_skipped: the session was resumed or \ closed while its dead sidecar was reaped"); } - // Task 5 (unified needs-attention signal): an UNREQUESTED end of a - // running turn is a turn end the user did not witness, even when a - // resume brought the session back during the stop. Interim - // rule (design §5.7, until C18): a sidecar that reported its - // stream's end already rang a result-less turn end itself, so the - // server rings only for a death that reported nothing (a Node - // death mid-turn) — exactly one edge either way. The armed state - // was captured at EOF entry above; the clearing + eviction in - // between have torn down the tracker and the session record, so - // the mint reads the CAPTURED value and the consumer-held clock - // Arc. Wall-clock advancement orders the Rust-minted `at`s against - // the sidecar-minted ones — the accepted regime, same as Task 3. - if rings { - let at = { - let mut guard = last_synthesized_complete_at - .lock() - .expect("synthesized clock lock"); - let at = next_monotonic_turn_complete_at( - *guard, - crate::rollback_record::now_ms(), - ); - *guard = Some(at); - at - }; - state.emit_attention_edge( - AttentionEdge::TurnComplete, - &stamp, - &sidecar_session_id, - &session_type, - at, - AttentionEdgeSource::ConsumerExit, - ); - } + // An UNREQUESTED end of a running turn is a turn end the user + // did not witness, even when a resume brought the session back + // during the stop: it rings once (design §5.7), from the + // session's clock, and the end is published either way. + state.edges.ended( + &crate::claude_edges::EdgeRecord { + session_key: &session_id, + stamp: &stamp, + sidecar_session_id: &sidecar_session_id, + session_type: &session_type, + clock: &clock, + }, + rings, + cause.reason(), + ); } }) } } -// ── FIFO turn tracker (ep2-r2) ─────────────────────────────────────────────────────────── - -/// Focused-review ep2-r2: the busy/attribution tracker is an EXPLICIT FIFO of -/// accepted ops. The counter approach it replaces could not encode QUEUE ORDER -/// — the reviewer's exact ep2-r2 repro: compact C1 → send S1 → compact C2 with -/// C1 silently provider-dropped, S1's terminal edge extinguished the WHOLE -/// compact count (C2 included) and released the rollback gate while C2 could -/// still run. Here every accepted op is a distinct queued entry retiring at its -/// OWN edge, so the gate's mid-turn protection (its SOLE protection per the -/// spec) is enumerable op-by-op. -/// -/// Stream invariants the attribution leans on (provider FIFO discipline): the -/// sidecar processes its input queue strictly in acceptance order; at most one -/// op runs at a time; EVERY op ends with exactly one logical terminal edge — -/// a turn emits `sdk.result` (any subtype) — the real sidecar sends no idle -/// (G23; only the legacy protocol fakes do, until C17/C18) — or a completed -/// interrupt stands in for the -/// running op's edge at the write site (no result frame exists, Task 4 edge -/// (d)); and a compact's terminal edge is ALWAYS preceded by its -/// manual-confirmed completion boundary in-stream (an unpromoted compact can -/// never own a terminal edge — the drop proof's premise, ep1-r2 F2; the bare -/// compacting STATUS frame is trigger-blind, ep3-r1 F1, so promotion waits for -/// the boundary's `manual` witness). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum TrackedOp { - /// A user send's turn (a "garlanded send" is simply a Turn queued behind - /// a compact — kind alone now, no separate count). - Turn, - /// A `/compact` op. Only a Compact ever carries (is promoted by) the - /// manual-confirmed completion boundary; the promotion retires the queue - /// entry into - /// the `running` slot. - Compact, -} - -/// The per-session FIFO attribution state (ep2-r2). `running` holds the ONE op -/// the provider is working on (a fresh op armed from idle owns it immediately; -/// a queued Compact moves here when its `sdk.status:compacting` is CONFIRMED as -/// a manual `/compact` run by its completion boundary (ep3-r1 F1) — -/// queued Turns never visibly "start" and instead retire straight out of the -/// queue at their own terminal edge, which is fine: attribution always retires -/// the OLDEST outstanding op). `queued` is the acceptance-ordered remainder. -/// `compact_candidate` marks a compaction announced by the bare -/// `sdk.status:compacting` frame whose trigger is not yet known. Shares ONE -/// std Mutex with no awaits inside any critical section; every -/// mutation recomputes the derived `in_turn` busy cache (`busy()`) in the same -/// critical section, so the rollback gate never reads a torn pair. -#[derive(Default)] -struct TurnTracker { - running: Option, - queued: std::collections::VecDeque, - compact_candidate: bool, -} - -impl TurnTracker { - /// The derived busy truth: the rollback gate's sole mid-turn input. - fn busy(&self) -> bool { - self.running.is_some() || !self.queued.is_empty() - } -} - -/// Arm one accepted op UNDER the session turn lock BEFORE the sidecar write: -/// an idle tracker takes the op as RUNNING, else it queues BEHIND every -/// outstanding op (FIFO). Either way the busy truth holds for the handler's -/// whole write window (the check-then-set window against `handle_rollback`'s -/// busy check stays closed). Returns `was_busy` for [`undo_turn_op_arm`]'s -/// exact undo (the arm site owns whether the op went to `running` vs -/// `queued`). -fn arm_turn_op( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, - op: TrackedOp, -) -> bool { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - let was_busy = tracker.busy(); - if was_busy { - tracker.queued.push_back(op); - } else { - tracker.running = Some(op); - } - in_turn.store(true, std::sync::atomic::Ordering::SeqCst); - was_busy -} - -/// SYNCHRONOUSLY undo EXACTLY the op this handler armed when the sidecar write -/// provably never happened (ep1-r3 F3's discipline, structural): pop our own -/// BACK entry (handler arms serialize on the session turn lock and the fold -/// only ever pops the FRONT, so ours is provably still last) — or clear -/// `running` when we were the idle-armed op. NEVER a stale whole-set restore: -/// the consumer's fold may have retired an EARLIER op mid-window, and the busy -/// truth is then recomputed from what genuinely survives (ep2-r2 F3: a reverted -/// arm with a send still queued behind an earlier op keeps the gate CLOSED — -/// the old spent-prior CAS cleared `in_turn` unconditionally right there). -fn undo_turn_op_arm( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, - was_busy: bool, -) { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - if was_busy { - tracker.queued.pop_back(); - } else { - tracker.running = None; - } - in_turn.store(tracker.busy(), std::sync::atomic::Ordering::SeqCst); -} +// ── pending-set fold (Task 2) ──────────────────────────────────────────────────────────── -/// One OBSERVED `sdk.status:compacting`: a compaction is running NOW — the -/// provider is provably in-flight (mid-turn auto or a `/compact` run), so the -/// gate CLOSES at this frame (ep3-r4 F1: after a SURVIVE-absorb opened the -/// gate, a revived compact's status must re-arm the busy truth at the status, -/// not at its later completion boundary, or the whole status→boundary -/// interval — and a status landing a beat after a probe's admission — admits -/// rollback while a compaction is provably executing). The frame carries NO -/// trigger, though, so it can NEVER promote a queued compact -/// (focused ep3-r1 F1): it marks a CANDIDATE; attribution waits for the -/// compaction's completion boundary, which consumes the candidate and -/// recomputes the gate. -fn mark_compact_candidate( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, -) { - // ep3-r5 F2 (torn publication): the candidate bit AND the gate-visible - // `in_turn` boost publish ATOMICALLY under the tracker mutex — any reader - // seeing `candidate == true` also provably sees `in_turn == true` (the - // gate reads `in_turn` first and must never slip a mark→boost pair). - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - tracker.compact_candidate = true; - in_turn.store(true, std::sync::atomic::Ordering::SeqCst); +/// How a line changed the session's pending approvals and questions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingChange { + /// From none to some: the session now waits on its user (the waiting edge). + Waiting, + /// From some to none. + Drained, } -/// The compaction's completion boundary (`sdk.compact_boundary {trigger}` — -/// the sidecar relays the SDK `compact_boundary` whose -/// `compact_metadata.trigger` the SDK fills as `'manual' | 'auto'`; the -/// sidecar fails toward `auto` on anything unknown). A `manual` boundary is -/// the ONLY wire-level witness that the compaction now finishing was requested -/// by `/compact`: promote the OLDEST queued Compact into the `running` slot -/// (FIFO-strict, every op ahead of it already produced its terminal edge, so -/// the `running` slot is empty exactly when the tracker's ordering holds — an -/// occupied slot means a compact armed from idle owns it (never promoted) or -/// the stream's ordering provably hasn't advanced; never promote past an -/// occupied slot, and never promote anything past a queued Compact either: -/// the candidate's trigger may be manual while the queued ops ahead are still -/// genuinely owed). An `auto` boundary (or no boundary — a failed compaction -/// settles at its own terminal edge instead) promotes NOTHING, and the -/// candidate is consumed either way. -fn confirm_compact_candidate( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, - boundary_manual: bool, -) { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - // The boundary confirms ONLY a LIVE candidate (ep3-r2 F1): a boundary - // arriving with NO pending candidate is a leftover (e.g. an interrupted - // compaction's late completion) and must never promote a LATER queued - // compact it was never observed for. - let had_candidate = tracker.compact_candidate; - tracker.compact_candidate = false; - if boundary_manual - && had_candidate - && tracker.running.is_none() - && matches!(tracker.queued.front(), Some(TrackedOp::Compact)) - { - tracker.running = tracker.queued.pop_front(); - // ep3-r3 F1: the promotion RE-ARMS the busy truth — a SURVIVE-absorbed - // compact whose debt left the gate open re-closes it the moment its - // run provably starts (revived compacts hold the mid-compaction gate). - in_turn.store(true, std::sync::atomic::Ordering::SeqCst); - } else { - // No promotion (auto boundary, a leftover, or no front Compact): the - // candidate is consumed and the gate recomputes from owed debt — - // ep3-r4 F1's status-time boost never wedges the tracker: queued - // compacts keep the gate closed (the absorb probe can still settle - // them later), an empty tracker re-opens it here. - in_turn.store(tracker.busy(), std::sync::atomic::Ordering::SeqCst); +impl PendingChange { + fn between(before: usize, after: usize) -> Option { + match (before, after) { + (0, 1..) => Some(Self::Waiting), + (1.., 0) => Some(Self::Drained), + _ => None, + } } } -/// The rollback gate's provable-quiescence admission (ep3-r3 F1). A queued -/// TURN never visibly "starts" (its first observable evidence IS its terminal -/// edge), so owed turn debt must hold the gate — a turn may be mid-generation -/// right now. A queued COMPACT is different: its run is announced by the -/// compacting status (the candidate) BEFORE its completion boundary. With -/// nothing in `running`, no pending candidate, and NO Turn anywhere in the -/// queue, every outstanding Compact is provably NOT started — the provider is -/// quiescent. Absorbing that debt means opening the gate WITHOUT clearing the -/// entries (SURVIVE-absorb): if one of them does run later, its status marks -/// the candidate and its manual boundary re-promotes it, RE-ARMING the busy -/// truth (`confirm_compact_candidate` stores `in_turn` on promotion) — a -/// genuinely-revived compact re-closes the gate mid-run exactly as expected, -/// while a rollback that proceeds tears down the sidecar and discards the -/// unstarted inputs. -/// -/// This is ALSO the sound resolution of the positionally-ambiguous boundary -/// promotion: the wire carries no compaction-op identity, so [C1 dropped, -/// C2 runs] is in-band indistinguishable from [C1 runs, C2 queued] — the -/// boundary promotes the FRONT compact either way, leaving the mislabeled -/// remnant as debt. Absorption at the gate is where that debt settles: the -/// provider is provably idle at that instant, so opening the gate is sound -/// (and never an early release of owed TURN work). -fn absorb_unstarted_compact_debt( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, -) -> bool { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - if tracker.running.is_none() - && !tracker.compact_candidate - && !tracker.queued.iter().any(|op| *op == TrackedOp::Turn) - { - in_turn.store(false, std::sync::atomic::Ordering::SeqCst); - true - } else { - false +impl ClaudePending { + fn len(&self) -> usize { + self.permissions.len() + self.questions.len() } -} -/// The in-stream TERMINAL-edge fold (`sdk.result` any subtype; `sdk.status:idle`, -/// which only the legacy protocol fakes still send: the real sidecar sends none -/// since C09). Attribution -/// retires the OLDEST outstanding op: -/// -/// (running) the edge belongs to the op in the `running` slot — the active -/// turn, or the promoted compaction whose manual-confirmed boundary put -/// it there. Retire it; -/// (drop peel) `running` empty and the OLDEST queued op is an UNPROMOTED -/// Compact: this edge cannot be its (a compact's terminal edge is ALWAYS -/// preceded by its own manual-confirmed boundary, and that promotion never -/// happened). The compact provably DROPPED — but ONLY because a following -/// queued Turn evidences it (this edge must belong to the oldest such -/// Turn: FIFO). Peel every leading unpromoted Compact up to that Turn — -/// compacts queued BEHIND the send need NO evidence handling: they remain -/// genuinely queued and survive (ep2-r2 F1). With NO Turn queued anywhere -/// behind, nothing can attribute the edge: fail-closed HOLD (`in_turn` -/// untouched — the gate stays closed, the old armed-with-neither-mark -/// wedge-avoiding discipline preserved); -/// (queued Turn) the edge retires the OLDEST queued Turn; -/// (nothing outstanding) the plain unarmed edge — clears the busy truth. -/// -/// `in_turn` is then stored as the recomputed `busy()`: the gate releases at -/// EXACTLY the last outstanding op's own edge — never earlier (each prior op -/// leaves the queue non-empty), and never wedged by a stale count. -fn fold_terminal_edge( - in_turn: &std::sync::atomic::AtomicBool, - turn_tracker: &std::sync::Mutex, -) { - let mut tracker = turn_tracker.lock().expect("turn tracker lock"); - // A terminal edge settles any compaction that never produced a completion - // boundary (e.g. a failed compact run — no boundary is emitted) — drop the - // unconsumed candidate; promotion happens only from `confirm_compact_candidate`. - tracker.compact_candidate = false; - if tracker.running.take().is_none() { - loop { - match tracker.queued.front().copied() { - Some(TrackedOp::Compact) => { - if tracker.queued.iter().any(|op| *op == TrackedOp::Turn) { - // The silent provider drop, evidenced by a following - // send: peel it and attribute onward. - tracker.queued.pop_front(); - continue; - } - // No following op can evidence attribution — fail-closed - // HOLD: leave `in_turn` exactly as it stands. - return; - } - Some(TrackedOp::Turn) => { - tracker.queued.pop_front(); - break; - } - None => break, - } - } + fn fold_frame(&mut self, value: &Value) -> Option { + let before = self.len(); + fold_pending_line(self, value); + PendingChange::between(before, self.len()) } - in_turn.store(tracker.busy(), std::sync::atomic::Ordering::SeqCst); } -// ── pending-set fold (Task 2) ──────────────────────────────────────────────────────────── - /// Fold one sidecar line into the session's pending approval/question state: /// `sdk.permission.request`/`sdk.question.request` PUSH (resend of the same requestId /// REPLACES — de-dupe), `sdk.permission.cancelled`/`sdk.question.cancelled` REMOVE. /// Every other line is a no-op here. The cancelled frames are additionally forwarded /// to the client (see `normalize_sdk_type`) — the fold never suppresses a broadcast. -fn fold_pending_frame(pending: &std::sync::Mutex, value: &Value) { +/// Returns how the line changed the pending set's emptiness (C18: the server's +/// waiting edge rides the 0 -> >=1 change). +#[cfg(test)] +fn fold_pending_frame( + pending: &std::sync::Mutex, + value: &Value, +) -> Option { + pending.lock().expect("pending lock").fold_frame(value) +} + +fn fold_pending_line(pending: &mut ClaudePending, value: &Value) { let Some(sdk_type) = value.get("type").and_then(Value::as_str) else { return; }; @@ -7671,17 +7175,14 @@ fn fold_pending_frame(pending: &std::sync::Mutex, value: &Value) value.get("suggestions"), ), }; - let mut p = pending.lock().expect("pending lock"); - p.permissions.retain(|e| e.request_id != entry.request_id); - p.permissions.push(entry); + pending + .permissions + .retain(|e| e.request_id != entry.request_id); + pending.permissions.push(entry); } "sdk.permission.cancelled" => { if let Some(request_id) = value.get("requestId").and_then(frame_request_id) { - pending - .lock() - .expect("pending lock") - .permissions - .retain(|e| e.request_id != request_id); + pending.permissions.retain(|e| e.request_id != request_id); } } "sdk.question.request" => { @@ -7700,17 +7201,14 @@ fn fold_pending_frame(pending: &std::sync::Mutex, value: &Value) request_id, questions: normalize_question_definitions(value.get("questions")), }; - let mut p = pending.lock().expect("pending lock"); - p.questions.retain(|e| e.request_id != entry.request_id); - p.questions.push(entry); + pending + .questions + .retain(|e| e.request_id != entry.request_id); + pending.questions.push(entry); } "sdk.question.cancelled" => { if let Some(request_id) = value.get("requestId").and_then(frame_request_id) { - pending - .lock() - .expect("pending lock") - .questions - .retain(|e| e.request_id != request_id); + pending.questions.retain(|e| e.request_id != request_id); } } _ => {} @@ -7816,53 +7314,6 @@ struct ClaudeOwnerKey { // ── sdk.* → freshAgent.event frame (port of sdk-events.ts normalizeFreshAgentProviderEvent) ─ -/// The two attention edges a claude session raises (AGENTS.md "Agent Status -/// Indicators"): a turn ended that the user did not witness, or the agent waits -/// on the user (a pending approval or question). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum AttentionEdge { - TurnComplete, - Waiting, -} - -impl AttentionEdge { - /// The edge a sidecar stdout line carries, if it carries one. - fn from_sidecar_type(sdk_type: &str) -> Option { - match sdk_type { - "sdk.turn.complete" => Some(Self::TurnComplete), - "sdk.turn.waiting" => Some(Self::Waiting), - _ => None, - } - } - - /// The client event type. - pub(crate) fn wire_type(self) -> &'static str { - match self { - Self::TurnComplete => "freshAgent.turn.complete", - Self::Waiting => "freshAgent.turn.waiting", - } - } -} - -/// Where an attention edge was minted (logged as `source`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum AttentionEdgeSource { - /// The sidecar minted it (a result, a result-less stream end, a pending - /// card) and the stdout consumer forwarded it. - Sidecar, - /// The stdout consumer minted it for an unrequested sidecar death mid-turn. - ConsumerExit, -} - -impl AttentionEdgeSource { - fn as_str(self) -> &'static str { - match self { - Self::Sidecar => "freshclaude/sidecar", - Self::ConsumerExit => "freshclaude/consumer-exit", - } - } -} - /// Map an `sdk.*` event line from the sidecar to a `freshAgent.event` wire frame. Renames /// the inner `type` `sdk.X → freshAgent.X` (only the known set — matching sdk-events.ts, /// which passes unknown types through unchanged and thus never surfaces them as fresh-agent @@ -7912,10 +7363,9 @@ fn normalize_sdk_type(sdk_type: &str) -> Option<&'static str> { "sdk.retry" => "freshAgent.retry", "sdk.notice" => "freshAgent.notice", // `sdk.turn.complete` / `sdk.turn.waiting` are deliberately absent: the - // consumer routes them to `emit_attention_edge` (AttentionEdge), the one - // logged edge exit, so no other path can broadcast an edge. + // server mints every edge itself (`claude_edges.rs`, C18), so no sidecar + // line becomes one. "sdk.error" => "freshAgent.error", - "sdk.exit" => "freshAgent.exit", "sdk.killed" => "freshAgent.killed", _ => return None, }) @@ -8598,7 +8048,7 @@ async fn teardown_removed_session_within( /// tree (G31), and the ownership tag the `/proc` reaper finds that tree by. struct SidecarProcesses { child: Child, - stdin: ChildStdin, + stdin: crate::claude_sidecar_io::SidecarWriter, tree_guard: crate::claude_spawn::SidecarTreeGuard, ownership_id: String, } @@ -8629,9 +8079,13 @@ impl SidecarProcesses { confirm_rounds: u8, grace: Duration, ) -> (crate::claude_spawn::SidecarExit, TeardownConfirmation) { - let exit = - crate::claude_spawn::graceful_then_kill(self.child, self.stdin, self.tree_guard, grace) - .await; + let exit = crate::claude_spawn::graceful_then_kill_with_writer( + self.child, + self.stdin, + self.tree_guard, + grace, + ) + .await; reap_owned_claude_sidecars(&self.ownership_id); #[cfg(target_os = "linux")] let confirmation = { @@ -8709,11 +8163,19 @@ fn log_claude_teardown( mod create_outcome; pub use create_outcome::ClaudeCreateOutcome; +mod send_outcome; +pub use send_outcome::ClaudeSendOutcome; + mod establish; mod fork; mod genesis; mod history; +mod pending_runtime; +use pending_runtime::{is_pending_frame, PendingResponse}; mod rollback; +mod sidecar_writer; +mod stop; +mod turn_status; use establish::{ BootRetry, DurableClaim, EstablishLane, EstablishPlan, LaunchLease, RegisterUnder, }; @@ -8727,6 +8189,9 @@ mod end_of_output_tests; #[cfg(test)] mod live_frames_tests; +#[cfg(test)] +mod pending_tests; + /// ISO-8601 / RFC-3339 millis-Z timestamp (`new Date().toISOString()`) for error frames. fn now_iso() -> String { let now = SystemTime::now() @@ -8821,7 +8286,7 @@ pub(crate) mod tests { session_id.to_string(), ClaudeSession { configuration: ClaudeConfiguration::default(), - stdin, + stdin: stdin.into(), child, tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), ownership_id: format!("test-{session_id}"), @@ -8832,12 +8297,9 @@ pub(crate) mod tests { owning_generation: None, broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), - in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), - turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), + ledger: crate::claude_turns::SharedLedger::default(), + clock: crate::attention::AttentionClock::default(), turn_lock: Arc::new(TokioMutex::new(())), - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), launch_genesis: None, }, ); @@ -9373,9 +8835,9 @@ pub(crate) mod tests { } /// A mid-turn `compacting` must NOT wedge the tracked status for the rest of - /// the session's life. The tracker's fold mirrors the reference bridge's - /// lifecycle (sdk-bridge.ts:445): EVERY `sdk.result` settles the status back - /// to `"idle"`, so once the compacted turn completes, the close-and-reopen + /// the session's life. The turn's `sdk.result` settles it (C17: the result + /// retires the turn's command and ends the compaction), so once the + /// compacted turn completes, the close-and-reopen /// rescue ack announces `"idle"` — never a sticky `compacting` that leaves /// the revived pane blue with user input queueing forever (the client's /// flush gate runs only when `!isBusy`). Both transitions are driven through @@ -9406,10 +8868,10 @@ pub(crate) mod tests { .await; await_status_on_wire(&mut rx, "compacting").await; - // The compacted turn completes: sdk.result is the settle edge. Observing - // its broadcast proves the settle fold has landed. - st.handle_send(send_msg(&session_id, "__emit_result__")) - .await; + // The compacted turn completes: its sdk.result settles it. Observing + // its broadcast proves the settle fold has landed. inject_raw_send: the + // same turn's result, not a second send (which would still be owed). + inject_raw_send(&st, &session_id, "__emit_result__").await; let _ = await_frame_of_inner_type(&mut rx, "freshAgent.result").await; // The reconnect rescue AFTER the turn settled: the ack must speak "idle", @@ -9430,7 +8892,7 @@ pub(crate) mod tests { /// Attaching while a turn is genuinely in flight must ack `"running"` — the /// reference bridge's per-turn lifecycle edge (sdk-bridge.ts:426 — the real /// sidecar announces NO `sdk.status: running`, busy is derived from stream - /// deltas, so this arm is the only way the tracker ever speaks "running"). + /// deltas, so the turn ledger speaks "running" from turn evidence). /// Drives a REAL `sdk.assistant` frame through the fold and observes its /// broadcast before the attach, so the assertion races nothing. #[tokio::test] @@ -9600,110 +9062,22 @@ pub(crate) mod tests { } #[test] - fn attention_edge_lines_leave_only_through_the_edge_exit() { - // The sidecar's attention edges are classified as edges, and the plain - // frame mapping refuses them: only `emit_attention_edge` (logged, G14) - // can put a freshAgent.turn.complete / turn.waiting on the wire. Its - // frame shape is pinned by - // `emit_attention_edge_logs_each_edge_and_broadcasts_its_frame`. - for (line, edge) in [ - ( - json!({ "type": "sdk.turn.complete", "sessionId": "s-1", "at": 42 }), - AttentionEdge::TurnComplete, - ), - ( - json!({ "type": "sdk.turn.waiting", "sessionId": "s-1", "at": 43 }), - AttentionEdge::Waiting, - ), + fn sidecar_edge_lines_never_become_frames() { + // C18: the server mints every attention edge itself (`claude_edges.rs`), + // so an edge line a protocol fake still writes is never forwarded as a + // frame of its own (the consumer drops it with a debug log). + for line in [ + json!({ "type": "sdk.turn.complete", "sessionId": "s-1", "at": 42 }), + json!({ "type": "sdk.turn.waiting", "sessionId": "s-1", "at": 43 }), + json!({ "type": "sdk.exit", "sessionId": "s-1" }), ] { - let line_type = line["type"].as_str().unwrap(); - assert_eq!(AttentionEdge::from_sidecar_type(line_type), Some(edge)); assert!( sdk_line_to_frame(&line, "s-1", "freshclaude").is_none(), - "{line_type} must not become a plain frame" + "{line} must not become a plain frame" ); } } - /// G14 / design §9.2 I-1: every claude attention edge leaves through - /// `emit_attention_edge`, which logs it at info (provider, session, type, - /// source and `at`, never content) and broadcasts the client frame. - #[tokio::test] - async fn emit_attention_edge_logs_each_edge_and_broadcasts_its_frame() { - use crate::claude_sidecar_io::test_capture::{capture, with_message}; - let (events, _guard) = capture(); - let (st, mut rx) = state_with_bus(); - st.emit_attention_edge( - AttentionEdge::TurnComplete, - "envelope-1", - "sidecar-1", - "kilroy", - 41, - AttentionEdgeSource::Sidecar, - ); - st.emit_attention_edge( - AttentionEdge::Waiting, - "envelope-1", - "sidecar-1", - "kilroy", - 42, - AttentionEdgeSource::Sidecar, - ); - st.emit_attention_edge( - AttentionEdge::TurnComplete, - "envelope-1", - "sidecar-1", - "kilroy", - 43, - AttentionEdgeSource::ConsumerExit, - ); - - let frames: Vec = (0..3) - .map(|_| serde_json::from_str(&rx.try_recv().expect("an edge frame")).unwrap()) - .collect(); - for frame in &frames { - assert_eq!(frame["type"], "freshAgent.event"); - assert_eq!(frame["provider"], "claude"); - assert_eq!(frame["sessionId"], "envelope-1"); - assert_eq!(frame["sessionType"], "kilroy"); - } - assert_eq!( - frames - .iter() - .map(|frame| frame["event"].clone()) - .collect::>(), - vec![ - json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": 41 }), - json!({ "type": "freshAgent.turn.waiting", "sessionId": "sidecar-1", "at": 42 }), - json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": 43 }), - ] - ); - - let completes = with_message(&events, "freshagent.turn.complete"); - let waits = with_message(&events, "freshagent.turn.waiting"); - assert_eq!(completes.len(), 2, "{completes:?}"); - assert_eq!(waits.len(), 1, "{waits:?}"); - let logged: Vec<(Option<&str>, Option<&str>)> = completes - .iter() - .chain(&waits) - .map(|event| (event.field("source"), event.field("at"))) - .collect(); - assert_eq!( - logged, - vec![ - (Some("freshclaude/sidecar"), Some("41")), - (Some("freshclaude/consumer-exit"), Some("43")), - (Some("freshclaude/sidecar"), Some("42")), - ] - ); - for event in completes.iter().chain(&waits) { - assert_eq!(event.level, tracing::Level::INFO); - assert_eq!(event.field("provider"), Some("claude")); - assert_eq!(event.field("session_id"), Some("envelope-1")); - assert_eq!(event.field("session_type"), Some("kilroy")); - } - } - /// Delta-review round 5 (AGENT-06): the `sdk.question.request` → /// `freshAgent.question.request` WS broadcast stays VERBATIM even though the /// snapshot-bound copy is normalized (see @@ -9756,24 +9130,22 @@ pub(crate) mod tests { #[test] fn sidecar_death_while_idle_emits_no_completion() { // The ADR Decision 2.1 no-FALSE-completion property, IDLE-death twin: a - // death-truncated stream WITHOUT an armed turn (stdout ends after some - // events but BEFORE any sdk.turn.complete) can NEVER produce a - // freshAgent.turn.complete. We model the consumer's mapping over the - // death-truncated line stream and assert no completion frame is produced. + // death-truncated stream with nothing owed (no send armed, so the turn + // ledger owes the user nothing at the end of output) can NEVER produce + // a freshAgent.turn.complete: the end-of-output arm rings only what the + // ledger owed, and no sidecar line becomes an edge frame. let death_stream = [ json!({ "type": "sdk.session.init", "sessionId": "s", "cliSessionId": "0199abcd-1234-7abc-8def-0123456789ab" }), - json!({ "type": "sdk.stream", "sessionId": "s", "event": { "type": "content_block_delta" } }), - json!({ "type": "sdk.assistant", "sessionId": "s", "content": [{ "type": "text", "text": "part" }] }), - // …process is SIGKILLed here — stdout ends. NO sdk.result, NO sdk.turn.complete. + json!({ "type": "sdk.session.state", "sessionId": "s", "state": "idle" }), + // …process is SIGKILLed here — stdout ends. NO sdk.result. ]; - // The consumer's per-line split: an edge line goes to the edge exit, - // anything else through the plain frame mapping. - let edge_of = |line: &Value| AttentionEdge::from_sidecar_type(line["type"].as_str()?); - let edges: Vec = death_stream.iter().filter_map(edge_of).collect(); - assert!( - edges.is_empty(), - "a death-truncated stream must never yield an attention edge, got {edges:?}" - ); + let mut ledger = crate::claude_turns::ClaudeTurnLedger::default(); + for line in &death_stream { + if let Some(fact) = crate::claude_turns::TurnFact::from_line(line) { + ledger.fold(&fact); + } + } + assert!(!ledger.eof(), "an idle session owes nothing at its death"); let inner_types: Vec = death_stream .iter() .filter_map(|l| sdk_line_to_frame(l, "s", "freshclaude")) @@ -9783,11 +9155,13 @@ pub(crate) mod tests { !inner_types.iter().any(|t| t.contains("turn.complete")), "a death-truncated stream must never yield a completion chime, got {inner_types:?}" ); - // And a subsequent success stream DOES complete — the edge is real, not disabled. - assert_eq!( - edge_of(&json!({ "type": "sdk.turn.complete", "sessionId": "s", "at": 1 })), - Some(AttentionEdge::TurnComplete) + // And a turn in flight IS owed — the death edge is real, not disabled. + ledger.arm( + "u".into(), + Some("r".into()), + crate::claude_turns::SendKind::Turn, ); + assert!(ledger.eof(), "a turn in flight is owed at its death"); } /// Task 5 (unified needs-attention signal): an UNREQUESTED sidecar death @@ -9806,8 +9180,10 @@ pub(crate) mod tests { let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - // One full death cycle: create → create-time idle → a plain send ARMS - // the turn → a mid-turn assistant frame models the truncated stream → + // One full death cycle: create → a plain send ARMS the turn (the + // fake's create-time idle can land at any point: an idle never ends a + // command the CLI has not reported, C17) → a mid-turn assistant frame + // models the truncated stream → // the sidecar dies through its own exit hook (the consumer observes // plain stdout EOF — indistinguishable from a SIGKILL mid-turn, the one // death shape whose sidecar finally never runs). Returns the minted @@ -9822,15 +9198,14 @@ pub(crate) mod tests { st.handle_create(dedup_create_msg(request_id), None).await; let created = await_claude_created(rx, request_id).await; let sid = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(rx, &sid, "idle").await; st.handle_send(send_msg(&sid, "a turn that dies mid-flight")) .await; - await_in_turn(st, &sid, true).await; + await_ledger_busy(st, &sid, true).await; inject_raw_send(st, &sid, "__emit_assistant__").await; inject_raw_send(st, &sid, "__exit__").await; // The death arm's two frames arrive in stream order (banner, then // the synthesized edge) but the scan is order-agnostic. Poll with a - // deadline (the `await_status_frame` pattern): a blocking recv could + // deadline: a blocking recv could // never observe the deadline once the bus goes quiet. let deadline = tokio::time::Instant::now() + Duration::from_secs(15); let mut saw_exited_banner = false; @@ -9891,7 +9266,7 @@ pub(crate) mod tests { .collect(); let expected = |at: i64| { ( - Some("freshclaude/consumer-exit".to_string()), + Some("death".to_string()), Some("freshclaude".to_string()), Some(at.to_string()), ) @@ -10144,21 +9519,9 @@ const deferCreateMs = parseInt(process.env.FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS const failCreate = process.env.FRESHELL_TEST_CLAUDE_FAIL_CREATE === '1' let counter = 0 -// Task 4c (ep3-r2 F2): live-session membership — the real sidecar DELETES its -// JS session when consumeStream's finally runs (stream end) while stdout stays -// open, so a later send is answered by a lone signed `sdk.error` (no terminal -// edge, no EOF). `__drop_session__` simulates that provider-side death. -const liveSessions = new Set( - (process.env.FRESHELL_TEST_CLAUDE_PRESEED_SESSIONS || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), -) // ep4-r2: the live-fixture rig boots WITHOUT a `create` frame — preseed its durable ids here so its sends are not answered with session-not-found. -// ep4-r3: a minimal model of the SDK-input queue for compact intents — every -// `/compact` send sits in it until the quiesce DRAINS it (the drain-count IS -// the cancelledQueue the real sidecar reports), mirroring the real -// cancellation authority at the pre-handoff residence. -const compactQueue = [] +// The sessions this sidecar hosts; a send to another is answered with the +// real sidecar's signed session-not-found error. +const liveSessions = new Set() const configuredSettings = new Map() // Sessions booted under a server-minted id whose init waits for the first send. const deferredInit = new Map() @@ -10219,37 +9582,16 @@ rl.on('line', (line) => { deferredInit.delete(msg.sessionId) console.log(JSON.stringify({ type: 'sdk.session.init', sessionId: msg.sessionId, cliSessionId: deferredCliId, model: 'fake-model', cwd: '/tmp', tools: [] })) } - // Task 4 (kata 1wxv) in_turn edge hooks: each magic text emits ONE canned sdk.* - // line so the four-edge/fail-closed busy contract can drive every edge. + // Magic texts: each emits ONE canned sdk.* line through the real stdout + // consumer (the fake reports no command lifecycle, so the turn ledger + // settles a result by its old-CLI head fallback). if (msg.text === '__emit_result_error__') { console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'error_during_execution' })) - } else if (msg.text === '__emit_idle__') { - console.log(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' })) - } else if (msg.text === '__emit_error__') { - console.log(JSON.stringify({ type: 'sdk.error', sessionId: msg.sessionId, message: 'boom' })) - } else if (msg.text === '__emit_compacting__') { - console.log(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'compacting' })) - // Task 4b (kata 1wxv, ep3-r1 F1): the SDK's compact COMPLETION boundary - // carries the manual/auto trigger the bare `compacting` status lacks — the - // ONLY wire-level way to tell an explicit `/compact` run apart from the - // SDK's automatic context compaction. The real sidecar relays it as - // `sdk.compact_boundary {trigger}` (crates/freshell-claude-sidecar). - } else if (msg.text === '__emit_compact_boundary_manual__') { - console.log(JSON.stringify({ type: 'sdk.compact_boundary', sessionId: msg.sessionId, trigger: 'manual' })) - } else if (msg.text === '__emit_compact_boundary_auto__') { - console.log(JSON.stringify({ type: 'sdk.compact_boundary', sessionId: msg.sessionId, trigger: 'auto' })) - } else if (msg.text === '__drop_session__') { - liveSessions.delete(msg.sessionId) } else if (!liveSessions.has(msg.sessionId)) { - // Task 4c (ep3-r2 F2): the provider-side session was DELETED (stream end) - // while stdout stayed open — the real sidecar answers with this lone - // signed frame and NOTHING after (no result, no idle, no EOF). - console.log(JSON.stringify({ type: 'sdk.error', sessionId: msg.sessionId, message: 'session not found', sessionNotFound: true })) - } else if (/^\s*\/compact(\s|$)/.test(String(msg.text ?? ''))) { - // ep4-r3: track compact intents in the fake's queue (the quiesce arm - // drains them) — then log like every other non-magic send. - compactQueue.push(msg.text) - if (respondLog) fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) + // A send to a session this sidecar does not host is refused before any + // CLI sees it, as the real sidecar refuses it (sdk.send.rejected): the + // server takes the send back. + console.log(JSON.stringify({ type: 'sdk.send.rejected', sessionId: msg.sessionId, uuid: msg.uuid ?? null, code: 'SESSION_NOT_FOUND', message: 'Claude session is no longer available.' })) } else if (msg.text === '__emit_assistant__') { console.log(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'noise' }] })) // Task 2 test hook: raise a canned pending permission the approve/deny flow can @@ -10272,11 +9614,11 @@ rl.on('line', (line) => { process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: msg.text.slice('__set_status__:'.length) }) + '\n') } else if (msg.text === '__emit_assistant__') { // Reconnect-revive hook: emit a real sdk.assistant frame (the real wire shape — - // index.mjs:165) so a test can drive the tracker's turn-start ("running") fold. + // index.mjs:165) so a test can drive the turn ledger's turn evidence ("running"). process.stdout.write(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'part' }], model: 'fake-model' }) + '\n') } else if (msg.text === '__emit_result__') { // Reconnect-revive hook: emit a real sdk.result frame (the real wire shape — - // index.mjs:177) so a test can drive the tracker's turn-complete settle fold. + // index.mjs:177) so a test can drive the turn ledger's result settle. process.stdout.write(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: {} }) + '\n') } else if (respondLog) { // Non-magic sends (e.g. `/compact …`) land in the respond log verbatim so @@ -10288,70 +9630,12 @@ rl.on('line', (line) => { } else if (msg.type === 'interrupt') { const interruptLog = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG if (interruptLog) fs.appendFileSync(interruptLog, `${msg.sessionId}\n`) - // Task 4c (kata 1wxv focused ep4-r1/ep4-r2 F1): the interrupt SETTLES - // asynchronously, and per the SDK contract (`sdk.d.ts:3760`) the control - // receipt is written BEFORE the interrupted turn's terminal `sdk.result` - // — so the fake mirrors: settle frame first, then (accepted case) the - // interrupted turn's own result, IN THAT ORDER (no trailing idle: the - // real sidecar sends none). The - // rollback gate opens at the RESULTS, never at the receipt. A knocked-out - // settle (probe on an idle sidecar) still stands alone: with no in-flight - // op the result folds harmlessly on an empty tracker. - // - // Knobs: FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT=1 — the provider REJECTED - // the interrupt (ok:false, NO trailing result: the turn still runs - // and its own later edge owns it — the gate stays closed); - // FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS — defer the whole settle→result - // chain (the request↔settle window is the provider-work window the - // gate must hold); - // FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE=1 — BEFORE the settle, - // emit `sdk.status:compacting` (the absorbed compact debt started in the - // sidecar before consuming the probe request: rollback's recheck must see - // the revived candidate via the probe's in-order stream position and - // ABORT the teardown) — and NO trailing result for the compact: its - // own terminal edge has not yet landed (the hazard window). - const settleMs = parseInt(process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS || '0', 10) - const reject = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT === '1' - const compactBeforeSettle = process.env.FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE === '1' - const settle = () => { - if (compactBeforeSettle) { - console.log(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'compacting' })) - } - console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: !reject })) - if (!reject && !compactBeforeSettle) { - console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, subtype: 'error', errors: ['interrupted'] })) - } - } - if (settleMs > 0) setTimeout(settle, settleMs) - else settle() - } else if (msg.type === 'rollback.quiesce') { - // Task 4c (kata 1wxv ep4-r3): rollback's pre-teardown quiesce probe. The - // fake drains its tracked compact queue and answers with a - // probeId-correlated verdict, echoing the real sidecar's stream order and - // fields. Knob FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE=1 models - // the un-cancellable race: an absorbed compact was HANDED to an idle SDK - // consumer before the probe — its compacting STATUS lands first (in-order - // evidence) and the answer reports handedCompactLikely (BUSY verdict); no - // trailing terminal frames (its run never completed in the hazard window). - const compactBeforeSettle = process.env.FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE === '1' - // FRESHELL_TEST_CLAUDE_PROBE_WRONG_ID=1 — answer with a FOREIGN probe id - // (a stale receipt): the probe must stay open and time out, never close on - // it (ep4-r3 F2 correlation). - const wrongId = process.env.FRESHELL_TEST_CLAUDE_PROBE_WRONG_ID === '1' - const echoId = wrongId ? 'stale-receipt-not-this-probe' : (msg.probeId ?? null) - if (compactBeforeSettle) { - console.log(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'compacting' })) - console.log(JSON.stringify({ type: 'sdk.rollback.quiesced', sessionId: msg.sessionId, probeId: echoId, cancelledQueue: 0, inFlightTurn: false, handedCompactLikely: true })) - } else if (process.env.FRESHELL_TEST_CLAUDE_PROBE_HANDED_BUSY === '1') { - // Models the SAME-TICK handoff: an absorbed compact was handed to an - // awaiting SDK consumer immediately before the probe — NO status has - // been emitted yet, so the verdict is the ONLY evidence. The gate must - // abort on the verdict alone. - console.log(JSON.stringify({ type: 'sdk.rollback.quiesced', sessionId: msg.sessionId, probeId: echoId, cancelledQueue: 0, inFlightTurn: false, handedCompactLikely: true })) - } else { - const cancelled = compactQueue.splice(0).length - console.log(JSON.stringify({ type: 'sdk.rollback.quiesced', sessionId: msg.sessionId, probeId: echoId, cancelledQueue: cancelled, inFlightTurn: false, handedCompactLikely: false })) - } + // The control receipt (echoing the Stop's id) is written BEFORE the + // interrupted turn's own result (sdk.d.ts:3760): the settle frame first, + // then the stopped turn's result in the pinned CLI's aborted shape (T11; no + // trailing idle: the real sidecar sends none). + console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: true })) + console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'error_during_execution', subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 })) } else if (msg.type === 'shutdown') { process.exit(0) } @@ -10489,7 +9773,7 @@ rl.on('line', (line) => { } #[test] - fn checkpoint_turn_identity_claude_send_carries_valid_uuid_and_attempt_correlation() { + fn checkpoint_turn_identity_claude_send_carries_the_attempt_correlation() { let mut msg = send_msg("claude-placeholder", "same prompt"); msg.request_id = Some("request-7".to_string()); msg.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { @@ -10497,16 +9781,13 @@ rl.on('line', (line) => { workspace: None, }); - let attempt = checkpoint_attempt_for_send(&msg).expect("checkpoint attempt"); - let uuid = uuid::Uuid::parse_str(attempt["uuid"].as_str().expect("UUID string")) - .expect("valid UUID"); + // C16: the attempt binds under the send's own uuid (the send line's + // `uuid`, `a_send_line_carries_its_uuid_and_the_acceptance_names_it`), + // so the correlation record carries no uuid of its own. assert_eq!( - uuid.get_version_num(), - 4, - "the SDK input uses a fresh v4 UUID" + checkpoint_attempt_for_send(&msg).expect("checkpoint attempt"), + json!({ "requestId": "request-7", "logicalAttemptId": "attempt-7" }) ); - assert_eq!(attempt["requestId"], "request-7"); - assert_eq!(attempt["logicalAttemptId"], "attempt-7"); assert!(checkpoint_attempt_for_send(&send_msg("legacy", "prompt")).is_none()); } @@ -10616,17 +9897,9 @@ rl.on('line', (line) => { .get(session_id) .expect("the old session stays mapped"); assert!( - !session.in_turn.load(std::sync::atomic::Ordering::SeqCst), + !session.ledger.lock().expect("turn ledger lock").busy(), "a stale delayed prompt never arms a turn or reaches the sidecar" ); - assert!( - !session - .turn_tracker - .lock() - .expect("turn tracker mutex") - .busy(), - "a refused stale prompt leaves the turn tracker idle" - ); } /// F4 (PR-A review): before the CLI reports a new pane's session the @@ -10680,8 +9953,10 @@ rl.on('line', (line) => { ); assert!( !st.sessions.lock().await[session_id] - .in_turn - .load(std::sync::atomic::Ordering::SeqCst), + .ledger + .lock() + .expect("turn ledger lock") + .busy(), "the refused send never reaches the sidecar" ); @@ -10695,8 +9970,10 @@ rl.on('line', (line) => { ); assert!( st.sessions.lock().await[session_id] - .in_turn - .load(std::sync::atomic::Ordering::SeqCst), + .ledger + .lock() + .expect("turn ledger lock") + .busy(), "the unfenced first send reaches the sidecar" ); } @@ -11360,8 +10637,10 @@ rl.on('line', (line) => { .await .get(session_id) .unwrap() - .in_turn - .load(std::sync::atomic::Ordering::SeqCst)); + .ledger + .lock() + .expect("turn ledger lock") + .busy()); } fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { @@ -17850,7 +17129,7 @@ rl.on('line', (line) => { session_id.to_string(), ClaudeSession { configuration: ClaudeConfiguration::default(), - stdin, + stdin: stdin.into(), child, tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), ownership_id: format!("test-{session_id}"), @@ -17861,12 +17140,9 @@ rl.on('line', (line) => { owning_generation: None, broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), pending: Arc::new(std::sync::Mutex::new(pending)), - in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), - turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), + ledger: crate::claude_turns::SharedLedger::default(), + clock: crate::attention::AttentionClock::default(), turn_lock: Arc::new(TokioMutex::new(())), - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), launch_genesis: None, }, ); @@ -18239,6 +17515,88 @@ rl.on('line', (line) => { drop(env); } + /// C16: every send line carries a uuid minted for it: v4, different per send, + /// with a checkpointed send's attempt correlation beside it (the attempt + /// binds under that uuid). `freshAgent.send.accepted.submittedTurnId` is that + /// uuid, and so is the turn id the caller gets back. + #[tokio::test] + async fn a_send_line_carries_its_uuid_and_the_acceptance_names_it() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-uuid"), None).await; + let created = await_claude_created(&mut rx, "req-uuid").await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + + let mut checkpointed = send_msg(&session_id, "first"); + checkpointed.request_id = Some("send-1".to_string()); + checkpointed.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { + logical_attempt_id: "attempt-1".to_string(), + workspace: None, + }); + let first = st.handle_send(checkpointed).await; + let mut plain = send_msg(&session_id, "second"); + plain.request_id = Some("send-2".to_string()); + let second = st.handle_send(plain).await; + + let sends: Vec = env + .respond_log_frames(2) + .await + .into_iter() + .filter(|frame| frame["type"] == "send") + .collect(); + assert_eq!(sends.len(), 2, "{sends:?}"); + let uuids: Vec = sends + .iter() + .map(|send| { + send["uuid"] + .as_str() + .expect("the send carries a uuid") + .to_string() + }) + .collect(); + for uuid in &uuids { + let parsed = uuid::Uuid::parse_str(uuid).expect("a uuid"); + assert_eq!(parsed.get_version_num(), 4); + } + assert_ne!(uuids[0], uuids[1], "each send has its own uuid"); + assert_eq!( + sends[0]["checkpointAttempt"], + json!({ "requestId": "send-1", "logicalAttemptId": "attempt-1" }) + ); + assert!(sends[1].get("checkpointAttempt").is_none()); + + for (outcome, uuid) in [(first, &uuids[0]), (second, &uuids[1])] { + assert_eq!( + outcome, + ClaudeSendOutcome::Accepted { + session_key: session_id.clone(), + submitted_turn_id: uuid.clone(), + } + ); + } + let mut accepted = Vec::new(); + while accepted.len() < 2 { + let frame: Value = serde_json::from_str( + &tokio::time::timeout(Duration::from_secs(15), rx.recv()) + .await + .expect("send.accepted in time") + .expect("bus open"), + ) + .unwrap(); + if frame["type"] == "freshAgent.send.accepted" { + accepted.push( + frame["submittedTurnId"] + .as_str() + .unwrap_or_default() + .to_string(), + ); + } + } + assert_eq!(accepted, uuids); + drop(env); + } + /// Task 2 (d): compact forwards to the sidecar as a plain `send` of `/compact` /// (empty/absent instructions) or `/compact ` — the legacy /// adapter's shape (`adapter.ts:168-174`) — with NO send.accepted ack frame. @@ -18431,7 +17789,8 @@ rl.on('line', (line) => { let reader = BufReader::new(stdout).lines(); let st = state(); - let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); + insert_fake_claude_session(&st, "fold-session").await; + let pending = st.sessions.lock().await["fold-session"].pending.clone(); let consumer = st.spawn_consumer( reader, "fold-session".to_string(), @@ -18440,10 +17799,8 @@ rl.on('line', (line) => { None, Arc::new(std::sync::Mutex::new("fold-session".to_string())), Arc::clone(&pending), - Arc::new(std::sync::Mutex::new("idle".to_string())), - Arc::new(std::sync::atomic::AtomicBool::new(false)), - Arc::new(std::sync::Mutex::new(TurnTracker::default())), - Arc::new(std::sync::Mutex::new(None)), + crate::claude_turns::SharedLedger::default(), + crate::attention::AttentionClock::default(), None, // provenance: test lane is conn-less (D8) ); @@ -19393,6 +18750,11 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-claude-interrupt").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); + // C18: a Stop reaches the sidecar only while something runs. The fake + // answers no plain send, so the turn stays owed until the Stop. + st.handle_send(send_msg(&session_id, "a turn to stop")) + .await; + await_ledger_busy(&st, &session_id, true).await; st.handle_interrupt(FreshAgentInterrupt { provider: freshell_protocol::AgentProvider::Claude, @@ -19417,12 +18779,15 @@ rl.on('line', (line) => { } tokio::time::sleep(Duration::from_millis(20)).await; } - // Success is silent -- no `error` frame was broadcast for this interrupt. The - // fake sidecar also emits `sdk.session.init`/`sdk.status` after `created` - // (Task 1); those unrelated `freshAgent.event` frames are skipped rather than - // miscounted as an interrupt response. - while let Ok(raw) = rx.try_recv() { + // The stopped turn's own (aborted) result settles it, silently: no + // `error` frame, no banner and no edge for the user's own Stop. + await_ledger_busy(&st, &session_id, false).await; + let deadline = tokio::time::Instant::now() + Duration::from_millis(300); + while let Ok(Ok(raw)) = tokio::time::timeout_at(deadline, rx.recv()).await { let frame: Value = serde_json::from_str(&raw).unwrap(); + if frame["type"] == "freshAgent.send.accepted" { + continue; + } assert_eq!( frame["type"], "freshAgent.event", "a successful interrupt must not broadcast: {frame}" @@ -19431,7 +18796,58 @@ rl.on('line', (line) => { frame["event"]["type"], "freshAgent.error", "a successful interrupt must not broadcast an error: {frame}" ); + assert_ne!( + frame["event"]["type"], "freshAgent.turn.complete", + "the user's own Stop never rings: {frame}" + ); } + // The interrupt carried the latched Stop's id. + let stops = env.interrupt_log_contents(); + assert_eq!(stops.lines().count(), 1, "{stops:?}"); + } + + /// C18 (design §5.4): an idle session has nothing to stop, so nothing is + /// sent to the sidecar, and the next turn still rings. + #[tokio::test] + async fn handle_interrupt_of_an_idle_session_sends_nothing() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (log_events, _log_guard) = crate::claude_sidecar_io::test_capture::capture(); + let (tx, mut rx) = tokio::sync::broadcast::channel::(64); + let st = FreshClaudeState::new(Arc::new(tx)); + + st.handle_create(dedup_create_msg("req-claude-idle-stop"), None) + .await; + let created = await_claude_created(&mut rx, "req-claude-idle-stop").await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + + st.handle_interrupt(FreshAgentInterrupt { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.clone(), + session_type: SessionType::Freshclaude, + cwd: None, + }) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + env.interrupt_log_contents(), + "", + "an idle Stop must not reach the sidecar" + ); + let stops = crate::claude_sidecar_io::test_capture::with_message( + &log_events, + "freshagent.claude.stop", + ); + assert_eq!(stops.len(), 1, "{stops:?}"); + assert_eq!(stops[0].field("outcome"), Some("idle")); + + // The next turn rings (no stray latch silences it). + while rx.try_recv().is_ok() {} + st.handle_send(send_msg(&session_id, "a turn")).await; + await_ledger_busy(&st, &session_id, true).await; + inject_raw_send(&st, &session_id, "__emit_result__").await; + let edge = await_frame_of_inner_type(&mut rx, "freshAgent.turn.complete").await; + assert!(edge["event"]["at"].as_i64().is_some(), "{edge}"); } /// Adapter-asymmetry fix (bug-hunt pbh-20260807): an UNREQUESTED sidecar death @@ -19570,6 +18986,57 @@ rl.on('line', (line) => { ); } + /// Design §3.1 and §5.5 (C18): a session's attention clock survives its + /// death and the resume that follows, so an edge the resumed session rings + /// lands strictly above the death's edge even within one millisecond. + #[tokio::test(flavor = "multi_thread")] + async fn a_resumed_session_keeps_the_attention_clock_of_its_death() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + // The fake sidecar's session reports this durable id. + const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-clock-carry"), None) + .await; + let created = await_claude_created_and_session_init(&mut rx, "req-clock-carry").await; + let pane_id = created["sessionId"].as_str().unwrap().to_string(); + // A turn in flight (the fake answers no plain send), so the death rings. + st.handle_send(send_msg(&pane_id, "a turn the death cuts off")) + .await; + await_ledger_busy(&st, &pane_id, true).await; + st.sessions + .lock() + .await + .get_mut(&pane_id) + .expect("live session in map") + .child + .start_kill() + .expect("kill the sidecar"); + let death = await_frame_of_inner_type(&mut rx, "freshAgent.turn.complete").await; + let death_at = death["event"]["at"].as_i64().expect("a numeric at"); + + let home = tempfile::tempdir().unwrap(); + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + + let key = st.resolve_session_key(DURABLE).await.expect("live key"); + let floor = st + .sessions + .lock() + .await + .get(&key) + .map(|session| session.clock.floor()); + assert_eq!( + floor, + Some(Some(death_at)), + "the resumed session continues the dead one's clock" + ); + } + /// Review PRB-C09: an attach that resumes the session under its own pane id /// while the dead sidecar is still being reaped (the reload and reconnect /// path) makes that id live again, so the dead incarnation's exit report — @@ -19590,7 +19057,7 @@ rl.on('line', (line) => { // The fake never answers a plain send: the turn stays in flight. st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) .await; - assert!(in_turn_of(&st, &pane_id).await); + assert!(ledger_busy_of(&st, &pane_id).await); let hold = st.hold_exit_reports(); st.sessions @@ -19785,11 +19252,9 @@ rl.on('line', (line) => { "freshell-claude-rollback-stdin-{}", uuid::Uuid::new_v4() )); - // ep4-r2: a real probe-answering child (node does exactly what `tee` - // did — every raw stdin line lands verbatim in stdin_log — PLUS the - // ep4-r2 quiesce probe: an `interrupt` request is answered with the - // sidecar's settled receipt, so rollback's probe never stalls the - // fixture. A live consumer folds those receipts (stream order). + // A live child standing in for the sidecar: every raw stdin line lands + // verbatim in stdin_log, and an `interrupt` request is answered with + // the sidecar's settled receipt. A live consumer folds its output. const FIXTURE_CHILD_SCRIPT: &str = r#" const fs = require('node:fs') const readline = require('node:readline') @@ -19805,15 +19270,6 @@ rl.on('line', (line) => { sessionId: msg.sessionId, ok: true, }) + '\n') - } else if (msg && msg.type === 'rollback.quiesce') { - process.stdout.write(JSON.stringify({ - type: 'sdk.rollback.quiesced', - sessionId: msg.sessionId, - probeId: msg.probeId ?? null, - cancelledQueue: 0, - inFlightTurn: false, - handedCompactLikely: false, - }) + '\n') } } catch {} }) @@ -19831,12 +19287,10 @@ rl.on('line', (line) => { let stdin = child.stdin.take().expect("piped stdin"); let stdout = child.stdout.take().expect("piped stdout"); let reader = BufReader::new(stdout).lines(); - let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); + let ledger = crate::claude_turns::SharedLedger::default(); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); + let clock = crate::attention::AttentionClock::default(); let turn_lock = Arc::new(TokioMutex::new(())); - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.to_string())); let consumer = st.spawn_consumer( reader, @@ -19846,69 +19300,15 @@ rl.on('line', (line) => { None, Arc::clone(&broadcast_id), Arc::clone(&pending), - Arc::clone(&last_status), - Arc::clone(&in_turn), - Arc::clone(&turn_tracker), - Arc::clone(&last_synthesized_complete_at), + Arc::clone(&ledger), + clock.clone(), None, // provenance: test lane is conn-less (D8) ); st.sessions.lock().await.insert( map_key.to_string(), ClaudeSession { configuration: ClaudeConfiguration::default(), - stdin, - child, - tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), - ownership_id: format!("test-{map_key}"), - consumer, - sidecar_session_id: map_key.to_string(), - cli_session_id: Some(durable.to_string()), - owning_operation: None, - owning_generation: None, - broadcast_id, - pending, - in_turn, - turn_tracker, - last_synthesized_complete_at, - turn_lock, - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, - launch_genesis: None, - }, - ); - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); - stdin_log - } - - /// Legacy tee rig: dead consumer, stdin is a plain `tee` — used ONLY by the - /// failed-arm families that SIGSTOP/SIGKILL the child to fail a parked - /// write (a live consumer would reap the tracker at EOF, which is the - /// production-correct behavior these tests deliberately bypass). - async fn insert_rollback_fixture_session_no_probe( - st: &FreshClaudeState, - map_key: &str, - durable: &str, - ) -> PathBuf { - let stdin_log = std::env::temp_dir().join(format!( - "freshell-claude-rollback-stdin-{}", - uuid::Uuid::new_v4() - )); - let mut child = tokio::process::Command::new("tee") - .arg(&stdin_log) - .stdin(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .expect("spawn tee"); - let stdin = child.stdin.take().expect("piped stdin"); - let consumer = tokio::spawn(async {}); - st.sessions.lock().await.insert( - map_key.to_string(), - ClaudeSession { - configuration: ClaudeConfiguration::default(), - stdin, + stdin: stdin.into(), child, tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), ownership_id: format!("test-{map_key}"), @@ -19917,109 +19317,11 @@ rl.on('line', (line) => { cli_session_id: Some(durable.to_string()), owning_operation: None, owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(map_key.to_string())), - pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), - in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), - turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), - turn_lock: Arc::new(TokioMutex::new(())), - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), - launch_genesis: None, - }, - ); - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); - stdin_log - } - - /// Focused ep4-r2 F2 rig: like [`insert_rollback_fixture_session`] (a - /// durable-bound, transcript-eligible rollback fixture) but with a LIVE - /// consumer attached to the real fake-sidecar script — so rollback's - /// in-order quiesce probe genuinely round-trips (the plain fixture's dead - /// consumer would skip it). The scripted fake is written per-call into a - /// unique temp dir (its own spawn/interrupt/respond logs); test knobs the - /// script honors ride the same process env (CLAUDE_ENV_LOCK guarded). - async fn insert_rollback_fixture_session_with_live_sidecar( - st: &FreshClaudeState, - map_key: &str, - durable: &str, - ) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-fixture-live-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&dir).expect("create live-fixture temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write live fixture fake"); - let stdin_log = dir.join("stdin.log"); - let spawn_log = dir.join("spawn.log"); - std::fs::write(&spawn_log, "").expect("init spawn log"); - - let stderr_log = std::fs::File::create(dir.join("stderr.log")).expect("init stderr log"); - let mut child = tokio::process::Command::new("node") - .arg(&script) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::from(stderr_log)) - .env("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log) - .env( - "FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", - dir.join("interrupt.log"), - ) - .env("FRESHELL_TEST_CLAUDE_RESPOND_LOG", dir.join("respond.log")) - .env("FRESHELL_TEST_CLAUDE_PRESEED_SESSIONS", map_key) - .kill_on_drop(true) - .spawn() - .expect("spawn live fake sidecar"); - let stdin = child.stdin.take().expect("piped stdin"); - let stdout = child.stdout.take().expect("piped stdout"); - let reader = BufReader::new(stdout).lines(); - - let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); - let turn_lock = Arc::new(TokioMutex::new(())); - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.to_string())); - let consumer = st.spawn_consumer( - reader, - map_key.to_string(), - "freshclaude".to_string(), - map_key.to_string(), - None, - Arc::clone(&broadcast_id), - Arc::clone(&pending), - Arc::clone(&last_status), - Arc::clone(&in_turn), - Arc::clone(&turn_tracker), - Arc::clone(&last_synthesized_complete_at), - None, // provenance: test lane is conn-less (D8) - ); - st.sessions.lock().await.insert( - map_key.to_string(), - ClaudeSession { - configuration: ClaudeConfiguration::default(), - stdin, - child, - tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), - ownership_id: format!("test-live-{map_key}"), - consumer, - sidecar_session_id: map_key.to_string(), - cli_session_id: Some(durable.to_string()), - owning_operation: None, - owning_generation: None, broadcast_id, pending, - in_turn, - turn_tracker, - last_synthesized_complete_at, + ledger, + clock, turn_lock, - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, launch_genesis: None, }, ); @@ -20060,38 +19362,30 @@ rl.on('line', (line) => { } } - async fn in_turn_of(st: &FreshClaudeState, map_key: &str) -> bool { + async fn ledger_busy_of(st: &FreshClaudeState, map_key: &str) -> bool { st.sessions .lock() .await .get(map_key) - .map(|s| s.in_turn.load(std::sync::atomic::Ordering::SeqCst)) + .map(|s| s.ledger.lock().expect("turn ledger lock").busy()) .unwrap_or(false) } - async fn await_in_turn(st: &FreshClaudeState, map_key: &str, want: bool) { + async fn await_ledger_busy(st: &FreshClaudeState, map_key: &str, want: bool) { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { - if in_turn_of(st, map_key).await == want { + if ledger_busy_of(st, map_key).await == want { return; } assert!( tokio::time::Instant::now() < deadline, - "in_turn never became {want}" + "the turn ledger never became {}", + if want { "busy" } else { "idle" } ); tokio::time::sleep(Duration::from_millis(5)).await; } } - fn interrupt_msg(session_id: &str) -> FreshAgentInterrupt { - FreshAgentInterrupt { - provider: freshell_protocol::AgentProvider::Claude, - session_id: session_id.to_string(), - session_type: SessionType::Freshclaude, - cwd: None, - } - } - /// Focused ep1-r1 F1 test rig: write a `send` frame to the session's /// sidecar stdin DIRECTLY (bypassing `handle_send`) — the fake sidecar's /// magic texts then emit their canned sdk.* frame IN-STREAM with NO @@ -20103,184 +19397,29 @@ rl.on('line', (line) => { let session = guard.get_mut(map_key).expect("tracked session"); let frame = json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": text }); - write_line(&mut session.stdin, &frame) - .await - .expect("raw stdin write"); + session.stdin.write(&frame).await.expect("raw stdin write"); } - /// Drain bus frames until the session's `freshAgent.status` carries - /// `status` — used post-create so the fake's CREATE-TIME `sdk.status:idle` - /// (printed with the `created` answer) provably folds BEFORE the test's - /// first turn sets the busy truth: otherwise that stale idle can land - /// mid-test and spoof a clear edge. - async fn await_status_frame( - rx: &mut tokio::sync::broadcast::Receiver, - session_id: &str, - status: &str, - ) { - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - loop { - while let Ok(raw) = rx.try_recv() { - let Ok(v) = serde_json::from_str::(&raw) else { - continue; - }; - if v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.status" - && v["event"]["status"] == status - { - return; - } - } - assert!( - tokio::time::Instant::now() < deadline, - "no freshAgent.status:{status} frame for {session_id}" - ); - tokio::time::sleep(Duration::from_millis(5)).await; - } - } - - /// The consumer's `sdk.status:idle` arm, which only the legacy protocol fakes - /// still reach (the real sidecar sends no idle since C09; C17/C18 delete the - /// arm). An idle after an earlier turn's result is an edge of its own and - /// releases the busy truth: the deleted result/idle pair latch skipped - /// exactly this idle and left the session busy (`apply_snapshot_metadata` - /// then reported `running`). The real sidecar's G23 behavior is pinned on - /// RealSidecarSim by `a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy` - /// (crates/freshell-ws/tests/freshagent_claude_lifecycle.rs). - #[tokio::test] - async fn a_legacy_fake_idle_after_an_earlier_result_releases_the_busy_truth() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-stream-end-idle"), None) - .await; - let created = await_claude_created(&mut rx, "req-stream-end-idle").await; - let sid = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &sid, "idle").await; - - st.handle_send(send_msg(&sid, "turn one")).await; - inject_raw_send(&st, &sid, "__emit_result__").await; - await_in_turn(&st, &sid, false).await; - - st.handle_send(send_msg(&sid, "turn two")).await; - assert!(in_turn_of(&st, &sid).await, "the second turn is running"); - // A legacy fake's idle while the second turn runs. - inject_raw_send(&st, &sid, "__emit_idle__").await; - await_in_turn(&st, &sid, false).await; - drop(env); - } - - #[tokio::test] - async fn in_turn_clears_on_exactly_the_four_contract_edges_fail_closed_otherwise() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-in-turn-edges"), None) - .await; - let created = await_claude_created(&mut rx, "req-in-turn-edges").await; - let sid = created["sessionId"].as_str().unwrap().to_string(); - // Fold the create-time idle BEFORE any turn starts: otherwise that - // stale idle can land mid-test and spoof a clear edge (flaked twice - // under full-suite load — this is the documented post-create hygiene - // every other busy-tracker test already follows). - await_status_frame(&mut rx, &sid, "idle").await; - - // (a) sdk.result clears with ANY subtype (r2: a result frame ends the turn — - // here an ERRORED result, the subtype the voided "success-only" wording excluded): - st.handle_send(send_msg(&sid, "turn one")).await; - assert!(in_turn_of(&st, &sid).await, "a send sets the busy truth"); - // inject_raw_send: the provider's terminal edge, NOT another tracked op - // (a magic-text handle_send would enqueue a phantom second op under the - // ep2-r2 order-explicit tracker). - inject_raw_send(&st, &sid, "__emit_result_error__").await; - await_in_turn(&st, &sid, false).await; - - // Fail-closed: the NON-EDGES must NOT clear — sdk.error, sdk.status - // compacting, sdk.assistant frames all leave in_turn TRUE. - st.handle_send(send_msg(&sid, "turn two")).await; - inject_raw_send(&st, &sid, "__emit_error__").await; - inject_raw_send(&st, &sid, "__emit_compacting__").await; - inject_raw_send(&st, &sid, "__emit_assistant__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &sid).await, - "sdk.error / sdk.status=compacting / sdk.assistant are NOT clear edges (fail-closed)" - ); - - // (b) sdk.status with status == "idle" clears: - inject_raw_send(&st, &sid, "__emit_idle__").await; - await_in_turn(&st, &sid, false).await; - - // (d) a completed handle_interrupt clears — the interrupted turn's OWN - // terminal result (per the SDK receipt-before-result contract) does - // it; an error-subtype result never chimes: - st.handle_send(send_msg(&sid, "turn three")).await; - assert!(in_turn_of(&st, &sid).await); - // Baseline: drop every frame the create/emit dance left on the bus. - while rx.try_recv().is_ok() {} - st.handle_interrupt(interrupt_msg(&sid)).await; - // ep4-r2 F1: the gate releases at the interrupted op's OWN result fold - // (the fake emits settle → result in SDK order); poll it. - await_in_turn(&st, &sid, false).await; - tokio::time::sleep(Duration::from_millis(300)).await; - while let Ok(raw) = rx.try_recv() { - let frame: Value = serde_json::from_str(&raw).unwrap(); - assert_ne!( - frame["event"]["type"], - json!("freshAgent.turn.complete"), - "an interrupt NEVER chimes even though the turn's own result frame lands: {frame}" - ); - } - - // (c) sidecar EOF/death (the SIDECAR_EXITED arm) clears: - st.handle_send(send_msg(&sid, "turn four")).await; - assert!(in_turn_of(&st, &sid).await); - let in_turn_handle = { - st.sessions - .lock() - .await - .get(&sid) - .expect("still tracked") - .in_turn - .clone() - }; - st.handle_send(send_msg(&sid, "__exit__")).await; - let deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while in_turn_handle.load(std::sync::atomic::Ordering::SeqCst) { - assert!( - tokio::time::Instant::now() < deadline, - "sidecar EOF/death never cleared the busy truth (edge c)" - ); - tokio::time::sleep(Duration::from_millis(5)).await; - } - // Task 5: the IN-FLIGHT death rings the unified edge — the busy clear - // and the synthesized `freshAgent.turn.complete` are the SAME EOF - // tail (the armed state was captured at EOF entry, before the clear). - // Await the edge for the broadcast id and pin its finite numeric `at`. - let mut synthesized_at: Option = None; - let edge_deadline = tokio::time::Instant::now() + Duration::from_secs(15); - while synthesized_at.is_none() { - while let Ok(raw) = rx.try_recv() { - let frame: Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.event" - && frame["sessionId"] == sid - && frame["event"]["type"] == "freshAgent.turn.complete" - { - synthesized_at = frame["event"]["at"].as_i64(); - } - } - assert!( - tokio::time::Instant::now() < edge_deadline, - "the in-flight sidecar death never rang freshAgent.turn.complete (edge c)" - ); - tokio::time::sleep(Duration::from_millis(5)).await; - } - assert!( - synthesized_at.unwrap_or(0) > 0, - "the synthesized edge carries a finite numeric at" + /// Mark a fixture session mid-turn: a send the CLI started, armed in the + /// session's turn ledger the way `handle_send` and the stdout consumer + /// would have left it. + async fn prime_fixture_running_turn(st: &FreshClaudeState, map_key: &str) { + let guard = st.sessions.lock().await; + let mut ledger = guard + .get(map_key) + .expect("tracked session") + .ledger + .lock() + .expect("turn ledger lock"); + ledger.arm( + "fixture-turn".to_string(), + None, + crate::claude_turns::SendKind::Turn, ); - drop(env); + ledger.fold(&crate::claude_turns::TurnFact::Command { + uuid: "fixture-turn".to_string(), + state: crate::claude_turns::CommandState::Started, + }); } #[tokio::test] @@ -20330,13 +19469,11 @@ rl.on('line', (line) => { #[tokio::test] async fn rollback_during_a_compact_turn_is_refused_busy_turn_with_zero_teardown_traffic() { - // Delta-r1 F1: handle_compact marks the claude busy truth (`in_turn`) UNDER - // the session turn lock BEFORE writing the /compact send to the sidecar — - // the check-then-set window against handle_rollback's busy gate is closed. - // A claude compact turn ends at the SDK's `sdk.result` frame (any subtype — - // the existing four-edge clear set, no new clear edge): WHILE it runs, a - // rollback refuses BUSY_TURN with zero teardown/spawn traffic; AFTER it, - // the gate reopens. + // handle_compact arms the turn ledger UNDER the session turn lock BEFORE + // writing the /compact send to the sidecar (C17) — the check-then-set + // window against handle_rollback's busy gate is closed. The compact ends + // at its own `sdk.result` (any subtype): WHILE it runs, a rollback refuses + // BUSY_TURN with zero teardown/spawn traffic; AFTER it, the gate reopens. let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); @@ -20347,8 +19484,8 @@ rl.on('line', (line) => { st.handle_compact(compact_msg(&session_id, None)).await; assert!( - in_turn_of(&st, &session_id).await, - "a compact marks in_turn BEFORE the sidecar write (F1)" + ledger_busy_of(&st, &session_id).await, + "a compact arms the ledger BEFORE the sidecar write" ); let (sink, captured) = capturing_sink(); @@ -20372,13 +19509,15 @@ rl.on('line', (line) => { "no teardown" ); - // The compact turn ends at the SDK's result frame (any subtype — here an - // ERRORED one); the four-edge clear set already covers it, and the busy - // gate reopens (a follow-up rollback leaves the window for its true - // verdict — NOTHING_TO_UNDO on this transcript-less session). - // inject_raw_send: the compact op's OWN edge, not a phantom second op. + // The compact turn ends at its result frame (any subtype — here an + // ERRORED one), and the busy gate reopens (a follow-up rollback leaves + // the window for its true verdict — NOTHING_TO_UNDO on this + // transcript-less session). The fake reports no command lifecycle, so + // the result settles the oldest command (the compact) by the head + // fallback. inject_raw_send: the compact's own result, not a second + // command. inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; + await_ledger_busy(&st, &session_id, false).await; let (sink2, captured2) = capturing_sink(); st.handle_rollback( rollback_op(&session_id, "rb-compact-2", RollbackDirection::Undo), @@ -20393,2308 +19532,6 @@ rl.on('line', (line) => { drop(env); } - /// Focused-review ep1-r1 F1 + ep1-r2 F2 + ep1-r3 F1 — the exact THREE-STAGE - /// queue repro (the fake sidecar consumes stdin FIFO, so each magic text - /// below models one queued provider turn's frames, in strict consumption - /// order): turn A running → `/compact` QUEUED behind it (armed) → user - /// send B QUEUED behind the compact (the garlanded tracker). Rollback is - /// BUSY_TURN from A's terminal edge all the way through B's own terminal - /// edge, with zero teardown traffic throughout: - /// - /// 1. A's `sdk.result` folds as the arm's structural prior edge — gate - /// HOLDS (ep1-r1 F1); - /// 2. B's acceptance disarms nothing (ep1-r2 F2 — a send ACCEPTED onto - /// the FIFO queue proves nothing about drainage); - /// 3. the compact's observed `sdk.status:compacting` + its OWN terminal - /// edge DISARM the trackers but the busy truth SURVIVES (ep1-r3 F1: - /// the observed-compaction branch must never release busy while a - /// garlanded send is still owed — B is generating NOW); - /// 4. B's own terminal edge — folding through the NORMAL unarmed - /// four-edge path — releases the gate exactly there. - #[tokio::test] - async fn queued_compact_with_a_garlanded_send_holds_busy_until_the_sends_own_terminal_edge() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qcompact"), None) - .await; - let created = await_claude_created(&mut rx, "req-qcompact").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Fold the create-time idle BEFORE any turn starts (no stale clear edge). - await_status_frame(&mut rx, &session_id, "idle").await; - - // Turn A starts (busy); the compact queues BEHIND it. - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!( - in_turn_of(&st, &session_id).await, - "turn A marks the busy truth" - ); - st.handle_compact(compact_msg(&session_id, None)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the queued compact keeps the busy truth set" - ); - - // (1) The PRIOR turn's terminal edge arrives in-stream: consumed as - // the arm's owed prior edge — the TRACKER holds the queued compact - // (ep1-r1 F1). ep3-r3 F1: at the rollback GATE, a provably-quiescent - // compact-only debt ADMITS and survives-absorbs (nothing running, no - // live candidate, no Turn owed): the rollback proceeds (nothing to - // undo here — zero teardown), the entry SURVIVES, and the gate is - // opened optimistically — C's later proven start re-closes it. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the prior turn's terminal edge never releases a queued compact" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qcompact-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!(frames.len(), 1, "{frames:?}"); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep3-r3 F1: provably-quiescent compact-only debt absorbs at the gate (the entry SURVIVES — its proven start re-closes the gate): {frames:?}" - ); - assert!( - st.sessions.lock().await.contains_key(&session_id), - "no teardown" - ); - - // (2) Send B lands (queued BEHIND the compact provider-side). ep1-r2 - // F2 + ep3-r3 F1: a queued TURN behind the surviving compact RE-CLOSES - // the gate — owed turn debt is never absorbable. - st.handle_send(send_msg(&session_id, "turn three")).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qcompact-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "F2: an accepted send never proves the compact drained — the gate stays closed" - ); - - // (3a) The compact's own turn starts: the provider OBSERVABLY compacts - // and its completion boundary confirms the run was the manual one - // (ep3-r1 F1: the trigger-blind status alone never promotes). - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qcompact-3", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "mid-compact: the gate is held until the compact's OWN terminal edge" - ); - - // (3b) ep1-r3 F1 CORE: the compact's OWN terminal edge lands while the - // garlanded send B is STILL generating. The trackers disarm here, but - // the busy truth MUST SURVIVE — a rollback admitted now would tear - // down/fork B mid-turn (the sole mid-turn protection requirement). - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "F1: the compact's terminal edge never releases the gate while the garlanded send B generates" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qcompact-4", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "BUSY_TURN", - "F1: BUSY_TURN from A's result THROUGH the compact's result — B still owns the turn: {frames:?}" - ); - assert_eq!( - env.spawn_count(), - 1, - "zero teardown traffic while B generates" - ); - assert!( - st.sessions.lock().await.contains_key(&session_id), - "no teardown" - ); - - // (4) B's OWN terminal edge folds through the normal unarmed four-edge - // path — the gate releases EXACTLY here. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink2, captured2) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qcompact-5", RollbackDirection::Undo), - sink2, - ) - .await; - let frames2 = captured_json(&captured2); - assert_eq!( - frames2[0]["event"]["code"], "NOTHING_TO_UNDO", - "the busy gate released exactly at B's own terminal edge: {frames2:?}" - ); - drop(env); - } - - /// Focused-review ep3-r1 F1 (automatic compaction is NEVER a queued - /// explicit compact's evidence): the bare `sdk.status:compacting` frame - /// carries NO trigger — the SDK fires it for an explicit `/compact` AND for - /// its own automatic context compaction. With [A done, C1 (queue-dropped) - /// ahead, B queued as the de-facto active turn], B automatically - /// compacts mid-turn: status-time promotion attributed the AUTO compaction - /// to C1 ("promotes the phantom"); B's own result frame then retired the - /// PHANTOM compact and stranded B queued with no edge left to retire it — - /// `in_turn` held FOREVER (BUSY_TURN for every later - /// undo). Only the SDK's compact COMPLETION boundary - /// (`sdk.compact_boundary {trigger}`) discriminates the trigger; promotion - /// now requires the `manual` boundary. - /// - /// A → C1 → B → A's result (A retires) → B generating → AUTO compact - /// (status + boundary auto — NO promotion) → B's own result - /// (B retires) → the gate OPENS. - #[tokio::test] - async fn an_automatic_compaction_mid_turn_is_never_attributed_to_a_queued_compact() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-autocompact"), None) - .await; - let created = await_claude_created(&mut rx, "req-autocompact").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_send(send_msg(&session_id, "turn two")).await; // B - - // A's result retires A; [C1 dropped, B (de-facto active)] remains. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!(in_turn_of(&st, &session_id).await, "C1(dropped) + B owed"); - - // B's AUTOMATIC context compaction: bare compacting status + its - // completion boundary tagged `auto`. NEVER C1's evidence: C1 is NOT - // promoted and the gate stays closed while B generates. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_auto__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-auto-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "ep3-r1 F1: the queued C1 is NOT promoted by an automatic compaction — B still owes its terminal edge" - ); - - // B's own result retires B — and the gate OPENS: - // nothing is owed (C1's silent drop is proven by B's activity per the - // existing drop-absorber), the pane is never permanently busy. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-auto-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep3-r1 F1: the gate opened at B's own terminal edge — never wedged by a mis-promoted phantom compact: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep3-r2 F1 (interrupt inside the compact candidate - /// window): C1's bare `sdk.status:compacting` has arrived but its manual - /// completion boundary has NOT — C1 is exactly the execution the SDK's - /// `query.interrupt()` cancels, while every op QUEUED BEHIND it (C2, S) - /// provably still runs. The silent-drop absorber (retire-to-first-Turn) - /// erased them too: `in_turn` went FALSE while C2 and S remained owed — - /// the sole mid-turn gate admitted a rollback wipe mid-generation. With a - /// LIVE candidate the interrupt retires EXACTLY the front compact. - /// - /// A → C1 → C2 → S → A's result (A retires) → C1 compacting (candidate) → - /// INTERRUPT (cancels C1 only — the gate STAYS CLOSED for C2 + S) → C2's - /// manual boundary + result (C2 retires; S still owed) → S's result (gate - /// OPENS exactly here). - #[tokio::test] - async fn an_interrupt_inside_the_compact_candidate_window_retires_only_the_interrupted_compact() - { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-icand"), None).await; - let created = await_claude_created(&mut rx, "req-icand").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_compact(compact_msg(&session_id, None)).await; // C2 - st.handle_send(send_msg(&session_id, "turn two")).await; // S - - // A's result retires A: [C1, C2, S] all queued, running empty. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - // C1 starts compacting (candidate pending — the boundary lands later). - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - // The interrupt cancels C1 — C2 and S provably survive provider-side: - // the gate MUST stay closed. - st.handle_interrupt(interrupt_msg(&session_id)).await; - assert!( - in_turn_of(&st, &session_id).await, - "ep3-r2 F1: the interrupt cancelled ONLY the in-flight C1 — C2 and S are still owed" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-icand-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "ep3-r2 F1: rollback stays refused while C2/S remain owed" - ); - - // C2 then runs for real: status marks its candidate, the manual - // boundary promotes it, and its own result retires it; S is still owed - // behind it — the gate holds. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!(in_turn_of(&st, &session_id).await, "S still owed behind C2"); - - // S's own pair: the gate OPENS at the last owed edge, exactly. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-icand-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep3-r2 F1: the gate opened at S's own edge — never early, never wedged: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep3-r2 F2 (provider-session death behind a live stdout): - /// the sidecar's consumeStream finally DELETES the JS session while the - /// long-lived stdout stays open; a later send arms the tracker and is then - /// answered by a lone signed `sdk.error{sessionNotFound:true}` — no result, - /// no idle, no EOF ever follows. The queue-only clear wedged `in_turn` - /// forever (BUSY_TURN for every later undo). The signed not-found error - /// retires the WHOLE tracker: the ops provably cannot complete. - #[tokio::test] - async fn a_signed_session_not_found_error_opens_the_gate_instead_of_wedging_it() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-notfound"), None) - .await; - let created = await_claude_created(&mut rx, "req-notfound").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - // One completed turn (armed then retired by its result) — the gate is open. - st.handle_send(send_msg(&session_id, "turn one")).await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - - // The provider-side session DIES (stream end deletes it) — stdout stays - // open, nothing announces it yet. - inject_raw_send(&st, &session_id, "__drop_session__").await; - - // A new send ARMS the tracker; the fake answers with the signed - // not-found error and NOTHING after. - st.handle_send(send_msg(&session_id, "turn two")).await; - assert!( - in_turn_of(&st, &session_id).await, - "the send arms the gate before the death surfaces" - ); - - // The signed not-found error must retire the whole tracker — pre-fix - // the queue-only clear left `running` armed forever (the BUSY wedge). - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-nf-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_ne!( - frames[0]["event"]["code"], "BUSY_TURN", - "ep3-r2 F2: the gate is open after the provider session's death — never wedged: {frames:?}" - ); - drop(env); - } - - /// F2 disarm case (b) — the FIFO-DROP proof, modeled on the strict queue - /// order: prior turn running + compact queued (armed) + prior turn's - /// result consumed + a fresh send accepted + the SEND's terminal edge with - /// NO observed compacting anywhere since the arm. FIFO-strictly, that edge - /// can only be the garlanded send's own terminal — and a compact that had - /// run ahead of it would have been observed compacting first. So the - /// queued compact provably never ran (provider-dropped): the trackers - /// disarm and the send's edge ends the busy truth (the gate reopens). - #[tokio::test] - async fn queued_compact_disarms_on_the_garlanded_sends_result_without_observed_compacting() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qproof"), None).await; - let created = await_claude_created(&mut rx, "req-qproof").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!(in_turn_of(&st, &session_id).await); - st.handle_compact(compact_msg(&session_id, None)).await; - - // The prior turn's result: consumed as the prior turn's — gate holds. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the armed window holds through the prior turn's result" - ); - - // The garlanded send, then ITS terminal edge with NO compacting - // observed since the arm: the FIFO-drop proof fires — the queued - // compact provably never ran, busy releases with the send's edge. - st.handle_send(send_msg(&session_id, "turn three")).await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink2, captured2) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qproof-1", RollbackDirection::Undo), - sink2, - ) - .await; - assert_eq!( - captured_json(&captured2)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the dropped-compact proof released the gate with the send's terminal edge" - ); - drop(env); - } - - /// F2 disarm case (c): an in-stream `sdk.error` frame disarms the pending - /// compact (the provider errored — the queued compact provably never - /// arrives as its own turn); the error itself never clears `in_turn` - /// (fail-closed), so the NEXT terminal edge ends the busy truth. - #[tokio::test] - async fn queued_compact_pending_disarms_on_sdk_error() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qdisarm"), None) - .await; - let created = await_claude_created(&mut rx, "req-qdisarm").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Fold the create-time idle BEFORE any turn starts (no stale clear edge). - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!(in_turn_of(&st, &session_id).await); - st.handle_compact(compact_msg(&session_id, None)).await; - inject_raw_send(&st, &session_id, "__emit_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "sdk.error is NOT itself an in_turn clear edge (fail-closed)" - ); - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qdisarm-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the sdk.error disarmed the pending compact — the next terminal edge reopened the gate" - ); - drop(env); - } - - /// F2's interposition window, FIFO-strict: a send accepted BETWEEN the arm - /// and the prior turn's result queues BEHIND the compact on the provider - /// — its acceptance (the deleted belt) and even the PRIOR turn's result - /// must not open the gate: the compact and the send are BOTH still queued. - /// (The pre-F2 belt disarmed at the send, so the prior result then cleared - /// the busy truth with the compact still queued — the review's repro for - /// "rollback mid-compact".) ep1-r3 F1: with the send still owed after the - /// compact, the gate survives the compact's own observed terminal edge too - /// and opens only at the garlanded send's own terminal edge. - #[tokio::test] - async fn a_send_between_the_arm_and_the_prior_result_keeps_the_gate_closed_through_both() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qinterpose"), None) - .await; - let created = await_claude_created(&mut rx, "req-qinterpose").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!(in_turn_of(&st, &session_id).await); - st.handle_compact(compact_msg(&session_id, None)).await; - // IMMEDIATELY after the arm, before ANY terminal edge: the send. - st.handle_send(send_msg(&session_id, "turn two")).await; - - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qinterpose-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "no terminal edge yet — the gate is closed beyond doubt" - ); - - // The PRIOR turn's result arrives. Both the compact and the send are - // STILL queued behind it — rollback must still refuse. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qinterpose-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "FIFO-strict: compact + send are still queued behind the prior turn's result — the gate holds" - ); - assert_eq!( - env.spawn_count(), - 1, - "no teardown traffic from any refused attempt" - ); - - // The compact's observed run + terminal edge: ep1-r3 F1 — the gate - // SURVIVES here (the garlanded send "turn two" is still generating); - // only the send's own terminal edge releases it. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "F1: the compact's observed terminal edge never releases the gate with a garlanded send owed" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qinterpose-3", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "F1: the garlanded send owns the turn past the compact's terminal edge — the gate holds" - ); - assert_eq!( - env.spawn_count(), - 1, - "zero teardown traffic while the garlanded send generates" - ); - - // The garlanded send's OWN terminal edge reopens the gate. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink2, captured2) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qinterpose-4", RollbackDirection::Undo), - sink2, - ) - .await; - assert_eq!( - captured_json(&captured2)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the garlanded send's own terminal edge released the gate" - ); - drop(env); - } - - /// Focused-review ep1-r5 F1 (queued-compact CARDINALITY): TWO `/compact`s - /// armed behind the same running turn are TWO distinct queued ops — - /// SUPERSEDED in ep3-r3 F1 (see the test body), which replaced the - /// hold-closed-until-every-edge rule with provable-quiescence absorption. - /// Queue: turn A running → C₁ queued → C₂ queued. - /// compact's run carries its OWN start evidence (the compacting candidate), - /// the queue's UNPROMOTED compacts are provably quiescent — nothing running, - /// no candidate pending, and (critically) no Turn owed. The rollback gate - /// ADMITS there and absorbs the compact debt (the teardown discards the - /// unstarted provider inputs; see [`absorb_unstarted_compact_debt`]). The - /// pre-fix shape wedged busy FOREVER on exactly this debt whenever the - /// front-entry promotion consumed a LATER compact's evidence (a dropped - /// compact ahead of a running one — in-band indistinguishable, so the debt - /// must settle at the gate, not the attribution). A turnaround edge replay - /// after absorption folds as benign noise (boundary-promotion on an empty - /// queue promotes nothing). - #[tokio::test] - async fn two_queued_compacts_are_provably_quiescent_and_absorb_at_the_rollback_gate() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-q2compact"), None) - .await; - let created = await_claude_created(&mut rx, "req-q2compact").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - // Turn A running; BOTH compacts queue behind it. - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!(in_turn_of(&st, &session_id).await, "turn A is running"); - st.handle_compact(compact_msg(&session_id, None)).await; - st.handle_compact(compact_msg(&session_id, None)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the running turn holds the busy truth" - ); - - // A's terminal edge retires A: [C1, C2] queued, nothing running, no - // candidate — the provider is provably quiescent: the rollback ADMITS - // and absorbs both compacts (their unstarted inputs die with the - // teardown; a stray loner races into the signed session-not-found - // fence). - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "both compacts still owed past A's edge" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2c-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep3-r3 F1: provably-quiescent compact debt absorbs at the gate — never wedged: {frames:?}" - ); - assert!( - !in_turn_of(&st, &session_id).await, - "the absorbed debt leaves the gate open" - ); - - // The absorbed entries' later provider evidence (had either actually - // run against the stream before the teardown) RE-ARMS the gate for the - // duration of that proven run — and its OWN terminal edge re-opens it. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "a revived absorbed compact RE-ARMS the gate at its proven start" - ); - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the revived compact's edge retires IT — the second absorbed entry still owes (tracker-truth busy)" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2c-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the remnant survivor absorbs at the gate again — optimistic admit, entry survives" - ); - assert!( - !in_turn_of(&st, &session_id).await, - "the gate stays open for quiescent compact-only debt" - ); - drop(env); - } - - /// Focused-review ep1-r5 F1 (garlanded-send CARDINALITY): TWO sends - /// accepted behind an armed compact owe TWO terminal edges. The one-slot - /// bool garlanded tracker kept busy through the compact's own terminal - /// edge but released at the FIRST garlanded send's edge — while S₂ was - /// still generating (a rollback admitted there forks/tears down S₂ - /// mid-turn). - /// - /// Queue: turn A running → C queued → S₁ queued → S₂ queued. - /// 1. A's terminal edge: the arm's structural prior edge — HOLDS; - /// 2. C's observed compacting + OWN terminal edge retires C — HOLDS - /// (S₁ and S₂ still owe their edges); - /// 3. S₁'s terminal edge: one garlanded send remains — HOLDS; - /// 4. S₂'s terminal edge: the gate RELEASES exactly there. - #[tokio::test] - async fn a_compact_with_two_garlanded_sends_holds_busy_until_the_last_sends_terminal_edge() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-q2sends"), None) - .await; - let created = await_claude_created(&mut rx, "req-q2sends").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C - st.handle_send(send_msg(&session_id, "turn two")).await; // S₁ - st.handle_send(send_msg(&session_id, "turn three")).await; // S₂ - - // (1) A's terminal edge: the arm's owed prior edge — C, S₁, S₂ are - // ALL still queued behind it. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2s-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "compact + both sends still queued behind the prior turn's edge" - ); - - // (2) C's observed run + OWN terminal edge: retires C — S₁ and S₂ - // still owe their terminal edges, so the gate HOLDS. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "the compact's terminal edge never releases the gate with two garlanded sends owed" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2s-2", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "BUSY_TURN", - "S₁ and S₂ still owed past the compact's terminal edge: {frames:?}" - ); - assert_eq!( - env.spawn_count(), - 1, - "zero teardown traffic while the garlanded sends generate" - ); - - // (3) ep1-r5 F1 CORE: S₁'s terminal edge is NOT the last owed edge — - // S₂ is still generating, so the gate MUST stay closed here (the - // one-slot garlanded bit released at S₁'s edge). - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "S₁'s terminal edge never releases the gate — S₂ is still generating" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2s-3", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "BUSY_TURN", - "S₂ still generating past S₁'s terminal edge: {frames:?}" - ); - assert_eq!(env.spawn_count(), 1, "zero teardown traffic while S₂ runs"); - assert!( - st.sessions.lock().await.contains_key(&session_id), - "no teardown" - ); - - // (4) S₂'s OWN terminal edge: the gate releases EXACTLY here. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-q2s-4", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "the gate released exactly at the last garlanded send's terminal edge: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep2-r1 F2 (the stale prior-edge debt): a SECOND compact - /// armed while the FIRST compact is mid-compaction owes C₁'s terminal edge - /// (the one op active at its arm). When C₁'s terminal lands it is C₁'s OWN - /// edge (the observed-compacting branch) — yet that edge ALSO settles C₂'s - /// debt: miss that and, with C₂ subsequently provider-DROPPED while a - /// garlanded send completes, the send's edge is swallowed consuming the - /// stale debt, leaving every tracker set forever with no edge left to - /// retire them — the pane wedges BUSY and rollback is disabled - /// permanently. - /// - /// Queue: A completed → C₁ mid-compaction → C₂ armed (owes C₁'s edge) → S - /// garlanded → C₁'s result lands (C₂'s debt settled redundantly with C₁'s - /// retirement) → C₂ provider-dropped (never observed compacting) → S's - /// result is the drop proof: extinguishes C₂ AND releases the gate. - #[tokio::test] - async fn arming_a_compact_during_an_active_compaction_settles_its_debt_at_that_compactions_edge( - ) { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qstale"), None).await; - let created = await_claude_created(&mut rx, "req-qstale").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C₁ queued - // A's result lands first: it settles the arm's prior debt. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - // C₁ observably starts; C₂ arms while C₁ is MID-COMPACTION — it owes - // C₁'s terminal edge; S queues behind BOTH compacts. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - st.handle_compact(compact_msg(&session_id, None)).await; // C₂ armed - st.handle_send(send_msg(&session_id, "turn two")).await; // S garlanded - - // C₁'s result: retires C₁ AND settles C₂'s owed edge — C₂ remains the - // sole queued compact; S is still owed. The gate HOLDS throughout. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "C₁'s edge retired C₁ and settled C₂'s debt — C₂ and S still owe edges" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qstale-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "C₂ queued + S owed — the gate holds past C₁'s result" - ); - - // C₂ is provider-DROPPED (never observed compacting); S's result lands: - // the result is the FIFO-drop proof (C₂ provably never ran), releasing - // at the LAST garlanded edge. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qstale-2", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "NOTHING_TO_UNDO", - "ep2-r1 F2: S's edge is the drop proof and releases — no stale debt may swallow it: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep3-r5 F2 (publication contract): the candidate mark and - /// its gate-visible `in_turn` boost publish under ONE critical section — a - /// rollback probe that reads `in_turn` first and refuses to consult the - /// tracker on a false read can therefore never slip past a live candidate. - /// (The torn two-step shape is not statically reachable; this pins the - /// end-state contract the gate's decision rule leans on.) - #[test] - fn compact_candidate_publication_is_atomic_under_the_tracker_lock() { - let in_turn = std::sync::atomic::AtomicBool::new(false); - let tracker = std::sync::Mutex::new(TurnTracker::default()); - mark_compact_candidate(&in_turn, &tracker); - assert!(tracker.lock().expect("turn tracker lock").compact_candidate); - assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); - // The consume side unwinds the same pair under the same lock. - confirm_compact_candidate(&in_turn, &tracker, false); // auto boundary - assert!(!tracker.lock().expect("turn tracker lock").compact_candidate); - assert!(!in_turn.load(std::sync::atomic::Ordering::SeqCst)); - } - - /// Focused-review ep3-r5 F1 (the admit→teardown window): the gate's - /// quiescence-proof is consulted ONCE at admission; the rollback handler - /// then runs transcript I/O before tearing the sidecar down, and the - /// consumer task folds evidence WITHOUT the session turn lock — a compact - /// armed just before admission can START mid-handler (its status folded, - /// candidate observed), and pre-fix the complete-determined handler tore - /// the sidecar down mid-compaction anyway (the sole busy gate defeated). - /// The handler now RECHECKS the tracker at the point of no return: revived - /// busy truth aborts the rollback with BUSY_TURN and a compensating ledger - /// rewrite, with zero teardown traffic. - /// - /// Choreography: C1 armed (running) → C2 queued → C1 settles (fold retires - /// it; C2 quiescent) → rollback admitted at the gate (absorb, C2 survives) - /// → parked in the admit→teardown window (test knob) → C2's compacting - /// status folds (the candidate marks mid-flight compaction) → the recheck - /// ABORTS the rollback. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_compaction_starting_mid_rollback_aborts_at_the_pre_teardown_recheck() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let home = tempfile::tempdir().unwrap(); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - std::env::set_var("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS", "400"); - write_rollback_transcript(home.path(), "dur-midcomp", &two_turn_transcript()); - let (st, _rx) = state_with_bus(); - let sink_impl = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink_impl.clone()); - let _stdin_log = insert_rollback_fixture_session(&st, "dur-midcomp", "dur-midcomp").await; - - // Arm two compacts: C1 takes the running slot, C2 queues behind. - st.handle_compact(compact_msg("dur-midcomp", None)).await; - st.handle_compact(compact_msg("dur-midcomp", None)).await; - let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "dur-midcomp").await; - assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); - - // C1's own terminal edge (its compacted run settled): retires C1; C2 - // remains queued-but-quiescent. - fold_terminal_edge(&in_turn, &turn_tracker); - assert!( - in_turn.load(std::sync::atomic::Ordering::SeqCst), - "C2's debt keeps the busy truth" - ); - - let (sink, captured) = capturing_sink(); - let rollback_driver = { - let st = st.clone(); - tokio::spawn(async move { - st.handle_rollback( - rollback_op("dur-midcomp", "rb-midcomp-1", RollbackDirection::Undo), - sink, - ) - .await; - }) - }; - // Let the rollback pass the gate (absorb admits the quiescent C2) and - // park in the admit→teardown window — then C2's compaction STARTS. - tokio::time::sleep(Duration::from_millis(150)).await; - mark_compact_candidate(&in_turn, &turn_tracker); - rollback_driver.await.expect("rollback task"); - - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "BUSY_TURN", - "ep3-r5 F1: a compaction starting mid-handler aborts the rollback at the pre-teardown recheck: {frames:?}" - ); - assert_eq!( - env.spawn_count(), - 0, - "zero fork-create traffic — the teardown never engaged" - ); - assert!( - st.sessions.lock().await.contains_key("dur-midcomp"), - "no teardown" - ); - assert!( - sink_impl.load_rollback("claude", "dur-midcomp").is_none(), - "ep4-r6 F5: no pre-op record existed, so the compensation DELETED the pre-write row" - ); - std::env::remove_var("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS"); - drop(env); - } - - /// Focused-review ep3-r4 F1 (the absorb-revival STATUS window): SURVIVE- - /// absorb leaves the debt queued with the gate open; when an absorbed - /// compact actually starts, its bare `sdk.status:compacting` marks the - /// candidate — but pre-fix NOTHING re-armed `in_turn` until the manual - /// boundary arrived, so the whole status→boundary interval (and a status - /// arriving a beat AFTER a probe's admission check) let a rollback proceed - /// while the provider compacted mid-flight. The status marks the candidate - /// AND closes the gate (in_turn=true); consumption at the boundary either - /// promotes (gate stays held) or recomputes the owed-debt busy truth. - /// - /// A → C1 → C2 → A's result → absorb probe (admitted; C1+C2 survive; gate - /// open) → C1's STATUS lands (candidate: the gate RE-CLOSES immediately) - /// → manual boundary (C1 promoted; held) → C1's result (C1 retires; C2 - /// owed) → absorb probe (admitted again) — the gate never rides the - /// proven-running interval. - #[tokio::test] - async fn a_revived_absorbed_compact_closes_the_gate_at_its_status_frame_not_just_its_boundary() - { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-revive"), None).await; - let created = await_claude_created(&mut rx, "req-revive").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_compact(compact_msg(&session_id, None)).await; // C2 - - // A's result retires A: [C1, C2] queued, nothing running. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - // Provably-quiescent debt: the gate ADMITS (entries survive). - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-rev-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "quiescent compact debt admits" - ); - assert!( - !in_turn_of(&st, &session_id).await, - "the absorb opened the gate" - ); - - // C1 now actually starts: the STATUS alone must RE-CLOSE the gate (the - // candidate is provable in-flight compaction — mid-compaction rollback - // is never admissible). - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-rev-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "ep3-r4 F1: the revived compact's status frame holds the gate — never admit mid-compaction" - ); - assert!(in_turn_of(&st, &session_id).await); - - // C1's boundary promotes it (still held); its own result retires it; C2 - // is owed; the gate admits at the next absorb probe, quiescent again. - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "promotion holds the closed gate through the boundary" - ); - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "C2's surviving debt keeps the tracker busy" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-rev-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the remnant survivor absorbs again — quiescent debt settles only at the gate" - ); - drop(env); - } - - /// Focused-review ep3-r3 F1 (dropped compact ahead of a RUNNING compact): - /// the wire carries no compaction-op identity — the manual boundary always - /// promotes the FRONT queued compact, so [C1 silently dropped, C2 runs] is - /// in-band indistinguishable from [C1 runs, C2 queued]: C2's whole evidence - /// set is consumed by the phantom C1 entry, and C2's own entry remains as - /// debt with NO further evidence, so - /// `in_turn` holds forever and every later undo/redo is refused BUSY_TURN - /// ("unavailable until another submission, which would itself destroy - /// redo"). The gate now admits when the debt is provably quiescent: - /// nothing running, no live candidate, and no Turn owed — unpromoted - /// compacts have not started (their runs raise the candidate FIRST), and - /// the rollback teardown discards their provider-side inputs. - /// - /// A → C1 → C2 → A's result (A retires) → C1 dropped (never evidences) → - /// C2 runs (status + manual boundary promotes the phantom C1) → C2's - /// result (the phantom retires; C2's entry remains) → - /// the rollback gate OPENS on admission: the provably-quiescent debt - /// absorbs (both in a single atomic decision). - #[tokio::test] - async fn a_dropped_compacts_remnant_debt_absorbs_at_the_gate_after_the_running_compact_retires_it( - ) { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-dropahead"), None) - .await; - let created = await_claude_created(&mut rx, "req-dropahead").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_compact(compact_msg(&session_id, None)).await; // C2 - - // A's result retires A: [C1, C2] queued, running empty. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!(in_turn_of(&st, &session_id).await, "both compacts owed"); - - // C1 is provider-DROPPED (never evidences). C2 RUNS: its status marks - // the candidate and its manual boundary promotes the FRONT — the - // phantom C1. C2's result lands: it retires running (phantom C1). - // C2's own entry is the surviving dead debt. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - // The debt is provably quiescent (nothing running, no candidate, no - // Turn owed): the rollback gate ADMITS and absorbs — never wedges busy. - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-dropahead-0", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep3-r3 F1: the dropped compact's remnant debt absorbed at the gate — rollback proceeds, never wedged: {frames:?}" - ); - assert!( - !in_turn_of(&st, &session_id).await, - "the absorbed debt leaves the busy truth open" - ); - drop(env); - } - - /// Focused-review ep2-r2 F1 (the INTERLEAVED queue): the FIFO-drop proof may - /// extinguish ONLY compacts queued AHEAD of the evidenced send — a compact - /// queued BEHIND it remains genuinely queued and survives. Bare op-counts - /// cannot express that order: with queue [C1, S1, C2] and C1 - /// provider-dropped (never observed compacting), S1's terminal edge fired - /// the drop branch and zeroed the whole compact count — C2 included — - /// releasing the gate while C2 could still run. Rollback passed the sole - /// busy gate with a compact mid-queue. - /// - /// Queue: A running → C1 → S1 → C2 (C2 queued BEHIND the send). C1 never - /// observed (silent provider drop); C2 then runs for real. - #[tokio::test] - async fn a_dropped_compact_extinguishes_only_compacts_ahead_of_the_evidencing_send() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qinterleave"), None) - .await; - let created = await_claude_created(&mut rx, "req-qinterleave").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_send(send_msg(&session_id, "turn two")).await; // S1 - st.handle_compact(compact_msg(&session_id, None)).await; // C2 (behind S1) - - // A's result retires A. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!(in_turn_of(&st, &session_id).await, "three ops still queued"); - - // C1 is provider-DROPPED (never observed); S1's result lands: the result - // evidences C1's drop and retires S1 — but C2 is queued BEHIND S1 and - // may still run, so the busy gate MUST stay closed past S1's edge. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "S1's edge retires C1's drop + S1 only — C2 is still queued behind the send" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qint-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "NOTHING_TO_UNDO", - "ep3-r3 F1: the surviving queued-behind compact is provably quiescent (no candidate) — the gate ADMITS its debt optimistically, and the entry SURVIVES (its proven start re-closes the gate): {frames:?}" - ); - assert!( - st.sessions.lock().await.contains_key(&session_id), - "no teardown" - ); - - // C2 then runs for real: its observed compacting + confirmed-manual - // boundary → pair lands — the gate releases EXACTLY at C2's own - // terminal edge (never earlier). - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qint-2", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "the gate released exactly at C2's own terminal edge: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep2-r4 (interrupt inside the drop window): with the - /// running slot empty and a queue [C1 (dropped silently — NEVER promoted), - /// B (the de-facto active turn)], the queue-front retirement rule popped - /// the FRONT — the COMPACT — off the tracker and left the interrupted B - /// stranded forever (no result frame exists for an interrupted turn): - /// `in_turn` held and every undo/redo was refused BUSY_TURN from then on. - /// The interrupt retires the op ACTUALLY in flight — the de-facto ACTIVE - /// queued op — and a Turn can only be de-facto active with every op ahead - /// of it already retired-or-dropped: those ahead compacts' silence at the - /// live turn's generation IS the drop evidence, absorbed here. - /// - /// A running → C1 queued → B queued → A's result → B generating NOW → - /// interrupt. Aftermath: NOTHING stands — the gate opens and STAYS sized - /// (a follow-up turn cycles fully clean). - #[tokio::test] - async fn interrupting_the_active_turn_absorbs_ahead_queued_silently_dropped_compacts() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qdropint"), None) - .await; - let created = await_claude_created(&mut rx, "req-qdropint").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_send(send_msg(&session_id, "turn two")).await; // B - - // A's result retires the running op; C1's compacting frame NEVER came — - // B generates (the drop-evidence window). The gate is closed. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "C1 believed queued + B outstanding — the gate is closed pre-interrupt" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qdi-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "pre-interrupt the gate is closed beyond doubt" - ); - - // Interrupt the ACTIVE turn (B): its entry AND C1's never-evidenced - // drop retire together — nothing remains to wedge the gate. - st.handle_interrupt(interrupt_msg(&session_id)).await; - // ep4-r1 F1: retirement waits for the sidecar's awaited settle ack. - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qdi-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep2-r4 F1: the gate opened exactly at the interrupt and stays truthful: {frames:?}" - ); - - // No-residue proof: a fresh turn's result cycles the tracker fully clean. - st.handle_send(send_msg(&session_id, "turn three")).await; - assert!(in_turn_of(&st, &session_id).await); - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - drop(env); - } - - /// Focused-review ep2-r3 (queue-advanced interrupt): queued TURNS are never - /// OBSERVED promoted (only compacts promote via the compacting frame), so - /// once the prior op's edge frees the `running` slot, the queue FRONT is the - /// de-facto active turn. Interrupting THAT turn produces no result frame — - /// nothing retires its queued entry afterward, so an interrupt that only - /// cleared the `running` slot left the entry there forever: `in_turn` stayed - /// true and the sole mid-turn gate rejected undo/redo permanently. The - /// interrupt retires the running slot OR the queue FRONT (the op actually in - /// flight) — never the still-queued ops behind it. - /// - /// A running → B queued → A's result retires A → B starts → interrupt B. - #[tokio::test] - async fn interrupting_a_queue_advanced_turn_releases_the_gate() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qadv"), None).await; - let created = await_claude_created(&mut rx, "req-qadv").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_send(send_msg(&session_id, "turn two")).await; // B queued - - // A's result retires the running op; B (the queue front) is now the - // de-facto active turn — the gate stays closed (B owes its own edge). - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "B advanced from the queue — the gate holds for its own edge" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qadv-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "B is in flight — the gate is closed pre-interrupt" - ); - - // Interrupt B (no result frame exists for it — the interrupt IS its - // busy-clear edge): the gate opens here, permanently. - st.handle_interrupt(interrupt_msg(&session_id)).await; - // ep4-r1 F1: retirement waits for the sidecar's awaited settle ack. - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qadv-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "the gate released with the interrupt — never wedged: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep2-r4 (interrupt with a never-promoted compact between): - /// [A running → C1 queued → B queued → A retires → B generates with C1 - /// silently DROPPED ahead of it (never promoted)]: the interrupted op is B. - /// The naive queue-front retirement removed C1 instead — stranding B (no - /// result frame exists for an interrupted turn) and wedging the sole - /// mid-turn gate FOREVER (BUSY_TURN for every later undo/redo). The - /// interrupt retires the OLDEST QUEUED TURN; the compacts ahead of it were - /// provably dropped by B's own generation (C1 promoted would have been - /// observed compacting before B's activity), so they absorb into the same - /// retirement. - #[tokio::test] - async fn interrupting_a_turn_behind_a_silently_dropped_compact_retires_the_turn_not_the_compact( - ) { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qdropabs"), None) - .await; - let created = await_claude_created(&mut rx, "req-qdropabs").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C1 - st.handle_send(send_msg(&session_id, "turn two")).await; // B - - // A's result retires A. [C1 (never promoted — dropped), B (now the - // de-facto active turn)] remains; the gate is closed. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!(in_turn_of(&st, &session_id).await, "C1(dropped) + B owed"); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qda-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "pre-interrupt: C1 + B are owed" - ); - - // Interrupt: retires B (the op actually in flight) and absorbs C1's - // never-evidenced drop. The gate OPENS — the pane must never wedge - // permanently busy over an interrupted turn. - st.handle_interrupt(interrupt_msg(&session_id)).await; - // ep4-r1 F1: retirement waits for the sidecar's awaited settle ack (the - // interrupt clears B AND absorbs the never-evidenced drop). - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qda-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "ep2-r4 F1: the gate opened at the interrupt — never wedged: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep2-r2 F2 (interrupt ≠ drain): a completed - /// `handle_interrupt` ends the ACTIVE turn's busy truth, but - /// `query.interrupt()` does not drain the sidecar's FIFO input queue — a - /// queued compact AND a queued send behind it both still run. The counter - /// gate read `in_turn || queued_compacts > 0` but NEITHER counted a queued - /// SEND: interrupting A with [C, S] behind it, then letting C complete, - /// left in_turn=false, queued=0, and S still generating — the gate accepted - /// rollback mid-send. - /// - /// Queue: A running → C → S; A is interrupted mid-turn; then C runs for - /// real and S generates — the gate stays closed until S's own edge. - #[tokio::test] - async fn an_interrupt_keeps_the_gate_closed_until_the_queued_compact_and_send_both_finish() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-qirq"), None).await; - let created = await_claude_created(&mut rx, "req-qirq").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A - st.handle_compact(compact_msg(&session_id, None)).await; // C - st.handle_send(send_msg(&session_id, "turn two")).await; // S - - // A is interrupted mid-turn (its OWN busy truth ends — but the FIFO - // queue is intact: C and S still owe their edges). - st.handle_interrupt(interrupt_msg(&session_id)).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qirq-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "the interrupt freed A only — C and S are still queued" - ); - - // C runs for real: observed compacting + confirmed-manual boundary + - // its result — retires C, and S is STILL owed: the gate MUST stay closed - // here. - inject_raw_send(&st, &session_id, "__emit_compacting__").await; - inject_raw_send(&st, &session_id, "__emit_compact_boundary_manual__").await; - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "ep2-r2 F2: C's edge retires C — S is still generating" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qirq-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "BUSY_TURN", - "ep2-r2 F2: past the interrupted turn's compact, the queued send still owns the gate: {frames:?}" - ); - assert_eq!(env.spawn_count(), 1, "zero teardown from refused attempts"); - - // S's result: the gate releases exactly at S's own terminal edge. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-qirq-2", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "NOTHING_TO_UNDO", - "the gate released exactly at S's own terminal edge: {frames:?}" - ); - drop(env); - } - - /// Focused-review ep4-r1 F1 (the request↔settle gap, now resolved as - /// settle-ack ≠ turn-complete per the SDK receipt-before-result contract - /// at ep4-r2): with the settle deferred, the gate must stay closed for the - /// whole provider-work window — and opens only at the interrupted turn's - /// OWN terminal frame (its result, emitted after the receipt - /// in that order). - /// - /// A running → interrupt (settle chain deferred by knob) → mid-window - /// rollback probe: BUSY_TURN → deferred chain (settle → result → idle) - /// → gate OPENS exactly there. - #[tokio::test] - async fn an_interrupt_keeps_the_gate_closed_until_the_interrupted_turns_own_frames_land() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS", "400"); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-settle"), None).await; - let created = await_claude_created(&mut rx, "req-settle").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; - assert!(in_turn_of(&st, &session_id).await, "the turn is running"); - - st.handle_interrupt(interrupt_msg(&session_id)).await; - // The write landed; the settle is DEFERRED: the gate MUST stay closed. - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-settle-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "ep4-r1 F1: an interrupt REQUEST never opens the gate — the interrupted turn's own terminal frames own the fold" - ); - assert!( - in_turn_of(&st, &session_id).await, - "the busy truth holds past the request write" - ); - - // The deferred chain arrives (receipt, then the turn's own result): - // the gate opens exactly at the op's own terminal edge. - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-settle-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the gate opened at the interrupted turn's own terminal frames, never at the receipt" - ); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INTERRUPT_SETTLE_MS"); - drop(env); - } - - /// Focused-review ep4-r1 F1 (rejection): a REJECTED interrupt (the sidecar - /// answers ok:false — e.g. the provider had no cancellable turn) retires - /// NOTHING: the gate stays closed and the turn's OWN terminal edge releases - /// it later. - #[tokio::test] - async fn a_rejected_interrupt_never_retires_the_running_turn() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT", "1"); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-ireject"), None) - .await; - let created = await_claude_created(&mut rx, "req-ireject").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; - - st.handle_interrupt(interrupt_msg(&session_id)).await; - // The rejection settles immediately: NOTHING retires — gate closed. - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "ep4-r1 F1: a rejected interrupt retires nothing" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-ireject-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "the rejected interrupt left the running turn owed — gate closed" - ); - - // The turn's OWN terminal edge (its real result) retires - // it — the gate opens exactly there. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-ireject-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the turn's own edge released the gate after the rejected interrupt" - ); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INTERRUPT_REJECT"); - drop(env); - } - - /// Focused-review ep4-r2 F2 (the absorbed-debt-started-behind-our-back - /// window): absorbed compact debt was pronounced "never started on the - /// provider side," but the sidecar's FIFO dispatch can start it AFTER the - /// absorb — and with the ep4-r1 fold-under-turn_lock shape, that start's - /// candidate fold blocked behind the rollback-held lock, so the recheck - /// saw nothing. The repair is the quiesce probe: the pre-teardown - /// interrupt's settle frame reaches the consumer's fold only AFTER every - /// already-emitted piece of evidence (stream order), so the revived - /// candidate is provably visible at the recheck — the rollback ABORTS - /// (BUSY_TURN + compensating ledger rewrite), the sidecar survives. - /// - /// Transcript-eligible durable fixture on a LIVE fake sidecar; [A → C1 → - /// A's result → C2] owed compacts; the gate absorb admits; the pre-teardown - /// quiesce probe (an interrupt write) elicits the fake's - /// FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE compacting STATUS - /// before its settle → the recheck provably sees the revived candidate - /// → BUSY_TURN, zero teardown, compensated ledger. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_compaction_starting_behind_the_absorb_aborts_at_the_quiesce_probes_recheck() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - std::env::set_var("FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE", "1"); - let home = tempfile::tempdir().unwrap(); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - write_rollback_transcript(home.path(), "dur-probe", &two_turn_transcript()); - let (st, _rx) = state_with_bus(); - let sink_impl = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink_impl.clone()); - insert_rollback_fixture_session_with_live_sidecar(&st, "rb-probe", "dur-probe").await; - - // [A → C1][A's result][C2] — compact-only owed queue (C1 promoted - // briefly under A, so retiring A leaves BOTH compacts queued and - // never-promoted: the absorb family's admittance shape). - st.handle_send(send_msg("rb-probe", "turn one")).await; // A - st.handle_compact(compact_msg("rb-probe", None)).await; // C1 queued behind A - inject_raw_send(&st, "rb-probe", "__emit_result_error__").await; - st.handle_compact(compact_msg("rb-probe", None)).await; // C2 queued - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, "rb-probe").await, - "the owed debt holds the gate closed pre-rollback" - ); - - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-probe", "rb-probe-0", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "BUSY_TURN", - "ep4-r2 F2: the absorbed compact provably STARTED in the sidecar — the quiesce probe's post-settle recheck aborts the teardown: {frames:?}" - ); - assert!( - in_turn_of(&st, "rb-probe").await, - "the revived candidate keeps the gate closed after the abort" - ); - assert!( - sink_impl.load_rollback("claude", "dur-probe").is_none(), - "the compensation deleted the pre-write row (nothing ever happened)" - ); - std::env::remove_var("FRESHELL_TEST_CLAUDE_PROBE_COMPACT_BEFORE_SETTLE"); - drop(env); - } - - /// Focused-review ep4-r1 F1 (misattribution) — the ep4-r1 repair retired - /// the running op at the interrupt receipt, but per the SDK contract the - /// receipt is written BEFORE the interrupted turn's terminal `sdk.result`. - /// With a turn queued behind, that trailing result then folds against the - /// NEXT op — the gate opens while it still runs. The receipt must retire - /// nothing; the result belongs to the interrupted op alone. - /// - /// A running, B queued → interrupt (fake emits settle → result → idle in - /// contract order) → the gate stays CLOSED for B (in_turn true, rollback - /// BUSY_TURN) → B's own terminal frames → gate OPENS. - #[tokio::test] - async fn an_interrupted_turns_trailing_result_never_retires_the_next_queued_op() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-misattr"), None) - .await; - let created = await_claude_created(&mut rx, "req-misattr").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - await_status_frame(&mut rx, &session_id, "idle").await; - - st.handle_send(send_msg(&session_id, "turn one")).await; // A running - st.handle_send(send_msg(&session_id, "turn two")).await; // B queued - - // Interrupt A: receipt + A's own result (in that order). - // NOTHING beyond A may retire. - st.handle_interrupt(interrupt_msg(&session_id)).await; - tokio::time::sleep(Duration::from_millis(300)).await; - assert!( - in_turn_of(&st, &session_id).await, - "ep4-r1 F1: A's trailing result belongs to A alone — B is still owed" - ); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-misattr-0", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "BUSY_TURN", - "the gate never opened over the still-queued B" - ); - - // B's own terminal frames retire it — the gate opens exactly there. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; - await_in_turn(&st, &session_id, false).await; - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op(&session_id, "rb-misattr-1", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the gate opened at B's own terminal frames" - ); - drop(env); - } - - /// Focused-review ep4-r3 F2 (probe correlation): an answer whose probeId - /// does NOT match the armed probe (a stale receipt from an earlier probe - /// or an unrelated interrupt's settle frame) must never close it. The - /// knobs force the fake to answer with a foreign probe id and shrink the - /// probe timeout so the wait resolves by timeout => BUSY_TURN — the SAFE - /// default, never a blind admit. - /// - /// RED harness: verifying the correlation code is what saves us requires - /// only flipping the consumer's probeId equality (the temporary - /// `armed_id == frame_probe_id` toggle) — the rollback then admits on the - /// stale answer (rolledBack), proving the exact attacker the finding named. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_stale_quiesced_frame_never_closes_a_live_rollback_probe() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - std::env::set_var("FRESHELL_TEST_CLAUDE_PROBE_WRONG_ID", "1"); - std::env::set_var("FRESHELL_TEST_CLAUDE_ROLLBACK_PROBE_TIMEOUT_MS", "200"); - let home = tempfile::tempdir().unwrap(); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - write_rollback_transcript(home.path(), "dur-stale", &two_turn_transcript()); - let (st, _rx) = state_with_bus(); - let sink_impl = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink_impl.clone()); - insert_rollback_fixture_session_with_live_sidecar(&st, "rb-stale", "dur-stale").await; - - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-stale", "rb-stale-0", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "BUSY_TURN", - "ep4-r3 F2: the stale (foreign probeId) answer never closes the probe — the wait times out into the safe refusal: {frames:?}" - ); - assert_eq!(env.spawn_count(), 0, "no teardown happened"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_PROBE_WRONG_ID"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_ROLLBACK_PROBE_TIMEOUT_MS"); - drop(env); - } - - /// Focused ep4-r3 (the probe's ADMIT path stays true): absorbed compact - /// debt the sidecar never started is cancelled AT the quiesce (its drain - /// count rides the answer), the verdict comes back all-clear, and the - /// rollback completes — absorb never became a BUSY-wedge (the point of - /// the ep3-r3 absorb lane). - #[cfg(target_os = "linux")] - #[tokio::test] - async fn the_quiesce_probe_admits_when_the_sidecar_drains_unstarted_compacts() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let home = tempfile::tempdir().unwrap(); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - write_rollback_transcript(home.path(), "dur-qadmit", &two_turn_transcript()); - let (st, _rx) = state_with_bus(); - let sink_impl = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink_impl.clone()); - let _ = - insert_rollback_fixture_session_with_live_sidecar(&st, "rb-qadmit", "dur-qadmit").await; - - // Compact-only owed queue (C1 rides under A; A's result retires it; C2). - st.handle_send(send_msg("rb-qadmit", "turn one")).await; - st.handle_compact(compact_msg("rb-qadmit", None)).await; - inject_raw_send(&st, "rb-qadmit", "__emit_result_error__").await; - st.handle_compact(compact_msg("rb-qadmit", None)).await; - tokio::time::sleep(Duration::from_millis(300)).await; - - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-qadmit", "rb-qadmit-0", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["type"], - "freshAgent.rolledBack", - "the quiesce drained the never-started compacts and answered all-clear — the rollback completed: {frames:?}" - ); - // The absorbed debt was dropped at the sidecar's OWN dispatch boundary; - // both probe round-trips passed. - assert!( - !st.sessions.lock().await.is_empty(), - "the respawned session exists post-rollback" - ); - drop(env); - } - - /// Focused-review ep4-r3 F1 (same-tick handoff): an absorbed compact - /// handed to an AWAITING SDK consumer in the tick before the probe is - /// un-cancellable AND emits no status before the quiesce answers — the - /// verdict is the ONLY evidence, so the busy branch of the verdict gate is - /// standalone load-bearing (the recheck's candidate fold covers the OTHER, - /// status-visible leg). - /// - /// folded-debt fixture on a live sidecar; the probe answers BUSY via - /// `handedCompactLikely` with zero status frames → BUSY_TURN, zero - /// teardown, gate re-closed by the verdict... note: the verdict itself - /// re-boosts nothing — the queue was absorbed empty — so the gate's - /// post-abort truth is open (the compact still owes its own evidence; - /// this assert anchors the abort, not a wedge). - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_compact_handed_in_the_probes_tick_aborts_on_the_verdict_alone() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - std::env::set_var("FRESHELL_TEST_CLAUDE_PROBE_HANDED_BUSY", "1"); - let home = tempfile::tempdir().unwrap(); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - write_rollback_transcript(home.path(), "dur-vhanded", &two_turn_transcript()); - let (st, _rx) = state_with_bus(); - let sink_impl = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink_impl.clone()); - insert_rollback_fixture_session_with_live_sidecar(&st, "rb-vhanded", "dur-vhanded").await; - - // A send+compact arm the debt realistically (A's result folds them to - // absorb shape: [C] owed, running empty). - st.handle_send(send_msg("rb-vhanded", "turn one")).await; - st.handle_compact(compact_msg("rb-vhanded", None)).await; - inject_raw_send(&st, "rb-vhanded", "__emit_result_error__").await; - tokio::time::sleep(Duration::from_millis(300)).await; - - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-vhanded", "rb-vhanded-0", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], - "BUSY_TURN", - "ep4-r3 F1: the verdict alone refuses — the handed compact is un-cancellable: {frames:?}" - ); - assert!( - sink_impl.load_rollback("claude", "dur-vhanded").is_none(), - "the compensation deleted the pre-write row (nothing ever happened)" - ); - std::env::remove_var("FRESHELL_TEST_CLAUDE_PROBE_HANDED_BUSY"); - drop(env); - } - - /// Focused-review ep2-r2 F3 (arm-revert vs queued sends): a queued - /// compact's no-write failure must undo EXACTLY its own bookkeeping — with - /// a send queued behind an EARLIER compact still outstanding, releasing - /// `in_turn` at the failed arm's revert opens the rollback gate while that - /// send generates. The counter revert cleared `in_turn` unconditionally on - /// its spent-prior CAS, and the gate never consulted the garlanded count: - /// C1 completed (its edge folded mid-window) while C2's write failed behind - /// it, leaving S1's send owed with the gate wide open. - /// - /// Rig: a REAL armed C1 behind fixture turn A; S1 accepted (garlanded); C1 - /// observably compacting; C2 armed and parked in its write window - /// (SIGSTOP'd fixture child, full pipe); C1's terminal edge folds - /// mid-window; C2's write then fails (SIGKILL) — the revert must keep the - /// gate closed for S1, and S1's own terminal edge releases it. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_failed_compact_write_keeps_the_gate_closed_while_a_garlanded_send_is_owed() { - let (st, _rx) = state_with_bus(); - insert_rollback_fixture_session_no_probe(&st, "rb-armfail-gar", "dur-armfail-gar").await; - // Turn A running at the STRUCTURAL level (the running slot + busy cache). - prime_fixture_running_turn(&st, "rb-armfail-gar").await; - - // Fetch the tracker arcs NOW: the compact driver holds the sessions - // lock ACROSS its parked write await — reading from the map mid-window - // would deadlock the rig. - let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-armfail-gar").await; - - // C1 arms behind A (write succeeds un-frozen) — then A's terminal edge - // retires the running turn, S1 accepts queued behind C1, and C1 - // observably starts compacting (promoted into `running`). - st.handle_compact(compact_msg("rb-armfail-gar", None)).await; - fold_terminal_edge(&in_turn, &turn_tracker); - st.handle_send(send_msg("rb-armfail-gar", "turn garlanded")) - .await; - mark_compact_candidate(&in_turn, &turn_tracker); - confirm_compact_candidate(&in_turn, &turn_tracker, true); - - // Park C2's write mid-window. - let pid = freeze_fixture_stdin(&st, "rb-armfail-gar").await; - let driver = st.handle_compact(compact_msg("rb-armfail-gar", None)); - tokio::pin!(driver); - assert!( - tokio::time::timeout(Duration::from_millis(300), &mut driver) - .await - .is_err(), - "C2's compact write is pending while the reader is stopped" - ); - - // C1's terminal edge folds mid-window: retires the promoted C1 — the - // gate stays closed with C2's armed entry + S1 still owed. - fold_terminal_edge(&in_turn, &turn_tracker); - assert!( - in_turn.load(std::sync::atomic::Ordering::SeqCst), - "C2 armed + S1 owed — the gate holds past C1's edge" - ); - - // C2's write FAILS: the undo pops EXACTLY C2's own entry — and the gate - // MUST stay closed for S1 (still queued behind the completed C1). - assert_eq!( - unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }, - 0, - "SIGKILL the fixture child — the parked write fails" - ); - tokio::time::timeout(Duration::from_secs(15), &mut driver) - .await - .expect("the failed write resolves the handler"); - assert!( - in_turn.load(std::sync::atomic::Ordering::SeqCst), - "ep2-r2 F3: the failed arm's undo never releases the gate while S1 is owed" - ); - { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - assert_eq!( - tracker.queued.iter().copied().collect::>(), - vec![TrackedOp::Turn], - "exactly S1's queued entry survives the undo" - ); - assert!( - tracker.running.is_none(), - "nothing is marked running after the undo" - ); - } - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-armfail-gar", "rb-ag-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!( - frames[0]["event"]["code"], "BUSY_TURN", - "ep2-r2 F3: rollback must refuse while the garlanded send is owed: {frames:?}" - ); - - // S1's own terminal edge: the gate releases exactly here. - fold_terminal_edge(&in_turn, &turn_tracker); - assert!(!in_turn.load(std::sync::atomic::Ordering::SeqCst)); - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-armfail-gar", "rb-ag-2", RollbackDirection::Undo), - sink, - ) - .await; - assert_eq!( - captured_json(&captured)[0]["event"]["code"], - "NOTHING_TO_UNDO", - "the gate released exactly at S1's terminal edge" - ); - assert!( - st.sessions.lock().await.contains_key("rb-armfail-gar"), - "the failed write never tore the session down" - ); - } - - /// ep1-r3 F3 rig: the session's busy-truth Arc + the FIFO turn tracker the - /// stdout consumer folds against (the whole tracked state the rig's direct - /// folds mutate). - #[cfg(target_os = "linux")] - async fn busy_tracker_arcs( - st: &FreshClaudeState, - map_key: &str, - ) -> ( - Arc, - Arc>, - ) { - let guard = st.sessions.lock().await; - let s = guard.get(map_key).expect("tracked session"); - (s.in_turn.clone(), s.turn_tracker.clone()) - } - - /// ep2-r2 rig: mark a fixture session mid-turn at the STRUCTURAL level - /// (the running slot holds the op + the derived busy cache set) — a bare - /// `in_turn.store(true)` would leave the tracker empty and steal the next - /// fold's attribution from the running op it never recorded. - async fn prime_fixture_running_turn(st: &FreshClaudeState, map_key: &str) { - let guard = st.sessions.lock().await; - let s = guard.get(map_key).expect("tracked session"); - arm_turn_op(&s.in_turn, &s.turn_tracker, TrackedOp::Turn); - } - - /// ep1-r3 F3 rig: SIGSTOP the fixture's reader and FILL its stdin pipe, so - /// the next `write_line` parks INSIDE the write await (a deterministic, - /// harness-pausable "mid-write" window) until the child resumes. Returns - /// the child's pid for the later SIGCONT/SIGKILL. - #[cfg(target_os = "linux")] - async fn freeze_fixture_stdin(st: &FreshClaudeState, map_key: &str) -> u32 { - let mut guard = st.sessions.lock().await; - let session = guard.get_mut(map_key).expect("tracked session"); - let pid = session.child.id().expect("live child"); - assert_eq!( - unsafe { libc::kill(pid as libc::pid_t, libc::SIGSTOP) }, - 0, - "SIGSTOP the fixture child" - ); - // A stopped reader never drains. Fill in chunks first, then exhaust - // any residual space one byte at a time: a blocked chunk alone does - // not prove that the smaller compact request cannot fit. ChildStdin - // has no userspace buffer, and a timed-out single-byte write proves - // backpressure without assuming the pipe's capacity or initial fill. - // `write` is cancellation-safe and reports partial writes; a cancelled - // `write_all` could instead hide bytes accepted before the timeout. - use tokio::io::AsyncWriteExt as _; - let junk = [b'x'; 4096]; - for chunk in [&junk[..], &junk[..1]] { - loop { - match tokio::time::timeout(Duration::from_millis(100), session.stdin.write(chunk)) - .await - { - Ok(Ok(n)) if n > 0 => {} - result => { - assert!( - result.is_err(), - "the {}-byte fill must stop on backpressure: {result:?}", - chunk.len() - ); - break; - } - } - } - } - drop(guard); - pid - } - - /// A one-page pipe must provide the same parked compact-write window as - /// a larger pipe. Exercise the real fixture and handler, then verify that - /// the request reaches the reader only after it resumes. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_small_fixture_pipe_blocks_compact_until_the_reader_resumes() { - use std::os::fd::AsRawFd as _; - - let (st, _rx) = state_with_bus(); - let stdin_log = - insert_rollback_fixture_session_no_probe(&st, "rb-small-pipe", "dur-small-pipe").await; - { - let guard = st.sessions.lock().await; - let session = guard.get("rb-small-pipe").expect("tracked session"); - // Only this test's empty, owned pipe changes. Linux rounds the - // request up to one page, including on hosts with larger pages. - let capacity = unsafe { libc::fcntl(session.stdin.as_raw_fd(), libc::F_SETPIPE_SZ, 1) }; - assert!( - capacity > 0, - "shrink the owned pipe: {}", - std::io::Error::last_os_error() - ); - assert_eq!(capacity as libc::c_long, unsafe { - libc::sysconf(libc::_SC_PAGESIZE) - }); - } - let pid = freeze_fixture_stdin(&st, "rb-small-pipe").await; - let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-small-pipe").await; - { - // Keep polling the SAME future after the timeout: cancellation - // must not restart the compact or submit a duplicate request. - let compact = st.handle_compact(compact_msg("rb-small-pipe", None)); - tokio::pin!(compact); - assert!( - tokio::time::timeout(Duration::from_millis(100), &mut compact) - .await - .is_err(), - "the real compact write stays pending while the reader is stopped" - ); - assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!( - turn_tracker.lock().expect("turn tracker lock").running, - Some(TrackedOp::Compact), - "the compact reached its write await after arming the tracker" - ); - assert!( - st.sessions.try_lock().is_err(), - "the pending write holds the session lock" - ); - assert_eq!(std::fs::read(&stdin_log).unwrap_or_default(), b""); - assert_eq!(unsafe { libc::kill(pid as libc::pid_t, libc::SIGCONT) }, 0); - tokio::time::timeout(Duration::from_secs(15), &mut compact) - .await - .expect("the compact write completes once the owned reader resumes"); - } - - // Close the writer and reap this fixture before reading its complete - // byte log. kill_on_drop also owns cleanup if an earlier assert fails. - let mut session = st.sessions.lock().await.remove("rb-small-pipe").unwrap(); - drop(session.stdin); - assert!( - tokio::time::timeout(Duration::from_secs(15), session.child.wait()) - .await - .unwrap() - .unwrap() - .success() - ); - let received = std::fs::read_to_string(stdin_log).unwrap(); - assert_eq!( - serde_json::from_str::(received.trim_start_matches('x')).unwrap(), - json!({ "type": "send", "sessionId": "rb-small-pipe", "text": "/compact" }), - "the reader receives exactly one complete compact request after the fill bytes" - ); - } - - /// ep1-r3 F3 CORE — the arm/await race: the stdout consumer folds terminal - /// events WITHOUT the turn lock, so the queued compact's tracker MUST be - /// armed BEFORE the sidecar write await — otherwise the prior turn's - /// `sdk.result` folds past the unarmed tracker (the busy truth dies with - /// the compact still owed, and a phantom "prior edge owed" is invented - /// once the post-await arm lands). Here the consumer's fold of the PRIOR - /// turn's terminal edge lands DURING the parked write window (a stopped, - /// pipe-full fixture child): after the fix the fold consumes the ARMED - /// owed edge and the busy truth survives coherently. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn the_prior_turns_terminal_edge_during_the_compact_write_window_folds_against_the_armed_tracker( - ) { - let (st, _rx) = state_with_bus(); - insert_rollback_fixture_session(&st, "rb-armrace", "dur-armrace").await; - prime_fixture_running_turn(&st, "rb-armrace").await; - let pid = freeze_fixture_stdin(&st, "rb-armrace").await; - - // Fetch the tracker arcs NOW: the compact handler holds the sessions - // lock ACROSS its parked write await, so reading them from the map - // mid-window would deadlock the rig. - let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-armrace").await; - - // The compact queues behind the running prior turn — and parks INSIDE - // the write await (the stopped child never drains a full pipe). - let driver = st.handle_compact(compact_msg("rb-armrace", None)); - tokio::pin!(driver); - assert!( - tokio::time::timeout(Duration::from_millis(300), &mut driver) - .await - .is_err(), - "the compact write is pending while the reader is stopped" - ); - fold_terminal_edge(&in_turn, &turn_tracker); - { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - assert!( - tracker.running.is_none(), - "the fold retired the prior turn's running entry mid-window" - ); - assert_eq!( - tracker.queued.iter().copied().collect::>(), - vec![TrackedOp::Compact], - "F3: the compact was armed BEFORE the write await — the fold saw its queued entry" - ); - } - assert!( - in_turn.load(std::sync::atomic::Ordering::SeqCst), - "F3: the busy truth survives the mid-window fold (the queued compact persists)" - ); - - // Resume: the parked write drains and the handler completes with the - // tracker still armed (the queued compact's busy truth is intact). - assert_eq!( - unsafe { libc::kill(pid as libc::pid_t, libc::SIGCONT) }, - 0, - "SIGCONT the fixture child" - ); - tokio::time::timeout(Duration::from_secs(15), &mut driver) - .await - .expect("the parked compact write completes once the child resumes"); - assert!(in_turn.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!( - turn_tracker.lock().expect("turn tracker lock").queued.len(), - 1, - "the accepted queued compact's busy truth survives the whole window" - ); - - // End-to-end (ep3-r3 F1): a rollback against the survived-but-quiescent - // compact debt ADMITS optimistically (nothing to undo → no teardown), - // the armed entry SURVIVES, and the gate opens; a proven start of the - // compact re-closes the gate again. - let (sink, captured) = capturing_sink(); - st.handle_rollback( - rollback_op("dur-armrace", "rb-armrace-1", RollbackDirection::Undo), - sink, - ) - .await; - let frames = captured_json(&captured); - assert_eq!(frames[0]["event"]["code"], "NOTHING_TO_UNDO", "{frames:?}"); - assert!( - st.sessions.lock().await.contains_key("rb-armrace"), - "zero teardown traffic" - ); - assert!( - !in_turn.load(std::sync::atomic::Ordering::SeqCst), - "the absorb opened the gate" - ); - assert_eq!( - turn_tracker.lock().expect("turn tracker lock").queued.len(), - 1, - "the armed compact's entry SURVIVES the absorb (its proven start re-arms the gate)" - ); - } - - /// ep1-r3 F3 failure path: the armed tracker state is REVERTED synchronously - /// when the write fails — and when the consumer's fold already consumed the - /// prior turn's terminal edge DURING the window, the REVERT also releases - /// the busy truth (nothing remains that could clear it: the compact never - /// went out and the prior edge is spent — the pane must never wedge busy - /// over a compact the blocked sidecar never received). The post-failure - /// tracker state carries NO phantom edges: a later ordinary turn's terminal - /// edge folds through the normal unarmed path and clears cleanly. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn a_failed_compact_write_reverts_the_armed_tracker_and_releases_the_spent_busy_truth() { - let (st, mut rx) = state_with_bus(); - insert_rollback_fixture_session_no_probe(&st, "rb-armfail", "dur-armfail").await; - prime_fixture_running_turn(&st, "rb-armfail").await; - let pid = freeze_fixture_stdin(&st, "rb-armfail").await; - - // Fetch the tracker arcs NOW: the compact handler holds the sessions - // lock ACROSS its parked write await, so reading them from the map - // mid-window would deadlock the rig. - let (in_turn, turn_tracker) = busy_tracker_arcs(&st, "rb-armfail").await; - - let driver = st.handle_compact(compact_msg("rb-armfail", None)); - tokio::pin!(driver); - assert!( - tokio::time::timeout(Duration::from_millis(300), &mut driver) - .await - .is_err(), - "the compact write is pending while the reader is stopped" - ); - // The prior turn's terminal edge folds mid-window: retires the running - // turn; the armed compact's queued entry survives (busy holds). - fold_terminal_edge(&in_turn, &turn_tracker); - - // The write now FAILS (the child is SIGKILLed — the parked write gets - // EPIPE): the armed entry must be undone synchronously. - assert_eq!( - unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }, - 0, - "SIGKILL the fixture child — the parked write fails" - ); - tokio::time::timeout(Duration::from_secs(15), &mut driver) - .await - .expect("the failed write resolves the handler"); - - // The frame is LOUD (the compact failure surfaces as INTERNAL_ERROR). - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; - assert_eq!(frame["event"]["code"], json!("INTERNAL_ERROR"), "{frame}"); - - // UNDO PROOF: busy released (the running prior turn's edge was spent - // mid-window and the compact never went out — nothing remains that - // could end a surviving busy truth), and the tracker is EMPTY (no - // phantom ops outstanding — the undo removed exactly the arm's entry). - use std::sync::atomic::Ordering::SeqCst; - assert!( - !in_turn.load(SeqCst), - "the undo released the spent busy truth — no wedge" - ); - { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - assert!( - tracker.running.is_none() && tracker.queued.is_empty(), - "no phantom ops outstanding — the undo removed exactly the arm's entry" - ); - } - // The phantom-op proof IN ACTION: a later ordinary turn's terminal - // edge folds through the NORMAL nothing-outstanding path and clears (a - // lingering entry would have swallowed it and wedged busy). - in_turn.store(true, SeqCst); // a new turn is running - fold_terminal_edge(&in_turn, &turn_tracker); - assert!( - !in_turn.load(SeqCst), - "a later turn's terminal edge clears cleanly — no phantom op swallowed it" - ); - assert!( - st.sessions.lock().await.contains_key("rb-armfail"), - "the failed write never tore the session down" - ); - } - #[tokio::test] async fn handle_rollback_redo_without_a_record_is_redo_unavailable() { let (st, _rx) = state_with_bus(); diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs index 4ea477505..c9ad2244a 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -362,8 +362,73 @@ fn field<'a>(fields: &'a capture::Fields, name: &str) -> Option<&'a str> { /// C08 + G31: a sidecar whose SDK stream ended reports it and exits 3. Its /// whole tree (the CLI stand-in in its group AND the one that left the group) /// is dead by the time the pane hears `exited`, with no waiting on the test's -/// side; the pane hears it once, with the reported cause, and nothing rings -/// (the sidecar rings a cut-off turn itself before it reports the end). +/// side; the pane hears it once, with the reported cause, and the cut-off turn +/// rings exactly once, from Rust (C18: the sidecar mints no edge of its own). +#[cfg(target_os = "linux")] +#[tokio::test] +async fn eof_drains_old_waiting_before_reaping_and_preserves_a_replacements_waiting() { + use std::sync::atomic::{AtomicBool, Ordering}; + #[derive(Default)] + struct Observer(AtomicBool); + impl crate::claude_edges::AttentionObserver for Observer { + fn on_waiting(&self, _: &str, _: &str, _: i64) { + self.0.store(true, Ordering::SeqCst); + } + fn on_pending_drained(&self, _: &str) { + self.0.store(false, Ordering::SeqCst); + } + } + let mut session = EndingSession::start("stream-ends", None).await; + let observer = Arc::new(Observer::default()); + session.st.clone().with_attention_observer(observer.clone()); + let pending = session.st.sessions.lock().await[&session.pane] + .pending + .clone(); + session + .st + .publish_pending_frame( + &session.pane, + &pending, + &json!({"type":"sdk.permission.request", "requestId":"old", "tool":{"name":"Bash"}}), + "freshclaude", + ) + .await; + assert!(observer.0.load(Ordering::SeqCst)); + let hold = session.st.hold_exit_reports(); + session + .st + .handle_send(send_msg(&session.pane, "end while waiting")) + .await; + tokio::time::timeout(Duration::from_secs(10), hold.parked()) + .await + .unwrap(); + let old_waiting_was_drained = !observer.0.load(Ordering::SeqCst); + + // A resumed runtime raises another request while the old process tree is + // still being reaped. Finishing that old death must not drain this interval. + tests::insert_fake_claude_session_with_pending(&session.st, &session.pane, None, &[]).await; + let replacement = session.st.sessions.lock().await[&session.pane] + .pending + .clone(); + session.st.publish_pending_frame(&session.pane, &replacement, + &json!({"type":"sdk.question.request", "requestId":"new", "questions":[{"question":"Continue?"}]}), + "freshclaude").await; + hold.release(); + session + .await_frame("the old lost turn's edge", is_turn_complete) + .await; + let new_waiting_survived = observer.0.load(Ordering::SeqCst); + session.st.shutdown().await; + assert!( + old_waiting_was_drained, + "EOF must drain old pending attention before teardown awaits" + ); + assert!( + new_waiting_survived, + "old teardown must not clear the resumed runtime's waiting interval" + ); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn a_stream_end_is_reported_once_its_whole_tree_is_confirmed_gone() { @@ -403,8 +468,8 @@ async fn a_stream_end_is_reported_once_its_whole_tree_is_confirmed_gone() { assert_eq!(session.count(is_sidecar_exited), 1, "{:?}", session.frames); assert_eq!( session.count(is_turn_complete), - 0, - "a reported stream end rings nothing from Rust: {:?}", + 1, + "the cut-off turn rings once: {:?}", session.frames ); assert!(session.st.dead_sessions.find(&session.pane).is_some()); @@ -415,7 +480,7 @@ async fn a_stream_end_is_reported_once_its_whole_tree_is_confirmed_gone() { assert_eq!(field(&logged[0], "stop"), Some("abnormal_exit")); assert_eq!(field(&logged[0], "confirmation"), Some("confirmed")); assert_eq!(field(&logged[0], "exit_status"), Some("code 3")); - assert_eq!(field(&logged[0], "rings"), Some("false")); + assert_eq!(field(&logged[0], "rings"), Some("true")); } /// C09 + G31: node killed mid-turn reports no stream end. Its exit status is @@ -430,14 +495,14 @@ async fn a_node_killed_mid_turn_reports_its_signal_after_the_tree_is_gone_and_ri .st .handle_send(send_msg(&session.pane, "a turn the sidecar dies in")) .await; - let in_turn = session + let busy = session .st .sessions .lock() .await .get(&session.pane) - .map(|live| live.in_turn.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!(in_turn, Some(true), "the turn is in flight"); + .map(|live| live.ledger.lock().expect("turn ledger lock").busy()); + assert_eq!(busy, Some(true), "the turn is in flight"); // SAFETY: the sidecar is this test's own child. unsafe { diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index bbc5ccc4a..e21992a37 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -64,12 +64,11 @@ pub(super) enum RegisterUnder { /// The per-session state a replacement runtime takes over from the runtime it /// replaces (an undo or redo, design §4.6): the turn lock a parked send waits -/// on, the busy truth and the attention clock. Only the process changes. +/// on, the turn ledger and the attention clock. Only the process changes. pub(super) struct InheritedRuntime { pub(super) turn_lock: Arc>, - pub(super) in_turn: Arc, - pub(super) turn_tracker: Arc>, - pub(super) last_synthesized_complete_at: Arc>>, + pub(super) ledger: crate::claude_turns::SharedLedger, + pub(super) clock: crate::attention::AttentionClock, } /// What a launch's claim of the provider lease on a durable id found (design @@ -700,21 +699,31 @@ impl FreshClaudeState { let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.clone())); log_labels.bind_session(&broadcast_id); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); + // A resume after an unrequested death continues the dead session's + // attention clock (design §5.5): its edges stay above the death's. + let resumed_clock = || { + [Some(map_key.as_str()), durable.as_deref()] + .into_iter() + .flatten() + .find_map(|id| self.dead_sessions.find(id)) + .map(|dead| dead.clock) + }; let InheritedRuntime { turn_lock, - in_turn, - turn_tracker, - // Task 5: the session's death-mint clock (see the field doc). - last_synthesized_complete_at, + ledger, + // The session's attention clock (see the field doc). + clock, } = inherit.unwrap_or_else(|| InheritedRuntime { turn_lock: Arc::new(TokioMutex::new(())), - in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), - turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), + ledger: crate::claude_turns::SharedLedger::default(), + clock: resumed_clock().unwrap_or_default(), }); - // A just-booted session has announced nothing: no turn can be in flight - // before the client sends. - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); + let (stdin, fault) = crate::claude_sidecar_io::SidecarWriter::spawn( + stdin, + log_labels, + crate::claude_sidecar_io::write_timeout(self.sidecar_environment.as_deref()), + ); + self.watch_writer_failure(&map_key, &ownership_id, fault); { let mut sessions = self.sessions.lock().await; let consumer = self.spawn_consumer( @@ -725,10 +734,8 @@ impl FreshClaudeState { recorded_settings, Arc::clone(&broadcast_id), Arc::clone(&pending), - Arc::clone(&last_status), - Arc::clone(&in_turn), - Arc::clone(&turn_tracker), - Arc::clone(&last_synthesized_complete_at), + Arc::clone(&ledger), + clock.clone(), provenance, ); self.test_holds.registration().await; @@ -756,18 +763,16 @@ impl FreshClaudeState { owning_generation, broadcast_id, pending, - in_turn, - turn_tracker, - last_synthesized_complete_at, + ledger, + clock, turn_lock, - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, launch_genesis: genesis.map(|genesis| crate::identity_sink::RecordedGenesis { genesis, updated_at_ms: crate::rollback_record::now_ms(), }), }, ); + self.edges.registered(&map_key, &spec.session_type); // C09: a resumed session is live again, so it is no longer an ended // one. Forgotten before the lock is released, so a death of this new // incarnation is recorded after it, never forgotten by it. diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 383e519d8..c434f6966 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -94,7 +94,8 @@ struct ForkParent { reply_id: String, /// The parent's durable id: whose effective transcript the fork reads. durable: String, - in_turn: Arc, + /// The parent's turn ledger (the busy gate reads it). + ledger: crate::claude_turns::SharedLedger, /// The settings the parent runs with (model, effort, permission mode, /// plugins, cwd): the child starts with the same. settings: crate::identity_sink::FreshAgentSettings, @@ -185,7 +186,7 @@ impl FreshClaudeState { reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); return; }; - if parent.in_turn.load(std::sync::atomic::Ordering::SeqCst) { + if parent.ledger.lock().expect("turn ledger lock").busy() { tracing::info!(target: "freshell_freshagent::claude", event = "freshagent.claude.fork_refused", session_id = %parent.durable, code = "BUSY_TURN", @@ -535,7 +536,7 @@ impl FreshClaudeState { .cli_session_id .clone() .unwrap_or_else(|| live_key.clone()), - in_turn: Arc::clone(&session.in_turn), + ledger: Arc::clone(&session.ledger), settings: session.configuration.settings.clone(), freshell_context: session.configuration.freshell_context.clone(), }) diff --git a/crates/freshell-freshagent/src/claude/pending_runtime.rs b/crates/freshell-freshagent/src/claude/pending_runtime.rs new file mode 100644 index 000000000..b9e838ddb --- /dev/null +++ b/crates/freshell-freshagent/src/claude/pending_runtime.rs @@ -0,0 +1,93 @@ +//! Pending cards and their attention transitions share one ordering boundary. +//! No sidecar I/O runs here. The sessions-map guard fences the incarnation and +//! the pending guard covers mutation through synchronous frame/edge publication. + +use super::*; + +pub(super) enum PendingResponse { + Approval, + Question, +} + +pub(super) fn is_pending_frame(value: &Value) -> bool { + matches!( + value["type"].as_str(), + Some( + "sdk.permission.request" + | "sdk.permission.cancelled" + | "sdk.question.request" + | "sdk.question.cancelled" + ) + ) +} + +impl FreshClaudeState { + pub(super) async fn complete_pending_response( + &self, + response: &ResponseSession, + request_id: &str, + kind: PendingResponse, + ) { + let sessions = self.sessions.lock().await; + let Some(session) = sessions.get(&response.map_key).filter(|session| { + session.ownership_id == response.ownership_id + && Arc::ptr_eq(&session.pending, &response.pending) + }) else { + return; + }; + let mut pending = session.pending.lock().expect("pending lock"); + let before = pending.len(); + match kind { + PendingResponse::Approval => pending.permissions.retain(|p| p.request_id != request_id), + PendingResponse::Question => pending.questions.retain(|q| q.request_id != request_id), + } + if PendingChange::between(before, pending.len()) == Some(PendingChange::Drained) { + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + self.edges.pending_drained(&stamp); + } + } + + pub(super) async fn publish_pending_frame( + &self, + map_key: &str, + expected_pending: &Arc>, + value: &Value, + session_type: &str, + ) { + let sessions = self.sessions.lock().await; + let Some(session) = sessions + .get(map_key) + .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) + else { + return; + }; + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + self.publish_claude_status(&session.ledger, &stamp, session_type, false); + let mut pending = session.pending.lock().expect("pending lock"); + let change = pending.fold_frame(value); + if let Some(frame) = sdk_line_to_frame(value, &stamp, session_type) { + let _ = self.broadcast_tx.send(frame); + } + match change { + Some(PendingChange::Waiting) => { + self.edges.waiting(&crate::claude_edges::EdgeRecord { + session_key: map_key, + stamp: &stamp, + sidecar_session_id: &session.sidecar_session_id, + session_type, + clock: &session.clock, + }); + } + Some(PendingChange::Drained) => self.edges.pending_drained(&stamp), + None => {} + } + } +} diff --git a/crates/freshell-freshagent/src/claude/pending_tests.rs b/crates/freshell-freshagent/src/claude/pending_tests.rs new file mode 100644 index 000000000..2a509a7d8 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/pending_tests.rs @@ -0,0 +1,177 @@ +//! Pending-set transitions must reflect each mutation atomically. A response can +//! remove the last approval while the stdout consumer receives the next one. + +use super::*; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[test] +fn responding_while_new_requests_arrive_preserves_every_waiting_transition() { + let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); + let done = Arc::new(AtomicBool::new(false)); + let start = Arc::new(std::sync::Barrier::new(2)); + let producer = { + let pending = pending.clone(); + let done = done.clone(); + let start = start.clone(); + std::thread::spawn(move || { + start.wait(); + let mut waiting = 0usize; + for _ in 0..20_000 { + let change = fold_pending_frame( + &pending, + &json!({ + "type": "sdk.permission.request", "requestId": "approval", + "tool": {"name": "Bash", "input": {"command": "ls"}}, + }), + ); + waiting += usize::from(change == Some(PendingChange::Waiting)); + } + done.store(true, Ordering::Release); + waiting + }) + }; + start.wait(); + let mut drained = 0usize; + while !done.load(Ordering::Acquire) { + let mut current = pending.lock().unwrap(); + if current.len() != 0 { + // The response handler's successful write removes its pending card. + current.permissions.clear(); + drained += 1; + } + drop(current); + std::thread::yield_now(); + } + let waiting = producer.join().unwrap(); + let remaining = usize::from(pending.lock().unwrap().len() != 0); + assert_eq!( + waiting, + drained + remaining, + "every new nonempty pending interval needs exactly one waiting edge" + ); + assert!(drained > 0, "the responder must have run concurrently"); +} + +#[test] +fn an_answered_approval_followed_by_a_question_starts_a_new_waiting_interval() { + let pending = std::sync::Mutex::new(ClaudePending::default()); + assert_eq!( + fold_pending_frame( + &pending, + &json!({ + "type": "sdk.permission.request", "requestId": "a", "tool": {"name":"Bash"}, + }) + ), + Some(PendingChange::Waiting) + ); + // A duplicate request does not create a new edge. + assert_eq!( + fold_pending_frame( + &pending, + &json!({ + "type": "sdk.permission.request", "requestId": "a", "tool": {"name":"Bash"}, + }) + ), + None + ); + pending.lock().unwrap().permissions.clear(); + assert_eq!( + fold_pending_frame( + &pending, + &json!({ + "type": "sdk.question.request", "requestId": "b", "questions": [{"question":"Continue?"}], + }) + ), + Some(PendingChange::Waiting) + ); + assert_eq!( + fold_pending_frame( + &pending, + &json!({ + "type": "sdk.question.cancelled", "requestId": "b", + }) + ), + Some(PendingChange::Drained) + ); +} + +/// Pause at the post-flush boundary, replace the runtime, then finish the old +/// response. Its delayed completion must not drain the replacement's attention. +#[cfg(unix)] +#[tokio::test] +async fn a_response_from_a_replaced_runtime_cannot_clear_the_new_waiting_interval() { + #[derive(Default)] + struct Observer(std::sync::Mutex>); + impl crate::claude_edges::AttentionObserver for Observer { + fn on_waiting(&self, _: &str, _: &str, _: i64) { + self.0.lock().unwrap().push("waiting"); + } + fn on_pending_drained(&self, _: &str) { + self.0.lock().unwrap().push("drained"); + } + } + let observer = Arc::new(Observer::default()); + let (tx, mut rx) = tokio::sync::broadcast::channel(64); + let state = FreshClaudeState::new(Arc::new(tx)).with_attention_observer(observer.clone()); + let old_request = + json!({"type":"sdk.permission.request", "requestId":"reused", "tool":{"name":"Bash"}}); + tests::insert_fake_claude_session_with_pending(&state, "same-id", None, &[old_request]).await; + let old = state + .response_session("same-id", SessionType::Freshclaude) + .await + .unwrap(); + + tests::insert_fake_claude_session_with_pending(&state, "same-id", None, &[]).await; + let new = state + .response_session("same-id", SessionType::Freshclaude) + .await + .unwrap(); + let new_request = + json!({"type":"sdk.permission.request", "requestId":"reused", "tool":{"name":"Read"}}); + state + .publish_pending_frame("same-id", &new.pending, &new_request, "freshclaude") + .await; + assert_eq!(*observer.0.lock().unwrap(), ["waiting"]); + + state + .complete_pending_response(&old, "reused", PendingResponse::Approval) + .await; + state + .publish_pending_frame( + "same-id", + &old.pending, + &json!({"type":"sdk.permission.cancelled", "requestId":"reused"}), + "freshclaude", + ) + .await; + assert_eq!( + *observer.0.lock().unwrap(), + ["waiting"], + "obsolete work cannot clear current attention" + ); + assert_eq!( + new.pending.lock().unwrap().permissions[0].request_id, + "reused" + ); + assert_eq!( + old.pending.lock().unwrap().permissions.len(), + 1, + "the obsolete pending set is not mutated after replacement" + ); + let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + assert_eq!( + frames + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.permission.cancelled") + .count(), + 0 + ); + + state + .complete_pending_response(&new, "reused", PendingResponse::Approval) + .await; + assert_eq!(*observer.0.lock().unwrap(), ["waiting", "drained"]); + state.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 196a5b5ac..3a1982f14 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -165,10 +165,10 @@ impl FreshClaudeState { /// Ordering (durable-BEFORE-mutation, spawn before teardown): toTurn /// validation → `rollback_in_flight` single-flight (FIRST) → turn lock /// (held across the whole handler; a send parked on it re-resolves to the - /// replacement afterwards) → busy gate (`BUSY_TURN`; the tracker plus the - /// quiesce probe until C17) → coordinator claim and lease on the original - /// → transcript reads and resume math → AWAITED record pre-write → quiesce - /// probe → pending-card cancellations → `establish_runtime(C)` while the + /// replacement afterwards) → busy gate (`BUSY_TURN`: the turn ledger, C17) + /// → coordinator claim and lease on the original → transcript reads and + /// resume math → AWAITED record pre-write → pending-card cancellations → + /// busy recheck → `establish_runtime(C)` while the /// original is still live: the sidecar answers `created` only once the CLI /// booted the fork (T6: it writes nothing until its first prompt), so no /// init is ever awaited (G01) → ONE awaited ledger batch (binding C with @@ -205,29 +205,19 @@ impl FreshClaudeState { )); return; } - let ( - durable_id, - in_turn, - turn_tracker, - turn_lock, - session_type, - last_synthesized_complete_at, - live_settings, - freshell_context, - ) = { + let (durable_id, ledger, turn_lock, session_type, clock, live_settings, freshell_context) = { let guard = self.sessions.lock().await; match guard.get(&map_key) { Some(s) => ( s.cli_session_id.clone().unwrap_or_else(|| map_key.clone()), - s.in_turn.clone(), - s.turn_tracker.clone(), + s.ledger.clone(), s.turn_lock.clone(), session_type_str(op.session_type), // Task 5: the fork continues the SAME per-session - // death-mint clock (carried like `in_turn` — the fork is + // death-mint clock (carried like the ledger — the fork is // the same logical session lifetime, so a death-mint // after the fork can never regress past one before it). - s.last_synthesized_complete_at.clone(), + s.clock.clone(), // The settings the session runs with (G15): the // replacement launches with them, on every lane. s.configuration.settings.clone(), @@ -313,30 +303,19 @@ impl FreshClaudeState { None } }; - // Held for the REST of this handler. in_turn is set by handle_send UNDER - // this same lock BEFORE the sidecar write (the check-then-set window is - // closed): observed false here means no op is in flight. Focused ep1-r1 - // F1 through ep2-r2: `in_turn` is the FIFO tracker's DERIVED busy truth - // — recomputed from `TurnTracker::busy()` inside every mutation's - // critical section — so the gate stays closed through every op that is - // queued or mid-compaction, whether its turn ended technically (the - // interrupted active turn included, ep2-r2 F2: the queue survives it) - // or only structurally (every queued op owes its own terminal edge). + // Held for the REST of this handler. Every send and compact arms the + // turn ledger UNDER this same lock BEFORE its sidecar write, so a + // ledger that is not busy here owes nothing: no command the server + // sent is unended, and the CLI runs no turn and no compaction of its + // own (C17; an absorbed send no longer reads busy, G08). let _turn = turn_lock.lock().await; - if in_turn.load(std::sync::atomic::Ordering::SeqCst) { - // ep3-r3 F1: owed debt broken down — queue entries that are ONLY - // unpromoted compacts (nothing running, no candidate pending, no - // Turn owed) are provably quiescent and absorb (see - // [`absorb_unstarted_compact_debt`]). Everything else is genuinely - // mid-execution risk and refuses. - if !absorb_unstarted_compact_debt(&in_turn, &turn_tracker) { - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } + if ledger.lock().expect("turn ledger lock").busy() { + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; } // Lease discipline: claim the OLD durable id exactly like the @@ -856,168 +835,6 @@ impl FreshClaudeState { } } - // ep3-r5 F1 (the admit→boot window): the gate's quiescence-proof is - // consumed ONCE at admission — a compact can arm and start (status - // folded, candidate set, gate re-boosted by ep3-r4 F1) while this - // already-admitted handler is doing transcript I/O. The quiesce probe - // above makes all provably-written evidence folded-visible; the - // recheck here (immediately before the replacement boots) aborts the rollback with BUSY_TURN plus a compensating - // ledger rewrite whenever that truth revived (the durable pre-write - // provably matches the provider: nothing was mutated). - if let Ok(ms) = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS") { - // Test-only knob: parks this handler in the admit→boot window so - // the recheck choreography is deterministic (never in production). - if let Ok(ms) = ms.parse::() { - tokio::time::sleep(Duration::from_millis(ms)).await; - } - } - - // Focused ep4-r2/ep4-r3 (the post-admission evidence race, repaired - // for real): absorbed compact debt can STILL start in the sidecar - // after admission — the dispatch lifecycle is: sidecar input queue → - // SAME-TICK handoff when the SDK consumer is awaiting → SDK turn - // start (its `compacting` status folds the candidate here). The - // quiesce probe closes every leg of that lifecycle atomically: the - // sidecar (1) DROPS never-handed compacts from its own input queue - // (cancellation the SDK surface cannot provide for queue items), (2) - // reports in-flight/handed truth, and (3) answers on its stdout - // stream AFTER every already-emitted piece of evidence has been folded - // (stream order), correlated by probeId so a stale receipt can never - // close the probe. The verdict gates below plus the recheck cover the - // whole shape: all-clear → proceed; any busy signal or no answer → - // BUSY_TURN + compensating ledger rewrite. Skip when no live evidence - // path exists: write failure (sidecar already dead), or a FINISHED - // consumer (its EOF reaped the busy truth; nothing fresh can arrive). - let mut probe_armed = false; - let mut probe_rx = None; - let mut probe_id = String::new(); - { - let mut guard = self.sessions.lock().await; - if let Some(session) = guard.get_mut(&map_key) { - if session.consumer.is_finished() { - // EOF already zeroed the tracker — the recheck sees it. - } else { - let (tx, rx) = tokio::sync::oneshot::channel::(); - probe_id = uuid::Uuid::new_v4().to_string(); - *session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock") = Some((probe_id.clone(), tx)); - // ep4-r3: the quiesce is its own request type — the sidecar - // drains never-handed queued compacts and answers with its - // own queue truth (the SDK's queued inputs cannot be - // cancelled once handed). - let probe_req = json!({ - "type": "rollback.quiesce", - "sessionId": session.sidecar_session_id, - "probeId": probe_id, - }); - match write_line(&mut session.stdin, &probe_req).await { - Ok(()) => { - probe_armed = true; - probe_rx = Some(rx); - } - Err(_) => { - let _ = session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock") - .take(); - } - } - } - } - } - if probe_armed { - let timeout_ms = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PROBE_TIMEOUT_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(3000); - let verdict = - tokio::time::timeout(Duration::from_millis(timeout_ms), probe_rx.unwrap()).await; - match verdict { - Ok(Ok(QuiesceVerdict { - cancelled_queue, - in_flight_turn: false, - handed_compact_likely: false, - })) => { - if cancelled_queue > 0 { - tracing::info!( - session = %map_key, - cancelled = cancelled_queue, - "freshagent.claude.rollback_quiesce_cancelled_queue" - ); - } - } - Ok(Ok(verdict)) => { - // ep4-r3 F1: provider work crossed the un-cancellable - // handoff (a turn mid-flight, or a compact already handed - // to an awaiting SDK consumer) — refuse. - tracing::info!(session = %map_key, ?verdict, "freshagent.claude.rollback_quiesce_busy"); - // ep4-r6 F5: never fabricate an empty row for a rollback - // that never happened — the shared helper DELETES the - // pre-write when the pre-op record was absent. - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - _ => { - // Timeout (or a dropped sender — impossible in practice - // since the slot outlives this handler): the absorb premise - // is unproven — refuse rather than tear a live provider - // flow down blind. Clear OUR slot (a stale receipt must - // never fire a later probe — ep4-r3 F2 defense in depth - // on top of probeId correlation). - if let Some(session) = self.sessions.lock().await.get(&map_key) { - let mut slot = session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock"); - if slot.as_ref().is_some_and(|(id, _)| *id == probe_id) { - slot.take(); - } - } - // ep4-r6 F5: never fabricate an empty row for a rollback - // that never happened — the shared helper DELETES the - // pre-write when the pre-op record was absent. - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - } - } - - let revived = { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - tracker.running.is_some() - || tracker.compact_candidate - || tracker.queued.iter().any(|op| *op == TrackedOp::Turn) - }; - if revived { - // Compensate the pre-op durable record: the provider was never - // touched, so the ledger must describe nothing happening — - // restore the pre-op record, and DELETE the just-written row when - // there was none (never fabricate, ep4-r6 F5). - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - // Cancel pending cards (decision 6) before the replacement boots. self.emit_pending_cancellations(&map_key, &op.session_id, session_type) .await; @@ -1065,6 +882,26 @@ impl FreshClaudeState { "the rollback boots its replacement runtime while the original stays live" ); self.test_holds.rollback_boot().await; + // The recheck, the last step before the replacement boots: no send or + // compact can arm (this handler holds the turn lock), but the CLI can + // start a turn of its own since the gate admitted the rollback (a + // background task's completion, T19). Then the rollback is refused and + // its record compensated: the provider was never touched. + if ledger.lock().expect("turn ledger lock").busy() { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_refused", + session_id = %durable_id, code = "BUSY_TURN", recheck = true, + "the CLI started a turn of its own while the rollback read the transcript"); + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; + } + let spec = ClaudeLaunchSpec::new( format!("rollback-{}", uuid::Uuid::new_v4()), session_type, @@ -1101,9 +938,8 @@ impl FreshClaudeState { start_pid_slot: &start_pid_slot, inherit: Some(InheritedRuntime { turn_lock: turn_lock.clone(), - in_turn: in_turn.clone(), - turn_tracker: turn_tracker.clone(), - last_synthesized_complete_at: Arc::clone(&last_synthesized_complete_at), + ledger: ledger.clone(), + clock: clock.clone(), }), genesis: Some(genesis.clone()), retry: None, diff --git a/crates/freshell-freshagent/src/claude/send_outcome.rs b/crates/freshell-freshagent/src/claude/send_outcome.rs new file mode 100644 index 000000000..fa52333f8 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/send_outcome.rs @@ -0,0 +1,102 @@ +//! What a claude send answered (core-runtime design §9.3, I2), and how a send the +//! sidecar refused is taken back (C16). +//! +//! [`FreshClaudeState::handle_send`] answers every send on the WS bus +//! (`freshAgent.send.accepted`, or a request `error`) and also returns the answer +//! as a [`ClaudeSendOutcome`], so an in-process caller (the REST/MCP agent API) can +//! act on it without reading the bus. A refusal's request error is sent by +//! [`FreshClaudeState::refuse_send`], which builds the outcome from the same code +//! and message, so the two never disagree; the WS dispatcher ignores the value. + +use super::{FreshClaudeState, PROVIDER}; +use crate::claude_turns::{SendRejection, SharedLedger}; + +/// The code a sender sees for a send the sidecar refused (`sdk.send.rejected`). +const SEND_REJECTED: &str = "CLAUDE_SEND_REJECTED"; + +/// The answer to one claude `freshAgent.send`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaudeSendOutcome { + /// The sidecar has the message (`freshAgent.send.accepted` was broadcast). + Accepted { + /// The id of the live session the message went to (the sessions-map key; a + /// send that waited behind an undo goes to the replacement). + session_key: String, + /// The uuid the message runs under: the turn id its user line lands with. + submitted_turn_id: String, + }, + /// The message was not sent; the sender got a request error with this code. + Refused { code: String, message: String }, +} + +impl FreshClaudeState { + /// Refuse a send: the request error the sender sees, and the same refusal as + /// the outcome. + pub(super) fn refuse_send( + &self, + request_id: &Option, + code: &str, + message: &str, + ) -> ClaudeSendOutcome { + self.send_error(request_id, code, message); + ClaudeSendOutcome::Refused { + code: code.to_string(), + message: message.to_string(), + } + } + + /// Whether the live session `id` (its pane id or its durable id) is working on + /// a turn; `None` when no live session answers to it (an unknown id, or a + /// session that ended). + pub async fn session_busy(&self, id: &str) -> Option { + let key = self.resolve_session_key(id).await?; + let sessions = self.sessions.lock().await; + let session = sessions.get(&key)?; + let busy = session.ledger.lock().expect("turn ledger lock").busy(); + Some(busy) + } + + /// The sidecar refused a send before the CLI saw it (`sdk.send.rejected`, for + /// example an image Claude cannot read); the session lives on. Take the send + /// back: the ledger lets go of exactly that command (by its uuid), so the + /// session is not left busy waiting for a turn that never runs, and tell the + /// sender. A send with a request gets the + /// request error the client's owned-send path turns into "Message not sent: + /// " with the text back in the composer; a compact (no request) gets a + /// nested `freshAgent.error` on the session. A uuid this session does not + /// remember is logged and changes nothing. + pub(super) fn take_back_rejected_send( + &self, + ledger: &SharedLedger, + session_id: &str, + session_type: &str, + rejection: &SendRejection, + ) { + let SendRejection { + uuid, + code, + message, + } = rejection; + let uuid = uuid.as_deref(); + let send = uuid.and_then(|uuid| ledger.lock().expect("turn ledger lock").undo_arm(uuid)); + let Some(send) = send else { + tracing::warn!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id, session_type, uuid, code = %code, + event = "freshagent.claude.send_rejected", + known = false, + "the sidecar rejected a send this session does not remember"); + return; + }; + tracing::warn!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id, session_type, uuid = %send.uuid, + request_id = send.request_id.as_deref(), kind = send.kind.as_str(), code = %code, + busy = ledger.lock().expect("turn ledger lock").busy(), + event = "freshagent.claude.send_rejected", + known = true, + "the sidecar rejected a send; it is taken back"); + match send.request_id { + Some(request_id) => self.send_error(&Some(request_id), SEND_REJECTED, message), + None => self.emit_fresh_agent_error(session_id, session_type, SEND_REJECTED, message), + } + } +} diff --git a/crates/freshell-freshagent/src/claude/sidecar_writer.rs b/crates/freshell-freshagent/src/claude/sidecar_writer.rs new file mode 100644 index 000000000..215c277ec --- /dev/null +++ b/crates/freshell-freshagent/src/claude/sidecar_writer.rs @@ -0,0 +1,32 @@ +//! A failed stdin write ends only the runtime that owned that writer. Keep its +//! session registered until stdout EOF performs normal death/attention handling. + +use super::FreshClaudeState; + +impl FreshClaudeState { + pub(super) fn watch_writer_failure( + &self, + map_key: &str, + ownership_id: &str, + fault: tokio::sync::oneshot::Receiver, + ) { + let state = self.clone(); + let map_key = map_key.to_string(); + let ownership_id = ownership_id.to_string(); + tokio::spawn(async move { + let Ok(cause) = fault.await else { return }; + let mut sessions = state.sessions.lock().await; + let Some(session) = sessions + .get_mut(&map_key) + .filter(|session| session.ownership_id == ownership_id) + else { + return; + }; + let result = session.child.start_kill(); + tracing::warn!(provider = "claude", session_id = %map_key, + ownership_id = %ownership_id, cause = %cause, + kill_error = result.err().map(|error| error.to_string()), + "freshagent.claude.sidecar_writer_ended_runtime"); + }); + } +} diff --git a/crates/freshell-freshagent/src/claude/stop.rs b/crates/freshell-freshagent/src/claude/stop.rs new file mode 100644 index 000000000..51160050c --- /dev/null +++ b/crates/freshell-freshagent/src/claude/stop.rs @@ -0,0 +1,118 @@ +//! The user's Stop (core-runtime design §5.4, C18; G03, G06): the latch lives in +//! the session's turn ledger (`claude_turns.rs`), and this side writes the +//! interrupts it asks for. +//! +//! Every interrupt carries a `stopId` the sidecar echoes on its receipt +//! (`sdk.interrupt_settled`), so the latch reads only its own receipt. Each Stop +//! decision is logged as `freshagent.claude.stop` at info: `sent` (with its +//! target), `idle` (nothing owed, nothing sent), `reissued` (with the re-send +//! case and count), `unsent` (the write failed) and `refused` (the CLI refused). + +use serde_json::json; + +use super::{FreshClaudeState, PROVIDER}; +use crate::claude_turns::StopReissue; + +const TARGET: &str = "freshell_freshagent::claude"; + +impl FreshClaudeState { + /// Stop the live session `map_key` (the body of [`Self::handle_interrupt`]). + /// Returns `false` when no live session is registered under it. + pub(super) async fn stop_live_session(&self, map_key: &str, session_type: &str) -> bool { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(map_key) else { + return false; + }; + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + let stop_id = uuid::Uuid::new_v4().to_string(); + // Latched under the ledger lock before the write: the receipt and the + // stopped turn's result can come back before this task runs again. + let armed = session + .ledger + .lock() + .expect("turn ledger lock") + .stop(stop_id.clone()); + let Some(armed) = armed else { + tracing::info!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, outcome = "idle", + "freshagent.claude.stop"); + return true; + }; + tracing::info!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, stop_id = %stop_id, outcome = "sent", + target = armed.target.as_str(), commands = ?armed.commands, + "freshagent.claude.stop"); + // Address the sidecar by ITS id for this session (== the map key for + // created sessions; differs for resumed-on-attach sessions, Task 6). + let request = json!({ + "type": "interrupt", + "sessionId": session.sidecar_session_id, + "stopId": stop_id, + }); + let writer = session.stdin.clone(); + let ledger = session.ledger.clone(); + drop(sessions); + if let Err(error) = writer.write(&request).await { + ledger + .lock() + .expect("turn ledger lock") + .stop_unsent(&stop_id); + tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, stop_id = %stop_id, outcome = "unsent", cause = %error, + "freshagent.claude.stop"); + self.send_error(&None, "CLAUDE_INTERRUPT_FAILED", &error); + } + true + } + + /// Send a latched Stop again (design §5.4): it missed a command the CLI was + /// still starting, which now runs. Written only to the incarnation that + /// asked (`sidecar_session_id`): a session re-registered under the same key + /// since is not this Stop's. + pub(super) async fn reissue_stop( + &self, + map_key: &str, + sidecar_session_id: &str, + stamp: &str, + session_type: &str, + reissue: &StopReissue, + ) { + tracing::info!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, stop_id = %reissue.stop_id, outcome = "reissued", + case = reissue.case.as_str(), target = %reissue.target, + reissues = reissue.reissues, + "freshagent.claude.stop"); + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions + .get_mut(map_key) + .filter(|session| session.sidecar_session_id == sidecar_session_id) + else { + return; + }; + let request = json!({ + "type": "interrupt", + "sessionId": sidecar_session_id, + "stopId": reissue.reissue_id, + }); + let writer = session.stdin.clone(); + drop(sessions); + if let Err(error) = writer.write(&request).await { + tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, stop_id = %reissue.stop_id, outcome = "unsent", + cause = %error, + "freshagent.claude.stop"); + } + } + + /// The CLI refused the latched Stop's interrupt: the latch is disarmed and + /// the turn keeps running (it rings when it ends). + pub(super) fn log_stop_refused(&self, stamp: &str, session_type: &str, stop_id: &str) { + tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, + session_type, stop_id = %stop_id, outcome = "refused", + "freshagent.claude.stop"); + } +} diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 1bc904958..6f45a8913 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -100,7 +100,7 @@ async fn fake_session( }); let session = ClaudeSession { configuration: ClaudeConfiguration::default(), - stdin, + stdin: stdin.into(), child, tree_guard, ownership_id: format!("test-teardown-{session_id}"), @@ -111,12 +111,9 @@ async fn fake_session( owning_generation: None, broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), - in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), - turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), - last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), + ledger: crate::claude_turns::SharedLedger::default(), + clock: crate::attention::AttentionClock::default(), turn_lock: Arc::new(TokioMutex::new(())), - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), launch_genesis: None, }; (session, ready) diff --git a/crates/freshell-freshagent/src/claude/turn_status.rs b/crates/freshell-freshagent/src/claude/turn_status.rs new file mode 100644 index 000000000..d79914b11 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/turn_status.rs @@ -0,0 +1,307 @@ +//! The side of the turn ledger that talks to the world (design §5.2, C17): the +//! stdout consumer folds each sidecar line into the session's +//! [`ClaudeTurnLedger`](crate::claude_turns::ClaudeTurnLedger) and logs what it +//! settled, and the session's effective status is published from the ledger +//! whenever it changes. The ledger itself is pure (`claude_turns.rs`). + +use serde_json::{json, Value}; + +use super::{FreshClaudeState, PROVIDER}; +use crate::claude_turns::{ + EffectiveStatus, Folded, ResultVerdict, SharedLedger, StopReissue, TurnFact, +}; +use freshell_protocol::{FreshAgentEvent, ServerMessage}; + +const TARGET: &str = "freshell_freshagent::claude"; + +/// The banner code of a failed turn (design §5.3, §6). +pub(super) const TURN_FAILED_CODE: &str = "CLAUDE_TURN_FAILED"; + +/// What folding one sidecar line asks the stdout consumer to do next. +pub(super) enum LedgerStep { + /// Nothing beyond forwarding the line. + Nothing, + /// A result settled a turn (design §5.3): forward it with `sessionBusy`, + /// then show its banner and ring its edge as its verdict says. + Settled(SettledTurn), + /// A latched Stop missed a command the CLI was still starting: send the + /// interrupt again (§5.4). + Reissue(StopReissue), + /// The CLI refused the latched Stop's interrupt; the turn keeps running. + StopRefused { stop_id: String }, +} + +/// One settled turn, as the consumer reports it. +pub(super) struct SettledTurn { + /// The session is still busy after it (the result frame's `sessionBusy`). + pub(super) busy: bool, + pub(super) verdict: ResultVerdict, + /// The request ids of the commands it retired (compacts have none). + pub(super) request_ids: Vec, + /// The uuids of the commands it retired. + pub(super) command_ids: Vec, +} + +/// The `freshAgent.status` event for the session's envelope id. +pub(crate) fn status_event(session_id: &str, status: EffectiveStatus) -> Value { + json!({ + "type": "freshAgent.status", + "sessionId": session_id, + "status": status.as_str(), + }) +} + +impl FreshClaudeState { + /// The id the live session `map_key` stamps its frames with (the map key + /// until an attach rebinds it to the durable id). + pub(super) async fn envelope_stamp(&self, map_key: &str) -> String { + self.sessions + .lock() + .await + .get(map_key) + .map(|session| { + session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone() + }) + .unwrap_or_else(|| map_key.to_string()) + } + + /// Take back a send armed in the ledger whose write never went out: + /// exactly that command leaves (whatever else is owed stays), and the + /// status follows. + pub(super) fn take_back_unwritten_send( + &self, + ledger: &SharedLedger, + session_id: &str, + session_type: &str, + uuid: &str, + ) { + let mut ledger = ledger.lock().expect("turn ledger lock"); + // EOF may have cleared this command before its writer-failure receipt + // reached the handler. Publish only for a command actually removed, + // under this same guard so EOF cannot overtake its status update. + if ledger.undo_arm(uuid).is_some() { + self.publish_locked_status(&mut ledger, session_id, session_type, false); + } + } + + /// Say the live session `any_id` names its effective status, changed or + /// not, right after its `freshAgent.created`: a new pane learns it is ready + /// (or, for a create that found the session live, busy) without waiting for + /// a turn. Nothing is said for an id with no live session. + pub(super) async fn announce_claude_status(&self, any_id: &str, session_type: &str) { + let Some(key) = self.resolve_session_key(any_id).await else { + return; + }; + let live = self.sessions.lock().await.get(&key).map(|session| { + let session_id = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + (session.ledger.clone(), session_id) + }); + let Some((ledger, session_id)) = live else { + return; + }; + let mut ledger = ledger.lock().expect("turn ledger lock"); + let status = ledger + .take_status_change(false) + .unwrap_or_else(|| ledger.published_status()); + tracing::debug!(target: TARGET, provider = PROVIDER, session_id = %session_id, + session_type, status = status.as_str(), announced = true, "freshagent.claude.status"); + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: status_event(&session_id, status), + provider: PROVIDER.to_string(), + session_id, + session_type: session_type.to_string(), + })); + } + + /// Fold one sidecar line into the session's ledger and say what it asks for + /// next ([`LedgerStep`]). Settlements are logged at debug with their + /// verdict, the old-CLI head fallback at warn, an unknown command at debug. + pub(super) fn fold_into_ledger( + &self, + ledger: &SharedLedger, + line: &Value, + broadcast_id: &std::sync::Mutex, + session_type: &str, + ) -> LedgerStep { + let Some(fact) = TurnFact::from_line(line) else { + return LedgerStep::Nothing; + }; + let (folded, busy) = { + let mut ledger = ledger.lock().expect("turn ledger lock"); + let folded = ledger.fold(&fact); + (folded, ledger.busy()) + }; + let session_id = broadcast_id.lock().expect("broadcast id lock").clone(); + match folded { + Folded::Applied => LedgerStep::Nothing, + Folded::UnknownCommand => { + let uuid = line.get("uuid").and_then(serde_json::Value::as_str); + let state = line.get("state").and_then(serde_json::Value::as_str); + tracing::debug!(target: TARGET, provider = PROVIDER, + session_id = %session_id, session_type, uuid, state, + "freshagent.claude.ledger_unknown_command"); + LedgerStep::Nothing + } + Folded::Reissue(reissue) => LedgerStep::Reissue(reissue), + Folded::StopRefused { stop_id } => LedgerStep::StopRefused { stop_id }, + Folded::Settled(settlement) => { + let command_ids: Vec = settlement + .retired + .iter() + .map(|command| command.uuid.clone()) + .collect(); + let request_ids: Vec = settlement + .retired + .iter() + .filter_map(|command| command.request_id.clone()) + .collect(); + if settlement.head_retired { + tracing::warn!(target: TARGET, provider = PROVIDER, + session_id = %session_id, session_type, retired = ?command_ids, + "ledger.fallback_head_retire: the CLI reports no command \ + lifecycle and no command started, so the result settles the \ + oldest one sent"); + } + tracing::debug!(target: TARGET, provider = PROVIDER, + session_id = %session_id, session_type, retired = ?command_ids, + request_ids = ?request_ids, session_busy = busy, + verdict = settlement.verdict.as_str(), + stop_id = settlement.stop_id.as_deref(), + "freshagent.claude.turn_settled"); + LedgerStep::Settled(SettledTurn { + busy, + verdict: settlement.verdict, + request_ids, + command_ids, + }) + } + } + } + + /// A result settled a turn (design §5.3): after its `freshAgent.result`, a + /// failed turn shows why (the nested `freshAgent.error{CLAUDE_TURN_FAILED}` + /// banner, never for an API error, which the transcript shows), and the + /// turn rings through the edge hub unless the user's Stop silenced it. + pub(super) fn ring_settled_turn( + &self, + record: &crate::claude_edges::EdgeRecord<'_>, + settled: SettledTurn, + ) { + if let Some(message) = settled.verdict.banner() { + self.emit_fresh_agent_error( + record.stamp, + record.session_type, + TURN_FAILED_CODE, + message, + ); + } + self.edges.settle( + record, + crate::claude_edges::SettledReport { + rings: settled.verdict.rings(), + outcome: settled.verdict.outcome(), + retired_request_ids: settled.request_ids, + retired_command_ids: settled.command_ids, + }, + ); + } + + /// Publish the session's effective status when it changed (design §5.2 + /// `publish_status`). The broadcast is sent under the ledger lock, so two + /// publishers (the consumer and a handler's arm) never reorder their + /// statuses on the wire. `after_result`: a result was just forwarded, so a + /// status other than `idle` is said again even when unchanged (a client + /// that predates `sessionBusy` forced its pane idle at the result). + pub(super) fn publish_claude_status( + &self, + ledger: &SharedLedger, + session_id: &str, + session_type: &str, + after_result: bool, + ) { + let mut ledger = ledger.lock().expect("turn ledger lock"); + self.publish_locked_status(&mut ledger, session_id, session_type, after_result); + } + + fn publish_locked_status( + &self, + ledger: &mut crate::claude_turns::ClaudeTurnLedger, + session_id: &str, + session_type: &str, + after_result: bool, + ) { + let (status, changed) = match ledger.take_status_change(false) { + Some(status) => (status, true), + None if after_result && ledger.published_status() != EffectiveStatus::Idle => { + (ledger.published_status(), false) + } + None => return, + }; + tracing::debug!(target: TARGET, provider = PROVIDER, session_id, session_type, + status = status.as_str(), changed, "freshagent.claude.status"); + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: status_event(session_id, status), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::claude_turns::SendKind; + use std::sync::Arc; + + #[tokio::test] + async fn a_failed_send_after_eof_does_not_replace_exited_with_idle() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let state = FreshClaudeState::new(Arc::new(tx)); + let ledger = SharedLedger::default(); + ledger + .lock() + .unwrap() + .arm("send-uuid".into(), Some("request".into()), SendKind::Turn); + state.publish_claude_status(&ledger, "session", "freshclaude", false); + assert!(ledger.lock().unwrap().eof()); + state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: "claude".into(), + session_id: "session".into(), + session_type: "freshclaude".into(), + event: json!({"type":"freshAgent.status", "sessionId":"session", "status":"exited"}), + })); + // A delayed writer-failure handler wakes after stdout EOF has already + // cleared the command and announced the ended runtime. + state.take_back_unwritten_send(&ledger, "session", "freshclaude", "send-uuid"); + let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + assert_eq!(frames.last().unwrap()["event"]["status"], "exited"); + } + + #[tokio::test] + async fn a_live_failed_send_still_returns_the_session_to_idle() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let state = FreshClaudeState::new(Arc::new(tx)); + let ledger = SharedLedger::default(); + ledger + .lock() + .unwrap() + .arm("send-uuid".into(), None, SendKind::Turn); + state.publish_claude_status(&ledger, "session", "freshclaude", false); + state.take_back_unwritten_send(&ledger, "session", "freshclaude", "send-uuid"); + let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + assert_eq!(frames.last().unwrap()["event"]["status"], "idle"); + } +} diff --git a/crates/freshell-freshagent/src/claude_dead_sessions.rs b/crates/freshell-freshagent/src/claude_dead_sessions.rs index 9404e3ee0..8b37f1cc3 100644 --- a/crates/freshell-freshagent/src/claude_dead_sessions.rs +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -38,6 +38,9 @@ pub(crate) struct DeadClaudeSession { pub(crate) reason: String, /// When it ended (epoch ms). pub(crate) at_ms: i64, + /// The session's attention clock (design §5.5): a resume continues it, so + /// the resumed session's edges land strictly above the dead one's. + pub(crate) clock: crate::attention::AttentionClock, } impl DeadClaudeSession { @@ -101,6 +104,8 @@ pub(crate) struct Death<'a> { pub(crate) commands: Option<&'a Vec>, /// The `sdk.stream.ended` reason, or `sidecar_exited`. pub(crate) reason: &'a str, + /// The session's attention clock, for the resume to continue. + pub(crate) clock: &'a crate::attention::AttentionClock, } /// The claude lane's dead-session records, shared by every clone of its state @@ -133,6 +138,7 @@ impl SharedDeadSessions { commands: death.commands.cloned(), reason: death.reason.to_string(), at_ms: crate::rollback_record::now_ms(), + clock: death.clock.clone(), }; let (dropped, remembered) = { let mut store = self.0.lock().expect("dead sessions lock"); @@ -190,6 +196,7 @@ mod tests { commands: None, reason: "cli_exited".into(), at_ms: 1, + clock: Default::default(), } } diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs new file mode 100644 index 000000000..2a950c9ab --- /dev/null +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -0,0 +1,529 @@ +//! The one place a claude attention edge leaves the server (core-runtime design +//! §3.2, §5.5, §9.2 I-1, §9.3 I3; C18). +//! +//! Every `freshAgent.turn.complete` and `freshAgent.turn.waiting` a freshclaude +//! or kilroy pane receives is minted here, by the server: a result that rings +//! (its verdict, `claude_turns.rs`), a sidecar that ended while a turn was owed, +//! a pending approval or question appearing. The sidecar mints none. Each edge: +//! +//! 1. takes its `at` from the session's [`AttentionClock`], so edges from every +//! source are strictly ordered per session (G24); +//! 2. is logged at info as `freshagent.turn.complete` / `freshagent.turn.waiting` +//! with the provider, the ids, the source and `at` (never content, G14); +//! 3. is broadcast as the client frame (its shape is unchanged); +//! 4. is reported to the [`AttentionObserver`], when one is installed (the +//! client-alerts work's attention ledger, §9.2 I-1); +//! 5. is published as a typed [`ClaudeTurnEvent`] on a low-volume channel of its +//! own (the agent API's turn correlation, §9.3 I3), which never carries +//! stream traffic and so cannot lag behind it. A result the user's Stop +//! silenced rings nothing but is still published, with no `at`. + +use std::sync::{Arc, RwLock}; + +use serde_json::json; +use tokio::sync::broadcast; + +use crate::attention::AttentionClock; +use crate::claude_turns::TurnOutcome; +use freshell_protocol::{FreshAgentEvent, ServerMessage}; + +const PROVIDER: &str = "claude"; + +/// How many turn events a slow subscriber may fall behind by before it loses +/// the oldest (one per settled turn, waiting edge or session end: low volume). +const TURN_EVENTS_CAPACITY: usize = 256; + +/// Hooks for whoever keeps the claude sessions' attention state (the +/// client-alerts work's attention ledger, design §9.2 I-1 and I-2). Every hook +/// has a default that does nothing. Ids are the ids clients address the +/// session by (its envelope stamp). +pub trait AttentionObserver: Send + Sync { + /// A session was registered (a create, or a resume). + fn on_registered(&self, _id: &str, _session_type: &str) {} + /// A `freshAgent.turn.complete` edge left the server. + fn on_turn_complete(&self, _id: &str, _session_type: &str, _at: i64) {} + /// A `freshAgent.turn.waiting` edge left the server. + fn on_waiting(&self, _id: &str, _session_type: &str, _at: i64) {} + /// The session's pending approvals and questions dropped back to none. + fn on_pending_drained(&self, _id: &str) {} + /// The id clients address the session by changed from `old` to `new`. + fn on_rekeyed(&self, _old: &str, _new: &str) {} + /// The session was closed by request. + fn on_forgotten(&self, _id: &str) {} +} + +/// A claude turn event for in-process observers (design §9.3 I3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClaudeTurnEvent { + /// A result settled a turn. `at` is its edge's, or `None` when the user's + /// Stop silenced it. + Settled { + /// The sessions-map key (the `session_key` of a send's acceptance). + session_key: String, + /// The request ids of the sends the result ended (an absorbed send ends + /// with the turn that absorbed it; a compact has no request id). + retired_request_ids: Vec, + /// The uuids of the commands the result ended. + retired_command_ids: Vec, + outcome: TurnOutcome, + at: Option, + }, + /// The session started waiting on its user (an approval or a question). + Waiting { session_key: String, at: i64 }, + /// The session's sidecar ended unrequested; `reason` is why + /// (`cli_exited`, `stream_error`, `stream_eof` or `sidecar_exited`). + Ended { session_key: String, reason: String }, +} + +/// The two attention edges (AGENTS.md "Agent Status Indicators"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EdgeKind { + TurnComplete, + Waiting, +} + +impl EdgeKind { + /// The client event type. + pub(crate) fn wire_type(self) -> &'static str { + match self { + Self::TurnComplete => "freshAgent.turn.complete", + Self::Waiting => "freshAgent.turn.waiting", + } + } +} + +/// What minted an edge (the logged `source`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EdgeSource { + /// A turn's result that rings (design §5.3). + Result, + /// The sidecar ended unrequested while a turn was owed (§5.7). + Death, + /// Pending approvals or questions went from none to some. + Pending, +} + +impl EdgeSource { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Result => "result", + Self::Death => "death", + Self::Pending => "pending", + } + } +} + +/// The session an edge is minted for. +pub(crate) struct EdgeRecord<'a> { + /// The sessions-map key (turn events name it). + pub(crate) session_key: &'a str, + /// The id the session's frames are stamped with (clients address it so). + pub(crate) stamp: &'a str, + /// The id the sidecar knows the session by (the edge event names it). + pub(crate) sidecar_session_id: &'a str, + pub(crate) session_type: &'a str, + /// The session's clock. + pub(crate) clock: &'a AttentionClock, +} + +/// One settled turn, as the hub reports it. +pub(crate) struct SettledReport { + pub(crate) rings: bool, + pub(crate) outcome: TurnOutcome, + pub(crate) retired_request_ids: Vec, + pub(crate) retired_command_ids: Vec, +} + +/// The choke point (design §5.5). Cheap to clone: every clone shares the bus, +/// the observer slot and the turn-event channel. +#[derive(Clone)] +pub(crate) struct ClaudeEdgeHub { + broadcast_tx: Arc>, + observer: Arc>>>, + turn_events: broadcast::Sender, +} + +impl ClaudeEdgeHub { + pub(crate) fn new(broadcast_tx: Arc>) -> Self { + Self { + broadcast_tx, + observer: Arc::new(RwLock::new(None)), + turn_events: broadcast::channel(TURN_EVENTS_CAPACITY).0, + } + } + + /// Install the observer every clone reports to. + pub(crate) fn set_observer(&self, observer: Arc) { + *self.observer.write().expect("attention observer lock") = Some(observer); + } + + /// A receiver of every turn event published from now on. + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.turn_events.subscribe() + } + + fn observe(&self, notify: impl FnOnce(&dyn AttentionObserver)) { + let observer = self + .observer + .read() + .expect("attention observer lock") + .clone(); + if let Some(observer) = observer { + notify(observer.as_ref()); + } + } + + fn publish(&self, event: ClaudeTurnEvent) { + // No subscriber is not an error: the events are for whoever listens. + let _ = self.turn_events.send(event); + } + + /// Mint, log, broadcast and report one edge. Every claude edge leaves + /// through here. + fn emit(&self, record: &EdgeRecord<'_>, kind: EdgeKind, source: EdgeSource) -> i64 { + let at = record.clock.mint(); + let source_name = source.as_str(); + match kind { + EdgeKind::TurnComplete => tracing::info!( + provider = PROVIDER, session_id = %record.stamp, + session_type = %record.session_type, source = source_name, at, + "freshagent.turn.complete" + ), + EdgeKind::Waiting => tracing::info!( + provider = PROVIDER, session_id = %record.stamp, + session_type = %record.session_type, source = source_name, at, + "freshagent.turn.waiting" + ), + } + let frame = ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": kind.wire_type(), + "sessionId": record.sidecar_session_id, + "at": at, + }), + provider: PROVIDER.to_string(), + session_id: record.stamp.to_string(), + session_type: record.session_type.to_string(), + }); + if let Ok(frame) = serde_json::to_string(&frame) { + let _ = self.broadcast_tx.send(frame); + } + self.observe(|observer| match kind { + EdgeKind::TurnComplete => { + observer.on_turn_complete(record.stamp, record.session_type, at) + } + EdgeKind::Waiting => observer.on_waiting(record.stamp, record.session_type, at), + }); + at + } + + /// A result settled a turn: ring it unless the user's Stop silenced it, and + /// publish the settlement either way. Returns the edge's `at`, if it rang. + pub(crate) fn settle(&self, record: &EdgeRecord<'_>, settled: SettledReport) -> Option { + let at = settled + .rings + .then(|| self.emit(record, EdgeKind::TurnComplete, EdgeSource::Result)); + self.publish(ClaudeTurnEvent::Settled { + session_key: record.session_key.to_string(), + retired_request_ids: settled.retired_request_ids, + retired_command_ids: settled.retired_command_ids, + outcome: settled.outcome, + at, + }); + at + } + + /// Pending approvals or questions went from none to some. + pub(crate) fn waiting(&self, record: &EdgeRecord<'_>) -> i64 { + let at = self.emit(record, EdgeKind::Waiting, EdgeSource::Pending); + self.publish(ClaudeTurnEvent::Waiting { + session_key: record.session_key.to_string(), + at, + }); + at + } + + /// The sidecar ended unrequested: ring when a turn was owed, and publish the + /// end either way. + pub(crate) fn ended(&self, record: &EdgeRecord<'_>, owed: bool, reason: &str) -> Option { + let at = owed.then(|| self.emit(record, EdgeKind::TurnComplete, EdgeSource::Death)); + self.publish(ClaudeTurnEvent::Ended { + session_key: record.session_key.to_string(), + reason: reason.to_string(), + }); + at + } + + /// The session's pending approvals and questions dropped back to none. + pub(crate) fn pending_drained(&self, stamp: &str) { + self.observe(|observer| observer.on_pending_drained(stamp)); + } + + /// A session was registered under `id`. + pub(crate) fn registered(&self, id: &str, session_type: &str) { + self.observe(|observer| observer.on_registered(id, session_type)); + } + + /// The id clients address a session by changed. + pub(crate) fn rekeyed(&self, old: &str, new: &str) { + if old != new { + self.observe(|observer| observer.on_rekeyed(old, new)); + } + } + + /// A session was closed by request. + pub(crate) fn forgotten(&self, id: &str) { + self.observe(|observer| observer.on_forgotten(id)); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use serde_json::Value; + + use super::*; + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + + /// Every hook call, in order. + #[derive(Default)] + struct Recorder(Mutex>); + + impl AttentionObserver for Recorder { + fn on_registered(&self, id: &str, session_type: &str) { + self.0 + .lock() + .unwrap() + .push(format!("registered {id} {session_type}")); + } + fn on_turn_complete(&self, id: &str, session_type: &str, at: i64) { + self.0 + .lock() + .unwrap() + .push(format!("complete {id} {session_type} {at}")); + } + fn on_waiting(&self, id: &str, session_type: &str, at: i64) { + self.0 + .lock() + .unwrap() + .push(format!("waiting {id} {session_type} {at}")); + } + fn on_pending_drained(&self, id: &str) { + self.0.lock().unwrap().push(format!("drained {id}")); + } + fn on_rekeyed(&self, old: &str, new: &str) { + self.0.lock().unwrap().push(format!("rekeyed {old} {new}")); + } + fn on_forgotten(&self, id: &str) { + self.0.lock().unwrap().push(format!("forgotten {id}")); + } + } + + fn hub() -> (ClaudeEdgeHub, broadcast::Receiver) { + let (tx, rx) = broadcast::channel(64); + (ClaudeEdgeHub::new(Arc::new(tx)), rx) + } + + fn record<'a>(clock: &'a AttentionClock) -> EdgeRecord<'a> { + EdgeRecord { + session_key: "key-1", + stamp: "envelope-1", + sidecar_session_id: "sidecar-1", + session_type: "kilroy", + clock, + } + } + + fn settled(rings: bool, outcome: TurnOutcome) -> SettledReport { + SettledReport { + rings, + outcome, + retired_request_ids: vec!["req-1".into()], + retired_command_ids: vec!["uuid-1".into()], + } + } + + fn frames(rx: &mut broadcast::Receiver) -> Vec { + std::iter::from_fn(|| rx.try_recv().ok()) + .map(|frame| serde_json::from_str(&frame).unwrap()) + .collect() + } + + /// Design §5.5 steps 1-4: each edge is minted on the session's clock, + /// logged at info with its source (ids only), broadcast as the client frame + /// and reported to the observer. + #[test] + fn every_edge_is_minted_logged_broadcast_and_observed() { + let (events, _guard) = capture(); + let (hub, mut rx) = hub(); + let recorder = Arc::new(Recorder::default()); + hub.set_observer(recorder.clone()); + let clock = AttentionClock::default(); + let record = record(&clock); + + let complete = hub.settle(&record, settled(true, TurnOutcome::Completed)); + let waiting = hub.waiting(&record); + let death = hub.ended(&record, true, "cli_exited"); + let (complete, death) = (complete.unwrap(), death.unwrap()); + assert!( + complete < waiting && waiting < death, + "one clock: {complete} {waiting} {death}" + ); + + let frames = frames(&mut rx); + for frame in &frames { + assert_eq!(frame["type"], "freshAgent.event"); + assert_eq!(frame["provider"], "claude"); + assert_eq!(frame["sessionId"], "envelope-1"); + assert_eq!(frame["sessionType"], "kilroy"); + } + assert_eq!( + frames + .iter() + .map(|frame| frame["event"].clone()) + .collect::>(), + vec![ + json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": complete }), + json!({ "type": "freshAgent.turn.waiting", "sessionId": "sidecar-1", "at": waiting }), + json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": death }), + ] + ); + + let logged = |message| { + with_message(&events, message) + .iter() + .map(|event| { + assert_eq!(event.level, tracing::Level::INFO); + assert_eq!(event.field("provider"), Some("claude")); + assert_eq!(event.field("session_id"), Some("envelope-1")); + assert_eq!(event.field("session_type"), Some("kilroy")); + ( + event.field("source").unwrap_or_default().to_string(), + event.field("at").unwrap_or_default().to_string(), + ) + }) + .collect::>() + }; + assert_eq!( + logged("freshagent.turn.complete"), + vec![ + ("result".to_string(), complete.to_string()), + ("death".to_string(), death.to_string()), + ] + ); + assert_eq!( + logged("freshagent.turn.waiting"), + vec![("pending".to_string(), waiting.to_string())] + ); + assert_eq!( + *recorder.0.lock().unwrap(), + vec![ + format!("complete envelope-1 kilroy {complete}"), + format!("waiting envelope-1 kilroy {waiting}"), + format!("complete envelope-1 kilroy {death}"), + ] + ); + } + + /// G24: a death right after a result, in the same millisecond, still gets + /// a strictly larger `at` (the client drops an edge whose `at` is not above + /// the last one it saw for the session). + #[test] + fn a_death_after_a_result_in_the_same_millisecond_gets_a_larger_at() { + let (hub, _rx) = hub(); + let clock = AttentionClock::default(); + // Hold the wall clock: the floor is ahead of now, as for two edges in + // one millisecond. + let ahead = crate::rollback_record::now_ms() + 60_000; + clock.observe(ahead); + let record = record(&clock); + let result = hub + .settle(&record, settled(true, TurnOutcome::Completed)) + .unwrap(); + let death = hub.ended(&record, true, "sidecar_exited").unwrap(); + assert_eq!(result, ahead + 1); + assert_eq!(death, ahead + 2); + } + + /// Design §9.3 I3: every settled turn is published with what it retired, + /// a silenced one with no `at`; waiting edges and session ends too. + #[tokio::test] + async fn turn_events_are_published_for_every_settlement_waiting_edge_and_end() { + let (hub, mut rx) = hub(); + let mut events = hub.subscribe(); + let clock = AttentionClock::default(); + let record = record(&clock); + + let rang = hub.settle(&record, settled(true, TurnOutcome::Failed)); + let silent = hub.settle(&record, settled(false, TurnOutcome::Interrupted)); + assert_eq!(silent, None); + let waiting = hub.waiting(&record); + assert_eq!( + hub.ended(&record, false, "stream_eof"), + None, + "an idle death is silent" + ); + + let received: Vec = + std::iter::from_fn(|| events.try_recv().ok()).collect(); + assert_eq!( + received, + vec![ + ClaudeTurnEvent::Settled { + session_key: "key-1".into(), + retired_request_ids: vec!["req-1".into()], + retired_command_ids: vec!["uuid-1".into()], + outcome: TurnOutcome::Failed, + at: rang, + }, + ClaudeTurnEvent::Settled { + session_key: "key-1".into(), + retired_request_ids: vec!["req-1".into()], + retired_command_ids: vec!["uuid-1".into()], + outcome: TurnOutcome::Interrupted, + at: None, + }, + ClaudeTurnEvent::Waiting { + session_key: "key-1".into(), + at: waiting, + }, + ClaudeTurnEvent::Ended { + session_key: "key-1".into(), + reason: "stream_eof".into(), + }, + ] + ); + assert_eq!( + frames(&mut rx) + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.turn.complete") + .count(), + 1, + "only the turn that rang put an edge on the wire" + ); + } + + #[test] + fn the_identity_hooks_reach_the_observer_and_nothing_breaks_without_one() { + let (hub, _rx) = hub(); + hub.registered("a", "freshclaude"); + hub.pending_drained("a"); + let recorder = Arc::new(Recorder::default()); + hub.clone().set_observer(recorder.clone()); + hub.registered("a", "freshclaude"); + hub.rekeyed("a", "a"); + hub.rekeyed("a", "b"); + hub.pending_drained("b"); + hub.forgotten("b"); + assert_eq!( + *recorder.0.lock().unwrap(), + vec![ + "registered a freshclaude", + "rekeyed a b", + "drained b", + "forgotten b", + ] + ); + } +} diff --git a/crates/freshell-freshagent/src/claude_sidecar_exit.rs b/crates/freshell-freshagent/src/claude_sidecar_exit.rs index 904572582..54b2770c9 100644 --- a/crates/freshell-freshagent/src/claude_sidecar_exit.rs +++ b/crates/freshell-freshagent/src/claude_sidecar_exit.rs @@ -9,8 +9,8 @@ //! sidecar through the same process-tree stop every teardown uses (`claude.rs` //! `SidecarProcesses::stop`, which reaps node's exit status), reports the //! [`SidecarExitCause`] to the user in `freshAgent.error{SIDECAR_EXITED}`, logs it -//! as `freshagent.claude.sidecar_exit`, and decides whether the lost turn rings -//! ([`death_rings`]). +//! as `freshagent.claude.sidecar_exit`, and rings the lost turn when one was +//! owed (C18: however the sidecar ended; it reports no edge of its own). use std::process::ExitStatus; @@ -125,14 +125,6 @@ pub(crate) fn describe_status(status: Option) -> String { } } -/// Whether the end-of-output path rings for a lost turn. Interim (C08 until Rust -/// mints every edge, C18): the sidecar rings a turn that ends with no result before -/// it reports the stream end, so the server rings only when there was no report — -/// a Node death — and a turn was running. -pub(crate) fn death_rings(turn_was_in_flight: bool, cause: &SidecarExitCause) -> bool { - turn_was_in_flight && matches!(cause, SidecarExitCause::ProcessEnded(_)) -} - /// The `freshAgent.error{SIDECAR_EXITED}` event for `session_id`. pub(crate) fn sidecar_exited_event(session_id: &str, cause: &SidecarExitCause) -> Value { json!({ @@ -280,17 +272,6 @@ mod tests { ); } - #[test] - fn only_an_unreported_death_mid_turn_rings() { - let node_death = SidecarExitCause::new(None, None); - assert!(death_rings(true, &node_death)); - assert!(!death_rings(false, &node_death), "an idle death is silent"); - // The sidecar already rang the turn before it reported the end (interim). - assert!(!death_rings(true, &ended("cli_exited", "x"))); - assert!(!death_rings(true, &ended("stream_eof", ""))); - assert!(!death_rings(false, &ended("stream_error", "x"))); - } - #[test] fn the_exited_event_carries_the_code_the_message_and_the_cause() { let cause = ended( diff --git a/crates/freshell-freshagent/src/claude_sidecar_io.rs b/crates/freshell-freshagent/src/claude_sidecar_io.rs index 8bc7ad44e..8298244c2 100644 --- a/crates/freshell-freshagent/src/claude_sidecar_io.rs +++ b/crates/freshell-freshagent/src/claude_sidecar_io.rs @@ -23,11 +23,202 @@ use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::Duration; use serde_json::{Map, Value}; -use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, oneshot, watch}; use tokio::time::Instant; const PROVIDER: &str = "claude"; +const WRITE_TIMEOUT_ENV: &str = "FRESHELL_FRESHCLAUDE_WRITE_TIMEOUT_MS"; +const WRITE_TIMEOUT: Duration = Duration::from_secs(10); + +/// A FIFO writer owned by one sidecar runtime. Callers clone this handle while +/// holding the sessions map, release that lock, and await only their own write. +/// Shutdown has a separate channel so it can cancel a stalled write promptly. +#[derive(Clone)] +pub(crate) struct SidecarWriter { + inner: Arc, +} + +struct WriterInner { + requests: mpsc::UnboundedSender, + shutdown: StdMutex>>, + closed: watch::Receiver, + task: tokio::task::AbortHandle, +} + +impl Drop for WriterInner { + fn drop(&mut self) { + self.task.abort(); + } +} + +struct WriteRequest { + line: Vec, + answer: oneshot::Sender>, +} + +impl SidecarWriter { + /// The fault receiver fires once on timeout or a broken pipe. Its runtime + /// owner kills only that incarnation without removing its map entry, letting + /// stdout EOF run the normal death, attention and recovery path. + pub(crate) fn spawn( + stdin: W, + labels: SidecarLogLabels, + timeout: Duration, + ) -> (Self, oneshot::Receiver) + where + W: AsyncWrite + Unpin + Send + 'static, + { + let (requests, input) = mpsc::unbounded_channel(); + let (shutdown, stop) = oneshot::channel(); + let (closed_tx, closed) = watch::channel(false); + let (fault_tx, fault) = oneshot::channel(); + let task = tokio::spawn(async move { + run_writer(stdin, input, stop, labels, timeout, fault_tx).await; + let _ = closed_tx.send(true); + }); + ( + Self { + inner: Arc::new(WriterInner { + requests, + shutdown: StdMutex::new(Some(shutdown)), + closed, + task: task.abort_handle(), + }), + }, + fault, + ) + } + + pub(crate) async fn write(&self, value: &Value) -> Result<(), String> { + let mut line = serde_json::to_vec(value).map_err(|error| error.to_string())?; + line.push(b'\n'); + let (answer, receipt) = oneshot::channel(); + self.inner + .requests + .send(WriteRequest { line, answer }) + .map_err(|_| "Claude sidecar writer is closed.".to_string())?; + receipt.await.unwrap_or_else(|_| { + Err("Claude sidecar writer stopped before the write finished.".into()) + }) + } + + /// Ask the sidecar to shut down, then close stdin, within the teardown's + /// existing deadline. A concurrent write is cancelled instead of extending + /// that deadline by the normal (longer) write timeout. + pub(crate) async fn shutdown(&self, deadline: Instant) { + if let Some(stop) = self + .inner + .shutdown + .lock() + .expect("writer shutdown lock") + .take() + { + let _ = stop.send(deadline); + } + let mut closed = self.inner.closed.clone(); + if tokio::time::timeout_at(deadline, closed.wait_for(|closed| *closed)) + .await + .is_err() + { + self.inner.task.abort(); + } + } +} + +/// The isolated test environment follows the same selection as the child; in +/// production the setting comes from the server environment. +pub(crate) fn write_timeout(environment: Option<&[(String, String)]>) -> Duration { + let configured = match environment { + Some(environment) => environment + .iter() + .rev() + .find(|(key, _)| key == WRITE_TIMEOUT_ENV) + .map(|(_, value)| value.clone()), + None => std::env::var(WRITE_TIMEOUT_ENV).ok(), + }; + configured + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .map(Duration::from_millis) + .unwrap_or(WRITE_TIMEOUT) +} + +async fn write_bytes(stdin: &mut W, line: &[u8]) -> std::io::Result<()> { + stdin.write_all(line).await?; + stdin.flush().await +} + +async fn writer_shutdown(stdin: &mut W, deadline: Instant) { + let _ = + tokio::time::timeout_at(deadline, write_bytes(stdin, b"{\"type\":\"shutdown\"}\n")).await; +} + +async fn run_writer( + mut stdin: W, + mut requests: mpsc::UnboundedReceiver, + mut stop: oneshot::Receiver, + labels: SidecarLogLabels, + timeout: Duration, + fault: oneshot::Sender, +) { + loop { + let request = tokio::select! { + biased; + deadline = &mut stop => { + if let Ok(deadline) = deadline { writer_shutdown(&mut stdin, deadline).await; } + return; + } + request = requests.recv() => match request { Some(request) => request, None => return }, + }; + let written = tokio::select! { + biased; + deadline = &mut stop => { + if let Ok(deadline) = deadline { writer_shutdown(&mut stdin, deadline).await; } + return; + } + result = tokio::time::timeout(timeout, write_bytes(&mut stdin, &request.line)) => result, + }; + let error = match written { + Ok(Ok(())) => { + let _ = request.answer.send(Ok(())); + continue; + } + Ok(Err(error)) => format!("Claude sidecar write failed: {error}"), + Err(_) => format!( + "Claude sidecar write timed out after {} ms.", + timeout.as_millis() + ), + }; + tracing::error!(provider = PROVIDER, session_id = labels.session_id().as_deref(), + ownership_id = %labels.ownership_id(), pid = labels.pid(), + timeout_ms = timeout.as_millis() as u64, cause = %error, + "freshagent.claude.sidecar_write_failed"); + let _ = fault.send(error.clone()); + let _ = request.answer.send(Err(error.clone())); + requests.close(); + while let Some(request) = requests.recv().await { + let _ = request.answer.send(Err(error.clone())); + } + return; + } +} + +/// Unit fixtures construct already-booted sessions directly; production always +/// supplies the real runtime's labels and watches the failure receiver. +#[cfg(test)] +impl From for SidecarWriter { + fn from(stdin: tokio::process::ChildStdin) -> Self { + Self::spawn( + stdin, + SidecarLogLabels::new("test-fixture", "freshclaude", None), + WRITE_TIMEOUT, + ) + .0 + } +} + /// Set by the hosted session host in a hosted sidecar's environment (to the /// provider it hosts: `claude` or `kilroy`). const HOSTED_ENV: &str = "FRESHELL_HOSTED_FRESH_AGENT"; @@ -545,7 +736,7 @@ fn emit_dropped(labels: &SidecarLogLabels, dropped: u64, window: Duration) { #[cfg(test)] pub(crate) mod test_capture { use std::collections::BTreeMap; - use std::sync::{Arc, Mutex as StdMutex}; + use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use tracing::field::{Field, Visit}; use tracing::{Event, Level, Subscriber}; use tracing_subscriber::layer::{Context, SubscriberExt}; @@ -609,6 +800,14 @@ pub(crate) mod test_capture { Arc>>, tracing::subscriber::DefaultGuard, ) { + // tracing-core's lone-dispatch optimization registers a new callsite + // using the emitting thread's default. A different test thread has + // no capture and can cache "never" globally. Keep a second, inert + // registrar alive so interest combines all scoped captures instead. + // This does not install or replace the process-wide subscriber. + static REGISTRAR: OnceLock = OnceLock::new(); + REGISTRAR + .get_or_init(|| tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default())); let events = Arc::new(StdMutex::new(Vec::new())); let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&events))); (events, tracing::subscriber::set_default(subscriber)) @@ -632,6 +831,20 @@ pub(crate) mod test_capture { #[cfg(test)] mod tests { use super::test_capture::{capture, with_message, Captured}; + + #[test] + fn scoped_capture_keeps_events_when_another_thread_first_logs_the_callsite() { + fn emit() { + tracing::info!("capture-cross-thread-registration"); + } + let (events, _guard) = capture(); + std::thread::spawn(emit).join().unwrap(); + emit(); + assert_eq!( + with_message(&events, "capture-cross-thread-registration").len(), + 1 + ); + } use super::*; use tokio::io::AsyncWriteExt; use tracing::Level; @@ -1017,3 +1230,105 @@ mod tests { } } } + +#[cfg(test)] +mod writer_tests { + use super::*; + + fn labels() -> SidecarLogLabels { + SidecarLogLabels::new("writer-test", "freshclaude", None) + } + + #[tokio::test] + async fn writes_are_flushed_in_fifo_order() { + let (stdin, stdout) = tokio::io::duplex(16); + let (writer, fault) = SidecarWriter::spawn(stdin, labels(), Duration::from_secs(1)); + let reader = tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + [ + lines.next_line().await.unwrap(), + lines.next_line().await.unwrap(), + ] + }); + let first = serde_json::json!({"text": "first"}); + let second = serde_json::json!({"text": "second"}); + let (a, b) = tokio::join!(writer.write(&first), writer.write(&second)); + a.unwrap(); + b.unwrap(); + assert_eq!( + reader.await.unwrap(), + [Some(first.to_string()), Some(second.to_string())] + ); + drop(writer); + assert!( + fault.await.is_err(), + "dropping a writer is not a runtime fault" + ); + } + + #[tokio::test] + async fn a_broken_pipe_refuses_the_write_and_reports_one_runtime_fault() { + let (stdin, stdout) = tokio::io::duplex(16); + drop(stdout); + let (writer, fault) = SidecarWriter::spawn(stdin, labels(), Duration::from_secs(1)); + let error = writer + .write(&serde_json::json!({"text":"first"})) + .await + .unwrap_err(); + assert!(error.contains("write failed"), "{error}"); + assert_eq!(fault.await.unwrap(), error); + assert!(writer + .write(&serde_json::json!({"text":"second"})) + .await + .is_err()); + } + + #[tokio::test(start_paused = true)] + async fn a_full_pipe_times_out_including_flush_and_reports_the_fault() { + let (stdin, _stdout) = tokio::io::duplex(16); + let (writer, fault) = SidecarWriter::spawn(stdin, labels(), Duration::from_millis(500)); + let started = Instant::now(); + let error = writer + .write(&serde_json::json!({"text":"x".repeat(1024)})) + .await + .unwrap_err(); + assert_eq!(Instant::now() - started, Duration::from_millis(500)); + assert!(error.contains("timed out after 500 ms"), "{error}"); + assert_eq!(fault.await.unwrap(), error); + } + + #[tokio::test(start_paused = true)] + async fn requested_shutdown_preempts_a_write_without_reporting_a_fault() { + let (stdin, _stdout) = tokio::io::duplex(16); + let (writer, fault) = SidecarWriter::spawn(stdin, labels(), Duration::from_secs(10)); + let pending = { + let writer = writer.clone(); + tokio::spawn(async move { + writer + .write(&serde_json::json!({"text":"x".repeat(1024)})) + .await + }) + }; + tokio::task::yield_now().await; + let started = Instant::now(); + writer.shutdown(started + Duration::from_millis(100)).await; + assert!(Instant::now() - started <= Duration::from_millis(100)); + assert!(pending.await.unwrap().is_err()); + assert!( + fault.await.is_err(), + "requested shutdown must remain silent" + ); + } + + #[test] + fn write_timeout_uses_the_runtime_environment_without_global_test_mutation() { + for (value, expected) in [("500", 500), ("0", 10_000), ("invalid", 10_000)] { + let environment = vec![(WRITE_TIMEOUT_ENV.into(), value.into())]; + assert_eq!( + write_timeout(Some(&environment)), + Duration::from_millis(expected) + ); + } + assert_eq!(write_timeout(Some(&[])), WRITE_TIMEOUT); + } +} diff --git a/crates/freshell-freshagent/src/claude_spawn.rs b/crates/freshell-freshagent/src/claude_spawn.rs index f040da33f..0d19f9ad9 100644 --- a/crates/freshell-freshagent/src/claude_spawn.rs +++ b/crates/freshell-freshagent/src/claude_spawn.rs @@ -369,17 +369,45 @@ impl std::fmt::Display for ClaudeSpawnError { /// tree at once. Never fails: every step is best-effort and the returned /// [`SidecarExit`] says what actually happened. pub async fn graceful_then_kill( - mut child: Child, + child: Child, stdin: ChildStdin, - mut guard: SidecarTreeGuard, + guard: SidecarTreeGuard, + grace: Duration, +) -> SidecarExit { + stop_with_shutdown(child, guard, grace, |deadline| { + request_shutdown(stdin, deadline) + }) + .await +} + +pub(crate) async fn graceful_then_kill_with_writer( + child: Child, + stdin: crate::claude_sidecar_io::SidecarWriter, + guard: SidecarTreeGuard, grace: Duration, ) -> SidecarExit { + stop_with_shutdown(child, guard, grace, |deadline| async move { + stdin.shutdown(deadline).await; + }) + .await +} + +async fn stop_with_shutdown( + mut child: Child, + mut guard: SidecarTreeGuard, + grace: Duration, + shutdown: F, +) -> SidecarExit +where + F: FnOnce(Instant) -> R, + R: std::future::Future, +{ let started = Instant::now(); let deadline = started + grace; // Before anything can end a member (node's exit can: on Windows node // keeps the children it spawns in a kill-on-close job of its own). guard.watch_members(); - request_shutdown(stdin, deadline).await; + shutdown(deadline).await; if let Ok(Ok(status)) = tokio::time::timeout_at(deadline, child.wait()).await { let (stop, drain) = if status.success() { ( diff --git a/crates/freshell-freshagent/src/claude_test_holds.rs b/crates/freshell-freshagent/src/claude_test_holds.rs index 2666e660c..db51e14bc 100644 --- a/crates/freshell-freshagent/src/claude_test_holds.rs +++ b/crates/freshell-freshagent/src/claude_test_holds.rs @@ -11,8 +11,9 @@ //! reports the exit, so a resume can land while the dead sidecar is reaped. //! - Rollback boot ([`ClaudeTestHolds::hold_rollback_boot`]): an undo or redo //! parks after it wrote its durable record, holding the session's turn lock, -//! before it boots the replacement runtime, so a test can park a send behind -//! it or change the transcript store under it. +//! before its last busy recheck and the replacement runtime's boot, so a test +//! can park a send behind it, change the transcript store under it, or let +//! the CLI start a turn of its own. //! - Fork child ([`ClaudeTestHolds::hold_fork_child`]): a fork parks once its //! child runtime is established, before it records the child's binding row, //! so a test can close the parent while the child is still booting. diff --git a/crates/freshell-freshagent/src/claude_turns.rs b/crates/freshell-freshagent/src/claude_turns.rs new file mode 100644 index 000000000..b38df1011 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_turns.rs @@ -0,0 +1,1353 @@ +//! freshclaude turn accounting (docs/plans/2026-10-22-freshclaude-core-runtime.md +//! §3.2, §5.1, §5.2). +//! +//! C16: every send the server writes to the sidecar carries a uuid the server +//! minted ([`mint_send_uuid`]). The sidecar puts it on the SDKUserMessage, so the +//! CLI reports the command's lifecycle under it and writes the user line with it: +//! it is the turn id the snapshot shows and the `submittedTurnId` of +//! `freshAgent.send.accepted`. The sidecar forwards the CLI's lifecycle facts +//! ([`SidecarFact`]); they are never client content. +//! +//! C17: the facts feed one pure [`ClaudeTurnLedger`] per session, the busy truth +//! (G08, G23): every command the server sent and the CLI has not ended, the +//! turns the CLI runs (including its own, T19) and its compactions. A result +//! retires every started command, so an absorbed send (T10) ends with the turn +//! that absorbed it, and a send queued behind a turn stays owed until its own +//! result. The session's effective status is derived from it. +//! +//! C18: the ledger also holds the session's Stop latch ([`StopLatch`], design +//! §5.4) and decides each result's [`ResultVerdict`] (§5.3): a Stop that landed +//! on what it targeted is silent, every other turn end rings, and a failed turn +//! also shows why. The server mints every attention edge from these verdicts +//! (`claude_edges.rs`); the sidecar mints none. + +use std::collections::VecDeque; + +use serde_json::Value; + +/// A fresh uuid for one send: the id the CLI keeps for the command and its user +/// line (the canonical lowercase v4 form, as the CLI writes it). +pub(crate) fn mint_send_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// What a send asks the CLI to do: run a turn, or compact (`/compact`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SendKind { + Turn, + Compact, +} + +impl SendKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Turn => "turn", + Self::Compact => "compact", + } + } +} + +/// A sent command's lifecycle state (`sdk.command`, T8). `Replayed` is the +/// sidecar's: the CLI took the command as a user message. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandState { + Queued, + Started, + Completed, + Cancelled, + Discarded, + Replayed, +} + +impl CommandState { + fn parse(state: &str) -> Option { + Some(match state { + "queued" => Self::Queued, + "started" => Self::Started, + "completed" => Self::Completed, + "cancelled" => Self::Cancelled, + "discarded" => Self::Discarded, + "replayed" => Self::Replayed, + _ => return None, + }) + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Started => "started", + Self::Completed => "completed", + Self::Cancelled => "cancelled", + Self::Discarded => "discarded", + Self::Replayed => "replayed", + } + } +} + +/// Whether the CLI is working (`sdk.session.state`, T9). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SessionState { + Running, + Idle, + RequiresAction, +} + +impl SessionState { + fn parse(state: &str) -> Option { + Some(match state { + "running" => Self::Running, + "idle" => Self::Idle, + "requires_action" => Self::RequiresAction, + _ => return None, + }) + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Idle => "idle", + Self::RequiresAction => "requires_action", + } + } +} + +/// What a heartbeat reports progress on (`sdk.heartbeat`, T17). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HeartbeatKind { + Tool, + Task, + Retry, +} + +impl HeartbeatKind { + fn parse(kind: &str) -> Option { + Some(match kind { + "tool" => Self::Tool, + "task" => Self::Task, + "retry" => Self::Retry, + _ => return None, + }) + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Tool => "tool", + Self::Task => "task", + Self::Retry => "retry", + } + } +} + +/// A send the sidecar refused before the CLI saw it (`sdk.send.rejected`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SendRejection { + /// The uuid of the refused send (`None` when the send had no valid one). + pub(crate) uuid: Option, + /// The sidecar's reason code (`INVALID_IMAGE`, `INVALID_SEND`). + pub(crate) code: String, + /// Why, in words the sender can read. + pub(crate) message: String, +} + +/// The reason a rejected send gives when the sidecar named none. +pub(crate) const REJECTED_SEND_FALLBACK_MESSAGE: &str = + "Claude could not take this message. Send it again."; + +/// A fact the sidecar forwards for the server's turn accounting (design §5.1). +/// None of these is client content: they are never broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SidecarFact { + /// A sent command's lifecycle (`sdk.command`). + Command { uuid: String, state: CommandState }, + /// The CLI started or stopped working (`sdk.session.state`). + SessionState(SessionState), + /// A model request started (`sdk.status requesting`): turn evidence. + Requesting, + /// A compaction started (`sdk.status compacting`, T15). + Compacting, + /// A legacy protocol fake's turn status (`sdk.status running|idle`). Only + /// the protocol fakes send it (until C30 retires them); the real sidecar + /// reports the CLI's session state instead. + LegacyStatus(SessionState), + /// A status ended (`sdk.status null`, T15): a compaction's outcome, or the + /// permission mode the CLI is in now. + StatusEnded { + compact_result: Option, + compact_error: Option, + permission_mode: Option, + }, + /// Liveness (`sdk.heartbeat`). + Heartbeat { + kind: HeartbeatKind, + tool_use_id: Option, + elapsed_ms: Option, + }, + /// The sidecar refused a send before the CLI saw it (`sdk.send.rejected`). + SendRejected(SendRejection), +} + +impl SidecarFact { + /// The fact a sidecar stdout line carries, if it carries one. Every status + /// the sidecar reports is a fact: the server publishes the session's status + /// itself (design §5.2), so no `sdk.status` reaches a client as it came. + pub(crate) fn from_line(line: &Value) -> Option { + let text = |key: &str| line.get(key).and_then(Value::as_str).map(str::to_string); + match line.get("type")?.as_str()? { + "sdk.command" => Some(Self::Command { + uuid: text("uuid")?, + state: CommandState::parse(line.get("state")?.as_str()?)?, + }), + "sdk.session.state" => Some(Self::SessionState(SessionState::parse( + line.get("state")?.as_str()?, + )?)), + "sdk.status" => match line.get("status")? { + Value::Null => Some(Self::StatusEnded { + compact_result: text("compactResult"), + compact_error: text("compactError"), + permission_mode: text("permissionMode"), + }), + Value::String(status) => match status.as_str() { + "requesting" => Some(Self::Requesting), + "compacting" => Some(Self::Compacting), + "running" => Some(Self::LegacyStatus(SessionState::Running)), + "idle" => Some(Self::LegacyStatus(SessionState::Idle)), + _ => None, + }, + _ => None, + }, + "sdk.heartbeat" => Some(Self::Heartbeat { + kind: HeartbeatKind::parse(line.get("kind")?.as_str()?)?, + tool_use_id: text("toolUseId"), + elapsed_ms: line + .get("elapsedMs") + .and_then(Value::as_f64) + .filter(|ms| ms.is_finite() && *ms >= 0.0) + .map(|ms| ms.round() as u64), + }), + "sdk.send.rejected" => Some(Self::SendRejected(SendRejection { + uuid: text("uuid"), + code: text("code").unwrap_or_else(|| "INVALID_SEND".to_string()), + message: text("message") + .unwrap_or_else(|| REJECTED_SEND_FALLBACK_MESSAGE.to_string()), + })), + _ => None, + } + } + + /// Log a lifecycle fact at debug (ids and states only). A rejected send is + /// logged where it is handled, with the request it takes back. + pub(crate) fn log(&self, session_id: &str, session_type: &str) { + const TARGET: &str = "freshell_freshagent::claude"; + match self { + Self::Command { uuid, state } => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, uuid = %uuid, + state = state.as_str(), "freshagent.claude.command"), + Self::SessionState(state) => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, state = state.as_str(), + "freshagent.claude.session_state"), + Self::Requesting => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, status = "requesting", + "freshagent.claude.status_fact"), + Self::Compacting => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, status = "compacting", + "freshagent.claude.status_fact"), + Self::LegacyStatus(state) => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, status = state.as_str(), + legacy = true, "freshagent.claude.status_fact"), + Self::StatusEnded { + compact_result, + compact_error, + permission_mode, + } => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, status = "ended", + compact_result = compact_result.as_deref(), + compact_error = compact_error.as_deref(), + permission_mode = permission_mode.as_deref(), + "freshagent.claude.status_fact"), + Self::Heartbeat { + kind, + tool_use_id, + elapsed_ms, + } => tracing::debug!(target: TARGET, + provider = "claude", session_id, session_type, kind = kind.as_str(), + tool_use_id = tool_use_id.as_deref(), elapsed_ms = *elapsed_ms, + "freshagent.claude.heartbeat"), + Self::SendRejected(_) => {} + } + } +} + +// ── the turn ledger (C17) ──────────────────────────────────────────────────── + +/// What the server is shown for a session: the one value status pushes, attach +/// acknowledgements and snapshot overlays all speak (design §5.2). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum EffectiveStatus { + #[default] + Idle, + Running, + Compacting, + Stuck, + Exited, +} + +impl EffectiveStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Running => "running", + Self::Compacting => "compacting", + Self::Stuck => "stuck", + Self::Exited => "exited", + } + } +} + +/// How far the CLI has taken a command the server sent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommandPhase { + /// Armed and written; the CLI has said nothing about it yet. + Accepted, + /// The CLI read it and holds it for later (`queued`). + Queued, + /// The CLI runs it (`started`, or its replay). + Started, +} + +/// One command the CLI owes an end for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LedgerCommand { + /// The uuid the server minted for it ([`mint_send_uuid`]). + pub(crate) uuid: String, + /// The client request that sent it (`None` for a compact, which has no ack). + pub(crate) request_id: Option, + pub(crate) kind: SendKind, + pub(crate) phase: CommandPhase, +} + +/// What one sidecar line tells the ledger (design §5.2's inputs). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TurnFact { + /// A sent command's lifecycle (`sdk.command`). + Command { uuid: String, state: CommandState }, + /// The CLI started or stopped working (`sdk.session.state`; a legacy + /// protocol fake's `sdk.status idle` is its idle). + SessionState(SessionState), + /// The per-turn init; `lifecycle_capable` when it advertises + /// `msg_lifecycle_v1` (T8). It opens no turn (see [`ClaudeTurnLedger::fold`]). + Init { lifecycle_capable: bool }, + /// Work in a turn that is not its content: a model request (a legacy + /// fake's `sdk.status running`). The fallback for a CLI that announces no + /// session state. + TurnEvidence, + /// The turn's own content: assistant text or a stream event (a subagent's + /// output is its parent's tool call, not the turn's content). Turn evidence + /// too, and the "first content" a cold Stop waits for (§5.4 case B). + Content, + /// A compaction started (`sdk.status compacting`). + CompactStarted, + /// A compaction ended (`sdk.status null`, `sdk.compact_boundary`). + CompactEnded, + /// A turn's result (`sdk.result`), with the facts its verdict reads. + Result(ResultFacts), + /// The CLI's receipt for an interrupt (`sdk.interrupt_settled`): `stop_id` + /// echoes the interrupt's (absent from a protocol fake), and `still_queued` + /// lists the sent commands the CLI says will still run (T12, T13; absent + /// when the CLI sends no receipt). + InterruptSettled { + stop_id: Option, + ok: bool, + still_queued: Option>, + }, +} + +impl TurnFact { + /// The fact a sidecar stdout line carries for the ledger, if any. + pub(crate) fn from_line(line: &Value) -> Option { + if let Some(fact) = SidecarFact::from_line(line) { + return match fact { + SidecarFact::Command { uuid, state } => Some(Self::Command { uuid, state }), + SidecarFact::SessionState(state) + | SidecarFact::LegacyStatus(state @ SessionState::Idle) => { + Some(Self::SessionState(state)) + } + SidecarFact::LegacyStatus(_) | SidecarFact::Requesting => Some(Self::TurnEvidence), + SidecarFact::StatusEnded { .. } => Some(Self::CompactEnded), + SidecarFact::Compacting => Some(Self::CompactStarted), + SidecarFact::Heartbeat { .. } | SidecarFact::SendRejected(_) => None, + }; + } + let subagent = || { + ["parentToolUseId", "parent_tool_use_id"] + .iter() + .any(|key| line.get(*key).is_some_and(|parent| parent.is_string())) + }; + match line.get("type")?.as_str()? { + "sdk.session.init" => Some(Self::Init { + lifecycle_capable: line + .get("capabilities") + .and_then(Value::as_array) + .is_some_and(|caps| { + caps.iter() + .any(|cap| cap.as_str() == Some(LIFECYCLE_CAPABILITY)) + }), + }), + "sdk.assistant" | "sdk.stream" if !subagent() => Some(Self::Content), + "sdk.compact_boundary" => Some(Self::CompactEnded), + "sdk.result" => Some(Self::Result(ResultFacts::from_line(line))), + "sdk.interrupt_settled" => Some(Self::InterruptSettled { + stop_id: line + .get("stopId") + .and_then(Value::as_str) + .map(str::to_string), + ok: line.get("ok").and_then(Value::as_bool) == Some(true), + still_queued: line + .get("stillQueued") + .and_then(Value::as_array) + .map(|uuids| { + uuids + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }), + }), + _ => None, + } + } +} + +/// One session's ledger, shared by its handlers and its stdout consumer. Every +/// critical section is synchronous (no await under the lock). +pub(crate) type SharedLedger = std::sync::Arc>; + +/// The init capability that says the CLI reports every sent command's +/// lifecycle (T8). +const LIFECYCLE_CAPABILITY: &str = "msg_lifecycle_v1"; + +/// What a result retired (design §5.2, §5.3): exactly one per result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Settlement { + /// The commands the result ended, in acceptance order. + pub(crate) retired: Vec, + /// No command had started on a CLI that reports no lifecycle, so the oldest + /// armed one was taken for the result's (T8's unknown slash command). + pub(crate) head_retired: bool, + /// Whether the result rings, and whether it failed (§5.3). + pub(crate) verdict: ResultVerdict, + /// The Stop latched when the result arrived, if any. + pub(crate) stop_id: Option, +} + +/// What folding one fact did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Folded { + /// The fact was applied (it may have changed nothing). + Applied, + /// A lifecycle for a uuid the ledger does not hold: ignored. + UnknownCommand, + /// A result, and what it retired. + Settled(Settlement), + /// The fact was applied, and a Stop that missed a command the CLI was + /// still starting must be sent again now (§5.4). + Reissue(StopReissue), + /// The CLI refused the latched Stop's interrupt (`ok: false`): the latch is + /// disarmed, and the turn it targeted keeps running. + StopRefused { stop_id: String }, +} + +/// The busy truth of one claude session (design §5.2): every command the +/// server sent and the CLI has not ended, whether the CLI is working on a turn +/// (any origin, including its own, T19), and whether a compaction runs. Pure +/// and synchronous: the handlers arm and take back commands under the +/// session's turn lock, the stdout consumer folds the sidecar's lines, and the +/// rollback, fork and configure gates read [`Self::busy`]. +#[derive(Debug, Default)] +pub(crate) struct ClaudeTurnLedger { + /// Acceptance order. + commands: VecDeque, + /// The CLI is working on a turn. + turn_open: bool, + compacting: bool, + /// The CLI reports each sent command's lifecycle (a lifecycle frame, or the + /// init's capability): a result that started no command is then not one of + /// the server's. + lifecycle_capable: bool, + /// The status last published for the session. + published: EffectiveStatus, + /// The Stop in force (§5.4), until every command it targeted has ended. + stop: Option, +} + +impl ClaudeTurnLedger { + /// The session owes the client an end: a command is outstanding, a turn is + /// open or a compaction runs. + pub(crate) fn busy(&self) -> bool { + !self.commands.is_empty() || self.turn_open || self.compacting + } + + /// The status the session shows: `exited` for a dead session, else `stuck`, + /// else `compacting`, else `running` while busy, else `idle`. + pub(crate) fn effective_status(&self, dead: bool, stuck: bool) -> EffectiveStatus { + if dead { + EffectiveStatus::Exited + } else if stuck { + EffectiveStatus::Stuck + } else if self.compacting { + EffectiveStatus::Compacting + } else if self.busy() { + EffectiveStatus::Running + } else { + EffectiveStatus::Idle + } + } + + /// The status to publish, when it differs from the last one published (a + /// new ledger has published `idle`); it is then the published one. + pub(crate) fn take_status_change(&mut self, stuck: bool) -> Option { + let status = self.effective_status(false, stuck); + (status != self.published).then(|| { + self.published = status; + status + }) + } + + /// The status last published. + pub(crate) fn published_status(&self) -> EffectiveStatus { + self.published + } + + /// A send the server is about to write (under the turn lock, before the + /// write: the CLI's answer can come before the writer runs again). + pub(crate) fn arm(&mut self, uuid: String, request_id: Option, kind: SendKind) { + self.commands.retain(|command| command.uuid != uuid); + self.commands.push_back(LedgerCommand { + uuid, + request_id, + kind, + phase: CommandPhase::Accepted, + }); + } + + /// Take back a send whose write failed or that the sidecar refused + /// (`sdk.send.rejected`): exactly that command leaves. + pub(crate) fn undo_arm(&mut self, uuid: &str) -> Option { + let at = self + .commands + .iter() + .position(|command| command.uuid == uuid)?; + let command = self.commands.remove(at); + self.stop_targets_ended(&[uuid]); + self.clear_stop_if_spent(); + command + } + + /// Fold one fact from the sidecar. + pub(crate) fn fold(&mut self, fact: &TurnFact) -> Folded { + match fact { + TurnFact::Command { uuid, state } => return self.fold_command(uuid, *state), + TurnFact::SessionState(SessionState::Running | SessionState::RequiresAction) => { + self.turn_open = true; + } + TurnFact::SessionState(SessionState::Idle) => { + // A started command cannot outlive an idle; one the CLI has not + // reported may have been pushed just as the CLI went idle (T9). + self.turn_open = false; + self.compacting = false; + let ended: Vec = self + .commands + .iter() + .filter(|command| command.phase == CommandPhase::Started) + .map(|command| command.uuid.clone()) + .collect(); + self.commands + .retain(|command| command.phase != CommandPhase::Started); + let ended: Vec<&str> = ended.iter().map(String::as_str).collect(); + self.stop_targets_ended(&ended); + if let Some(stop) = self.stop.as_mut() { + stop.cli_turn = false; + } + self.clear_stop_if_spent(); + } + TurnFact::Init { lifecycle_capable } => { + // Not turn evidence: every real turn has opened by its init (its + // command started, or the session state said running), and the + // protocol fakes announce an init at create, when no turn runs. + self.lifecycle_capable |= lifecycle_capable; + } + TurnFact::TurnEvidence => self.turn_open = true, + TurnFact::Content => { + self.turn_open = true; + let only_started = self.only_started_command().map(str::to_string); + if let Some(stop) = self.stop.as_mut() { + if stop.awaiting_start.is_some() + && only_started.as_deref() == stop.awaiting_start.as_deref() + { + stop.target_content = true; + } + } + return self.evaluate_stop(); + } + TurnFact::CompactStarted => self.compacting = true, + TurnFact::CompactEnded => self.compacting = false, + TurnFact::Result(facts) => return Folded::Settled(self.settle(facts)), + TurnFact::InterruptSettled { + stop_id, + ok, + still_queued, + } => return self.fold_receipt(stop_id.as_deref(), *ok, still_queued.as_deref()), + } + Folded::Applied + } + + fn fold_command(&mut self, uuid: &str, state: CommandState) -> Folded { + if state != CommandState::Replayed { + self.lifecycle_capable = true; + } + let Some(at) = self + .commands + .iter() + .position(|command| command.uuid == uuid) + else { + return Folded::UnknownCommand; + }; + match state { + CommandState::Queued => { + let command = &mut self.commands[at]; + if command.phase == CommandPhase::Accepted { + command.phase = CommandPhase::Queued; + } + } + CommandState::Started | CommandState::Replayed => { + self.commands[at].phase = CommandPhase::Started; + self.turn_open = true; + } + CommandState::Completed | CommandState::Cancelled | CommandState::Discarded => { + self.commands.remove(at); + self.stop_targets_ended(&[uuid]); + self.clear_stop_if_spent(); + return Folded::Applied; + } + } + if let Some(stop) = self.stop.as_mut() { + if stop.awaiting_start.as_deref() == Some(uuid) { + if stop.receipt == StopReceipt::Pending { + stop.target_lifecycle_before_receipt = true; + } + if matches!(state, CommandState::Started | CommandState::Replayed) { + stop.target_started = true; + } + if state == CommandState::Replayed { + stop.target_content = true; + } + } + } + self.evaluate_stop() + } + + /// A result ends the turn: it retires every started command (one turn runs + /// at a time, and absorbed commands start inside it, T10), so it rings at + /// most once and its trailers (the lifecycle's end, the idle) find nothing. + /// Its verdict is decided against the Stop latched when it arrived. + fn settle(&mut self, facts: &ResultFacts) -> Settlement { + let (retired, kept): (VecDeque<_>, VecDeque<_>) = std::mem::take(&mut self.commands) + .into_iter() + .partition(|command| command.phase == CommandPhase::Started); + self.commands = kept; + let mut retired: Vec = retired.into(); + let mut head_retired = false; + if retired.is_empty() && !self.lifecycle_capable { + if let Some(head) = self.commands.pop_front() { + retired.push(head); + head_retired = true; + } + } + self.turn_open = false; + self.compacting = false; + let verdict = ResultVerdict::decide(facts, &retired, self.stop.as_ref()); + let stop_id = self.stop.as_ref().map(|stop| stop.stop_id.clone()); + let ended: Vec<&str> = retired + .iter() + .map(|command| command.uuid.as_str()) + .collect(); + self.stop_targets_ended(&ended); + if let Some(stop) = self.stop.as_mut() { + stop.cli_turn = false; + } + self.clear_stop_if_spent(); + Settlement { + retired, + head_retired, + verdict, + stop_id, + } + } + + /// The sidecar's output ended: whether anything was owed, and the ledger + /// is cleared (the CLI and its queue are gone, and so is any Stop). + pub(crate) fn eof(&mut self) -> bool { + let owed = self.busy(); + self.clear(); + owed + } + + fn clear(&mut self) { + self.commands.clear(); + self.turn_open = false; + self.compacting = false; + self.stop = None; + } + + // ── the Stop latch (C18, design §5.4) ──────────────────────────────────── + + /// The user's Stop. A session with nothing owed has nothing to stop: `None`, + /// and nothing is sent. Otherwise the Stop targets one thing: the running + /// turn's commands; else the oldest command the CLI has not started (it may + /// still be starting, T13: the latch waits for it); else the turn the CLI + /// runs by itself. Commands queued behind the running turn are not targets + /// (decision 1): they run and ring. A Stop while another is latched keeps + /// the earlier one's targets, so everything the user stopped stays silent. + pub(crate) fn stop(&mut self, stop_id: String) -> Option { + if !self.busy() { + return None; + } + let started: Vec = self + .commands + .iter() + .filter(|command| command.phase == CommandPhase::Started) + .map(|command| command.uuid.clone()) + .collect(); + let (target, commands, awaiting_start) = if !started.is_empty() { + (StopTargetKind::Running, started, None) + } else if let Some(head) = self.commands.front() { + ( + StopTargetKind::NotStarted, + vec![head.uuid.clone()], + Some(head.uuid.clone()), + ) + } else { + (StopTargetKind::CliTurn, Vec::new(), None) + }; + let earlier = self.stop.take(); + let mut latch = StopLatch { + stop_id: stop_id.clone(), + commands: Vec::new(), + cli_turn: target == StopTargetKind::CliTurn, + awaiting_start: awaiting_start.clone(), + receipt: StopReceipt::Pending, + target_lifecycle_before_receipt: false, + target_started: false, + target_content: false, + reissues: 0, + }; + if let Some(earlier) = earlier { + latch.commands = earlier.commands; + latch.cli_turn |= earlier.cli_turn; + latch.reissues = earlier.reissues; + } + for uuid in &commands { + if !latch.commands.contains(uuid) { + latch.commands.push(uuid.clone()); + } + } + let armed = StopArmed { + stop_id, + target, + commands: latch.commands.clone(), + }; + self.stop = Some(latch); + Some(armed) + } + + /// The interrupt for `stop_id` could not be written: the Stop never reached + /// the CLI, so the latch is disarmed (a later Stop's latch is kept). + pub(crate) fn stop_unsent(&mut self, stop_id: &str) { + if self + .stop + .as_ref() + .is_some_and(|stop| stop.stop_id == stop_id) + { + self.stop = None; + } + } + + /// A Stop is latched. + #[cfg(test)] + pub(crate) fn stop_latched(&self) -> bool { + self.stop.is_some() + } + + /// The CLI's receipt for an interrupt. Only the latch's own receipt counts + /// (a re-sent interrupt's, or an earlier Stop's, is not); a protocol fake + /// echoes no stop id, so its receipt is the pending latch's. + fn fold_receipt( + &mut self, + stop_id: Option<&str>, + ok: bool, + still_queued: Option<&[String]>, + ) -> Folded { + let Some(stop) = self.stop.as_mut() else { + return Folded::Applied; + }; + let ours = match stop_id { + Some(id) => id == stop.stop_id, + None => stop.receipt == StopReceipt::Pending, + }; + if !ours || stop.receipt != StopReceipt::Pending { + return Folded::Applied; + } + if !ok { + let stop_id = stop.stop_id.clone(); + self.stop = None; + return Folded::StopRefused { stop_id }; + } + let lists_target = stop.awaiting_start.as_ref().is_some_and(|target| { + still_queued.is_some_and(|queued| queued.iter().any(|uuid| uuid == target)) + }); + stop.receipt = StopReceipt::Received { lists_target }; + self.evaluate_stop() + } + + /// Whether the latched Stop must be sent again now (§5.4): it waits for a + /// command the CLI had not started, the command is still owed, and: + /// - case A (the receipt lists the command, or came before any of its + /// lifecycle): both the receipt and the command's start have been seen; + /// - case B (an empty receipt after its lifecycle: the interrupt may have + /// landed on it): its first content has been seen. + fn evaluate_stop(&mut self) -> Folded { + let owed = |uuid: &str| self.commands.iter().any(|command| command.uuid == uuid); + let Some(stop) = self.stop.as_ref() else { + return Folded::Applied; + }; + let Some(target) = stop.awaiting_start.clone() else { + return Folded::Applied; + }; + if !owed(&target) || stop.reissues >= MAX_STOP_REISSUES { + return Folded::Applied; + } + let StopReceipt::Received { lists_target } = stop.receipt else { + return Folded::Applied; + }; + let case = if lists_target || !stop.target_lifecycle_before_receipt { + ReissueCase::A + } else { + ReissueCase::B + }; + let due = match case { + ReissueCase::A => stop.target_started, + ReissueCase::B => stop.target_content, + }; + if !due { + return Folded::Applied; + } + let stop = self.stop.as_mut().expect("checked above"); + stop.reissues += 1; + stop.awaiting_start = None; + Folded::Reissue(StopReissue { + stop_id: stop.stop_id.clone(), + reissue_id: format!("{}:{}", stop.stop_id, stop.reissues), + case, + target, + reissues: stop.reissues, + }) + } + + /// `ended` commands are no longer owed: they leave the latch's targets. + fn stop_targets_ended(&mut self, ended: &[&str]) { + let Some(stop) = self.stop.as_mut() else { + return; + }; + stop.commands.retain(|uuid| !ended.contains(&uuid.as_str())); + if stop + .awaiting_start + .as_deref() + .is_some_and(|target| ended.contains(&target)) + { + stop.awaiting_start = None; + } + } + + /// The latch clears once nothing it targeted is left. + fn clear_stop_if_spent(&mut self) { + if self + .stop + .as_ref() + .is_some_and(|stop| stop.commands.is_empty() && !stop.cli_turn) + { + self.stop = None; + } + } + + /// The one started command, when exactly one has started. + fn only_started_command(&self) -> Option<&str> { + let mut started = self + .commands + .iter() + .filter(|command| command.phase == CommandPhase::Started); + let first = started.next()?; + started.next().is_none().then_some(first.uuid.as_str()) + } + + /// The outstanding commands' uuids, in acceptance order. + #[cfg(test)] + pub(crate) fn outstanding(&self) -> impl Iterator { + self.commands.iter().map(|command| command.uuid.as_str()) + } +} + +// ── the Stop latch and the result verdict (C18, design §5.3, §5.4) ────────── + +/// A Stop re-sends its interrupt at most this many times in all (a re-send +/// ends the wait for its command, so one Stop re-sends at most once; the cap +/// bounds a run of Stops that keep one latch). +pub(crate) const MAX_STOP_REISSUES: u8 = 2; + +/// What a Stop targets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StopTargetKind { + /// The running turn's commands. + Running, + /// The oldest command the CLI had not started (it may still be starting). + NotStarted, + /// A turn the CLI runs by itself (no command of the server's runs). + CliTurn, +} + +impl StopTargetKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::NotStarted => "not_started", + Self::CliTurn => "cli_turn", + } + } +} + +/// A Stop the ledger latched: what the interrupt is written for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StopArmed { + pub(crate) stop_id: String, + pub(crate) target: StopTargetKind, + /// Every command the latch now targets (an earlier latched Stop's too). + pub(crate) commands: Vec, +} + +/// The interrupt's receipt, as far as the latch has seen it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StopReceipt { + Pending, + /// `lists_target`: the receipt lists the command the latch waits for among + /// the ones that will still run. + Received { + lists_target: bool, + }, +} + +/// Which re-send rule fired (§5.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReissueCase { + /// The receipt lists the command, or came before its lifecycle: re-sent at + /// its start. + A, + /// An empty receipt after its lifecycle: re-sent on its first content. + B, +} + +impl ReissueCase { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::A => "A", + Self::B => "B", + } + } +} + +/// A Stop to send again: the command it missed now runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StopReissue { + /// The latched Stop. + pub(crate) stop_id: String, + /// The re-sent interrupt's own id (its receipt is not the latch's). + pub(crate) reissue_id: String, + pub(crate) case: ReissueCase, + /// The command the Stop missed. + pub(crate) target: String, + /// How many times the latch has re-sent, this one included. + pub(crate) reissues: u8, +} + +/// The Stop in force for a session (§5.4). +#[derive(Clone, Debug, PartialEq, Eq)] +struct StopLatch { + stop_id: String, + /// The commands it targets; a result that retires only these is silent. + commands: Vec, + /// It targets a turn the CLI runs by itself (a result retiring nothing). + cli_turn: bool, + /// The command the CLI had not started at the Stop, while the latch still + /// waits to see whether the interrupt missed it. + awaiting_start: Option, + receipt: StopReceipt, + /// That command's queued, started or replay came before the receipt. + target_lifecycle_before_receipt: bool, + /// That command started (or was replayed). + target_started: bool, + /// That command's first content was seen (its replay, or the turn's own + /// assistant or stream frame while it is the only started command). + target_content: bool, + reissues: u8, +} + +/// The banner of a turn a Stop did not end but that the CLI aborted. +pub(crate) const ABORTED_TURN_MESSAGE: &str = "Claude stopped the turn before it finished."; +/// The banner of a failed turn whose kind has no copy of its own. +pub(crate) const DEFAULT_TURN_FAILURE_MESSAGE: &str = + "Claude could not complete this turn. Try sending your message again."; + +/// The banner for a failed result of `subtype` that says nothing itself. +fn subtype_failure_message(subtype: &str) -> &'static str { + match subtype { + "error_max_turns" => "Claude reached its turn limit. Send a message to continue.", + "error_max_budget_usd" => "Claude reached the configured spending limit.", + "error_max_structured_output_retries" => { + "Claude could not produce the requested response format." + } + _ => DEFAULT_TURN_FAILURE_MESSAGE, + } +} + +/// The `terminal_reason`s of a turn that was aborted (T11). +const ABORTED_TERMINAL_REASONS: [&str; 2] = ["aborted_streaming", "aborted_tools"]; + +/// What a result says about its turn (`sdk.result`, design §5.1). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct ResultFacts { + /// The result's subtype (`success`, `error_during_execution`, ...). A + /// protocol fake names it in `result` instead. + pub(crate) subtype: String, + pub(crate) is_error: bool, + pub(crate) terminal_reason: Option, + pub(crate) num_turns: Option, + /// The CLI's own error text, without its diagnostics. + pub(crate) error_text: Option, +} + +impl ResultFacts { + pub(crate) fn from_line(line: &Value) -> Self { + let text = |key: &str| { + line.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) + }; + let subtype = text("subtype") + .or_else(|| text("result")) + .unwrap_or_else(|| "success".to_string()); + Self { + is_error: line + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(subtype != "success"), + terminal_reason: text("terminalReason"), + num_turns: line.get("numTurns").and_then(Value::as_u64), + error_text: text("errorText"), + subtype, + } + } + + /// The CLI aborted the turn: its terminal reason says so, or (an older CLI + /// that reports none) it ended `error_during_execution` after running a turn + /// while a Stop was latched. A boot refusal ran no turn (T5), so it is never + /// a stopped turn. + fn aborted(&self, stop_latched: bool) -> bool { + match self.terminal_reason.as_deref() { + Some(reason) => ABORTED_TERMINAL_REASONS.contains(&reason), + None => { + stop_latched + && self.subtype == "error_during_execution" + && self.num_turns.is_some_and(|turns| turns > 0) + } + } + } +} + +/// How a turn ended, for in-process observers (design §9.3 I3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TurnOutcome { + /// It ran to a successful result. + Completed, + /// It ended with an error (a failed subtype, or an API error). + Failed, + /// The user's Stop ended it. + Interrupted, +} + +/// What a result means for the user (§5.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ResultVerdict { + /// The user's Stop landed on what it targeted: no edge, no banner. + Silent, + /// The turn failed: it rings, and the banner says why. + Failed { message: String }, + /// The turn ended (an API error too, which the transcript shows): it rings. + Completed { is_error: bool }, +} + +impl ResultVerdict { + /// Silent only for an aborted result that retired nothing but what the + /// latched Stop targeted (or, retiring nothing, ended the CLI's own turn it + /// targeted). A command that started after the Stop, or another device's + /// that a mistaken re-send aborted, rings. + fn decide(facts: &ResultFacts, retired: &[LedgerCommand], stop: Option<&StopLatch>) -> Self { + let aborted = facts.aborted(stop.is_some()); + let landed = stop.is_some_and(|stop| { + if retired.is_empty() { + stop.cli_turn + } else { + retired + .iter() + .all(|command| stop.commands.contains(&command.uuid)) + } + }); + if aborted && landed { + Self::Silent + } else if facts.subtype != "success" { + let message = facts.error_text.clone().unwrap_or_else(|| { + if aborted { + ABORTED_TURN_MESSAGE.to_string() + } else { + subtype_failure_message(&facts.subtype).to_string() + } + }); + Self::Failed { message } + } else { + Self::Completed { + is_error: facts.is_error, + } + } + } + + /// The result rings the attention edge. + pub(crate) fn rings(&self) -> bool { + !matches!(self, Self::Silent) + } + + /// The failure banner the result shows, if any. + pub(crate) fn banner(&self) -> Option<&str> { + match self { + Self::Failed { message } => Some(message), + _ => None, + } + } + + pub(crate) fn outcome(&self) -> TurnOutcome { + match self { + Self::Silent => TurnOutcome::Interrupted, + Self::Failed { .. } | Self::Completed { is_error: true } => TurnOutcome::Failed, + Self::Completed { is_error: false } => TurnOutcome::Completed, + } + } + + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Silent => "silent", + Self::Failed { .. } => "failed", + Self::Completed { is_error: true } => "completed_with_error", + Self::Completed { is_error: false } => "completed", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_send_uuid_is_a_fresh_v4_uuid() { + let first = mint_send_uuid(); + let parsed = uuid::Uuid::parse_str(&first).expect("a uuid"); + assert_eq!(parsed.get_version_num(), 4); + assert_eq!( + first, + parsed.hyphenated().to_string(), + "the canonical lowercase form" + ); + assert_ne!(first, mint_send_uuid()); + } + + #[test] + fn command_lifecycle_facts_parse() { + for (state, expected) in [ + ("queued", CommandState::Queued), + ("started", CommandState::Started), + ("completed", CommandState::Completed), + ("cancelled", CommandState::Cancelled), + ("discarded", CommandState::Discarded), + ("replayed", CommandState::Replayed), + ] { + assert_eq!( + SidecarFact::from_line(&json!({ + "type": "sdk.command", "sessionId": "s", "uuid": "u", "state": state, + })), + Some(SidecarFact::Command { + uuid: "u".into(), + state: expected, + }), + ); + } + // A state this server does not know is not a fact it can fold. + assert_eq!( + SidecarFact::from_line( + &json!({ "type": "sdk.command", "uuid": "u", "state": "paused" }) + ), + None + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.command", "state": "queued" })), + None + ); + } + + #[test] + fn session_state_facts_parse() { + for (state, expected) in [ + ("running", SessionState::Running), + ("idle", SessionState::Idle), + ("requires_action", SessionState::RequiresAction), + ] { + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.session.state", "state": state })), + Some(SidecarFact::SessionState(expected)), + ); + } + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.session.state", "state": "asleep" })), + None + ); + } + + #[test] + fn every_status_the_sidecar_reports_is_a_fact() { + assert_eq!( + SidecarFact::from_line(&json!({ + "type": "sdk.status", "status": null, "compactResult": "failed", + "compactError": "Not enough messages to compact.", "permissionMode": "plan", + })), + Some(SidecarFact::StatusEnded { + compact_result: Some("failed".into()), + compact_error: Some("Not enough messages to compact.".into()), + permission_mode: Some("plan".into()), + }), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status", "status": null })), + Some(SidecarFact::StatusEnded { + compact_result: None, + compact_error: None, + permission_mode: None, + }), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status", "status": "requesting" })), + Some(SidecarFact::Requesting), + ); + // C17: the server publishes the session's status itself, so the + // statuses the sidecar reports are facts too, never client content. + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status", "status": "compacting" })), + Some(SidecarFact::Compacting), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status", "status": "running" })), + Some(SidecarFact::LegacyStatus(SessionState::Running)), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status", "status": "idle" })), + Some(SidecarFact::LegacyStatus(SessionState::Idle)), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.status" })), + None + ); + } + + #[test] + fn heartbeat_facts_parse() { + assert_eq!( + SidecarFact::from_line(&json!({ + "type": "sdk.heartbeat", "kind": "tool", "toolUseId": "toolu_1", "elapsedMs": 30000, + })), + Some(SidecarFact::Heartbeat { + kind: HeartbeatKind::Tool, + tool_use_id: Some("toolu_1".into()), + elapsed_ms: Some(30_000), + }), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.heartbeat", "kind": "retry" })), + Some(SidecarFact::Heartbeat { + kind: HeartbeatKind::Retry, + tool_use_id: None, + elapsed_ms: None, + }), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.heartbeat", "kind": "task" })), + Some(SidecarFact::Heartbeat { + kind: HeartbeatKind::Task, + tool_use_id: None, + elapsed_ms: None, + }), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.heartbeat", "kind": "pulse" })), + None + ); + } + + #[test] + fn a_rejected_send_parses_with_or_without_its_uuid() { + assert_eq!( + SidecarFact::from_line(&json!({ + "type": "sdk.send.rejected", "sessionId": "s", "uuid": "u", + "code": "INVALID_IMAGE", "message": "Claude cannot read image/svg+xml as an image.", + })), + Some(SidecarFact::SendRejected(SendRejection { + uuid: Some("u".into()), + code: "INVALID_IMAGE".into(), + message: "Claude cannot read image/svg+xml as an image.".into(), + })), + ); + assert_eq!( + SidecarFact::from_line(&json!({ "type": "sdk.send.rejected", "uuid": null })), + Some(SidecarFact::SendRejected(SendRejection { + uuid: None, + code: "INVALID_SEND".into(), + message: REJECTED_SEND_FALLBACK_MESSAGE.into(), + })), + ); + } + + #[test] + fn content_and_control_lines_are_not_facts() { + for line in [ + json!({ "type": "sdk.result", "subtype": "success" }), + json!({ "type": "sdk.assistant", "content": [] }), + json!({ "type": "sdk.turn.complete", "at": 1 }), + json!({ "type": "sdk.interrupt_settled", "ok": true, "stillQueued": [] }), + json!({ "type": "created", "requestId": "r" }), + ] { + assert_eq!(SidecarFact::from_line(&line), None, "{line}"); + } + } +} + +#[cfg(test)] +#[path = "claude_turns_ledger_tests.rs"] +mod ledger_tests; + +#[cfg(test)] +#[path = "claude_turns_stop_tests.rs"] +mod stop_tests; diff --git a/crates/freshell-freshagent/src/claude_turns_ledger_tests.rs b/crates/freshell-freshagent/src/claude_turns_ledger_tests.rs new file mode 100644 index 000000000..0e45d0aa0 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_turns_ledger_tests.rs @@ -0,0 +1,1461 @@ +//! [`ClaudeTurnLedger`] tests (design §5.2, §11.2; C17). +//! +//! The table tests replay the pinned CLI's recorded traces (scratch +//! `design-probe` H I J K L M N and the `final-probe` CLI-initiated turn) as the +//! lines the sidecar forwards for them, with the server's arms where the server +//! makes them (before the send is written). The property tests drive a model of +//! the CLI that keeps T8-T10 (per-command lifecycle, session state brackets, +//! sends absorbed into a tool call versus queued behind streamed text) through +//! random interleavings of sends and CLI frames: the ledger is busy exactly when +//! the CLI owes work, and every result is settled exactly once. + +use std::collections::{HashSet, VecDeque}; + +use serde_json::{json, Value}; + +use super::*; + +// ── line builders: what the sidecar forwards ───────────────────────────────── + +fn command(uuid: &str, state: &str) -> Value { + json!({ "type": "sdk.command", "sessionId": "s", "uuid": uuid, "state": state }) +} + +fn session_state(state: &str) -> Value { + json!({ "type": "sdk.session.state", "sessionId": "s", "state": state }) +} + +fn init() -> Value { + json!({ + "type": "sdk.session.init", "sessionId": "s", "cliSessionId": "c", + "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1"], + }) +} + +fn init_without_lifecycle() -> Value { + json!({ "type": "sdk.session.init", "sessionId": "s", "cliSessionId": "c" }) +} + +fn requesting() -> Value { + json!({ "type": "sdk.status", "sessionId": "s", "status": "requesting" }) +} + +fn compacting() -> Value { + json!({ "type": "sdk.status", "sessionId": "s", "status": "compacting" }) +} + +fn status_ended() -> Value { + json!({ "type": "sdk.status", "sessionId": "s", "status": null, "compactResult": "success" }) +} + +fn boundary() -> Value { + json!({ "type": "sdk.compact_boundary", "sessionId": "s", "trigger": "auto" }) +} + +fn assistant() -> Value { + json!({ "type": "sdk.assistant", "sessionId": "s", "content": [{ "type": "text", "text": "ok" }] }) +} + +fn stream() -> Value { + json!({ "type": "sdk.stream", "sessionId": "s", "event": { "type": "content_block_delta" } }) +} + +fn result(subtype: &str) -> Value { + json!({ "type": "sdk.result", "sessionId": "s", "subtype": subtype, "isError": subtype != "success" }) +} + +fn heartbeat() -> Value { + json!({ "type": "sdk.heartbeat", "sessionId": "s", "kind": "task" }) +} + +/// One ledger driven the way the stdout consumer drives it: arms from the +/// handlers, folds from the sidecar's lines, with every settlement recorded. +#[derive(Default)] +struct Trace { + ledger: ClaudeTurnLedger, + /// The uuids each result retired, in result order. + settlements: Vec>, + /// The results that retired by the old-CLI head fallback. + head_retires: usize, +} + +impl Trace { + fn arm(&mut self, uuid: &str) -> &mut Self { + self.ledger.arm( + uuid.to_string(), + Some(format!("request-{uuid}")), + SendKind::Turn, + ); + self + } + + fn arm_compact(&mut self, uuid: &str) -> &mut Self { + self.ledger.arm(uuid.to_string(), None, SendKind::Compact); + self + } + + fn feed(&mut self, line: Value) -> &mut Self { + if let Some(fact) = TurnFact::from_line(&line) { + if let Folded::Settled(settlement) = self.ledger.fold(&fact) { + if settlement.head_retired { + self.head_retires += 1; + } + self.settlements.push( + settlement + .retired + .iter() + .map(|command| command.uuid.clone()) + .collect(), + ); + } + } + self + } + + fn feed_all(&mut self, lines: impl IntoIterator) -> &mut Self { + for line in lines { + self.feed(line); + } + self + } + + fn busy(&self) -> bool { + self.ledger.busy() + } + + fn status(&self) -> &'static str { + self.ledger.effective_status(false, false).as_str() + } + + fn outstanding(&self) -> Vec<&str> { + self.ledger.outstanding().collect() + } +} + +// ── recorded traces ────────────────────────────────────────────────────────── + +/// H (warm interrupt during streamed text): the aborted result settles the +/// command; its trailing `cancelled` and the idle change nothing. +#[test] +fn trace_h_a_warm_interrupt_settles_at_the_aborted_result() { + let mut t = Trace::default(); + t.arm("u"); + assert!(t.busy(), "busy from the arm, before the CLI says anything"); + assert_eq!(t.status(), "running"); + t.feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + init(), + requesting(), + command("u", "replayed"), + assistant(), + ]); + assert!(t.busy()); + t.feed(result("error_during_execution")); + assert!(!t.busy(), "the stopped turn ended at its result"); + assert_eq!(t.settlements, vec![vec!["u".to_string()]]); + t.feed_all([command("u", "cancelled"), session_state("idle")]); + assert!(!t.busy()); + assert_eq!(t.settlements.len(), 1, "the trailers settle nothing"); + assert_eq!(t.status(), "idle"); +} + +/// I (cold Stop: the receipt came back first and the command still ran to +/// success): one settlement, at its success result. +#[test] +fn trace_i_a_command_that_ran_after_a_cold_stop_settles_once() { + let mut t = Trace::default(); + t.arm("u").feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + init(), + requesting(), + command("u", "replayed"), + assistant(), + ]); + assert!(t.busy()); + t.feed(result("success")); + assert!(!t.busy()); + t.feed_all([command("u", "completed"), session_state("idle")]); + assert_eq!(t.settlements, vec![vec!["u".to_string()]]); + assert!(!t.busy()); +} + +/// J (a send absorbed into a tool call, T10): both commands start inside the one +/// turn, the absorbed one completes before the result, the first after it. One +/// result settles the turn and nothing is left owed: an absorbed send never +/// wedges the session busy (G08). +#[test] +fn trace_j_an_absorbed_send_leaves_the_session_idle_after_the_one_result() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + init(), + requesting(), + command("a", "replayed"), + assistant(), + ]); + // The second send, pushed during the tool call. + t.arm("b").feed(command("b", "queued")); + assert_eq!(t.outstanding(), vec!["a", "b"]); + t.feed_all([ + heartbeat(), + command("b", "replayed"), + command("b", "started"), + requesting(), + assistant(), + command("b", "completed"), + ]); + assert!(t.busy(), "the turn runs until its result"); + assert_eq!(t.outstanding(), vec!["a"], "b ended with its own lifecycle"); + t.feed(result("success")); + assert!(!t.busy(), "one result settles the absorbed turn"); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + t.feed_all([command("a", "completed"), session_state("idle")]); + assert!(!t.busy()); + assert_eq!(t.settlements.len(), 1); + assert_eq!(t.status(), "idle"); +} + +/// J with the absorbed command's `completed` after the result: the result +/// retires every started command (only one turn runs, and absorbed commands +/// start inside it), so the late lifecycle finds nothing. +#[test] +fn an_absorbed_command_still_started_at_the_result_is_retired_by_it() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + assistant(), + ]); + t.arm("b").feed_all([ + command("b", "queued"), + command("b", "replayed"), + command("b", "started"), + assistant(), + ]); + t.feed(result("success")); + assert!(!t.busy()); + assert_eq!(t.settlements, vec![vec!["a".to_string(), "b".to_string()]]); + t.feed_all([command("b", "completed"), command("a", "completed")]); + assert_eq!(t.settlements.len(), 1); + assert!(!t.busy()); +} + +/// K (cold Stop re-sent once the command started): the aborted result settles +/// the command. +#[test] +fn trace_k_a_re_sent_cold_stop_settles_at_the_aborted_result() { + let mut t = Trace::default(); + t.arm("u").feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + init(), + requesting(), + command("u", "replayed"), + ]); + assert!(t.busy()); + t.feed(result("error_during_execution")); + assert!(!t.busy()); + t.feed_all([command("u", "cancelled"), session_state("idle")]); + assert_eq!(t.settlements, vec![vec!["u".to_string()]]); +} + +/// L (a send pushed during streamed text queues as its own turn, T10): the +/// first result settles only the first command; the session stays busy for the +/// queued one (the client's `sessionBusy`), which its own result settles. The +/// CLI announces no idle between the two turns (T9). +#[test] +fn trace_l_a_queued_send_keeps_the_session_busy_between_its_results() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + init(), + requesting(), + command("a", "replayed"), + ]); + t.arm("b").feed_all([command("b", "queued"), assistant()]); + t.feed(result("success")); + assert!(t.busy(), "b is still owed after a's result"); + assert_eq!(t.status(), "running"); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + t.feed_all([ + command("a", "completed"), + command("b", "started"), + init(), + requesting(), + command("b", "replayed"), + assistant(), + ]); + assert!(t.busy()); + t.feed(result("success")); + assert!(!t.busy()); + t.feed_all([command("b", "completed"), session_state("idle")]); + assert_eq!( + t.settlements, + vec![vec!["a".to_string()], vec!["b".to_string()]] + ); + assert_eq!(t.status(), "idle"); +} + +/// M (`/compact` is an ordinary command with one result, T15): the session +/// reads `compacting` while the compaction runs, `running` after its status +/// ends, and the compact's own result settles it. +#[test] +fn trace_m_a_compact_is_a_command_with_its_own_result() { + let mut t = Trace::default(); + t.arm("seed").feed_all([ + // A replay of a uuid the server never sent (the CLI's local-command + // output) is not forwarded by the sidecar; one that slips through is + // ignored. + command("never-sent", "replayed"), + session_state("running"), + command("seed", "queued"), + command("seed", "started"), + init(), + requesting(), + command("seed", "replayed"), + assistant(), + result("success"), + command("seed", "completed"), + session_state("idle"), + ]); + assert!(!t.busy()); + t.arm_compact("c").feed_all([ + session_state("running"), + command("c", "queued"), + command("c", "started"), + ]); + assert_eq!(t.status(), "running"); + t.feed(compacting()); + assert_eq!(t.status(), "compacting"); + t.feed(json!({ + "type": "sdk.status", "sessionId": "s", "status": null, "compactResult": "failed", + "compactError": "Not enough messages to compact.", + })); + assert_eq!( + t.status(), + "running", + "the compaction ended; the command runs on" + ); + t.feed_all([init(), assistant(), command("c", "replayed")]); + assert!(t.busy()); + t.feed(result("success")); + assert!(!t.busy()); + t.feed_all([command("c", "completed"), session_state("idle")]); + assert_eq!( + t.settlements, + vec![vec!["seed".to_string()], vec!["c".to_string()]] + ); +} + +/// N (the CLI is killed mid-turn, T14): no result arrives; the end of the +/// sidecar's output finds the turn owed, and clears everything. +#[test] +fn trace_n_a_cli_death_mid_turn_is_owed_at_the_end_of_output() { + let mut t = Trace::default(); + t.arm("u").feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + init(), + requesting(), + command("u", "replayed"), + assistant(), + ]); + assert!(t.ledger.eof(), "the turn was owed"); + assert!(!t.busy()); + assert!(t.outstanding().is_empty()); + assert!(!t.ledger.eof(), "nothing is owed twice"); +} + +/// The final-probe CLI-initiated turn (T9, T19): a background agent's completion +/// starts a turn with no uuid and no lifecycle. The session is busy from its +/// `running` until its result, which settles no command. +#[test] +fn the_cli_initiated_turn_is_busy_from_its_running_to_its_result() { + let mut t = Trace::default(); + t.arm("u1").feed_all([ + session_state("running"), + command("u1", "queued"), + command("u1", "started"), + init(), + requesting(), + command("u1", "replayed"), + stream(), + assistant(), + heartbeat(), + stream(), + requesting(), + stream(), + assistant(), + stream(), + ]); + t.feed(result("success")); + assert!(!t.busy()); + t.feed_all([ + command("u1", "completed"), + session_state("idle"), + heartbeat(), + ]); + assert!(!t.busy(), "a background task's progress is not a turn"); + t.feed(session_state("running")); + assert!(t.busy(), "the CLI started a turn of its own"); + assert_eq!(t.status(), "running"); + t.feed_all([init(), requesting(), stream(), assistant(), stream()]); + assert!(t.busy()); + t.feed(result("success")); + assert!(!t.busy()); + t.feed(session_state("idle")); + assert_eq!( + t.settlements, + vec![vec!["u1".to_string()], Vec::::new()], + "the CLI's own turn settles with nothing retired" + ); + assert_eq!(t.head_retires, 0); +} + +// ── the rules one at a time ────────────────────────────────────────────────── + +/// An armed command is owed before the CLI says anything about it: no send can +/// hide in a queue the server does not see (the property the deleted rollback +/// quiesce probe protected). +#[test] +fn an_armed_command_is_owed_before_the_cli_sees_it() { + let mut t = Trace::default(); + t.arm_compact("c1"); + assert!(t.busy()); + assert_eq!(t.status(), "running"); + t.arm("s1").arm_compact("c2"); + assert_eq!(t.outstanding(), vec!["c1", "s1", "c2"]); +} + +/// A session idle ends the turn and every started command (a started command +/// cannot outlive an idle) and any compaction; a command the CLI has not +/// reported yet stays owed, because a send just pushed can race an idle (T9). +#[test] +fn an_idle_ends_the_turn_and_every_started_command_but_not_a_just_pushed_send() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + compacting(), + ]); + t.arm("late"); + t.feed(session_state("idle")); + assert_eq!(t.outstanding(), vec!["late"]); + assert!(t.busy(), "the just-pushed send is still owed"); + assert_eq!(t.status(), "running", "the compaction ended with the idle"); + assert!(t.settlements.is_empty(), "an idle is not a result"); +} + +/// The legacy protocol fakes' `sdk.status idle` is the session idle: it releases +/// a turn whose result already came (the deleted legacy-idle test's property). +#[test] +fn a_legacy_idle_status_is_the_session_idle() { + let mut t = Trace::default(); + t.feed(json!({ "type": "sdk.status", "sessionId": "s", "status": "running" })); + assert!(t.busy(), "the legacy running status is turn evidence"); + t.feed(json!({ "type": "sdk.status", "sessionId": "s", "status": "idle" })); + assert!(!t.busy()); +} + +/// Only a result, an idle or the end of output ends a turn. An error, a +/// compaction, assistant text, a heartbeat, an interrupt receipt (accepted or +/// rejected) and a configure answer never do (the deleted four-edge, +/// rejected-interrupt and interrupt-receipt tests' properties). +#[test] +fn only_a_result_an_idle_or_the_end_of_output_ends_a_turn() { + let mut t = Trace::default(); + t.arm("u").feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + json!({ "type": "sdk.error", "sessionId": "s", "message": "boom" }), + json!({ "type": "sdk.error", "sessionId": "s", "message": "failed", "turnFailure": true }), + compacting(), + boundary(), + status_ended(), + assistant(), + stream(), + heartbeat(), + json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "ok": true, "stillQueued": [] }), + json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "ok": false }), + json!({ "type": "sdk.configured", "sessionId": "s", "requestId": "r", "ok": true }), + json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "p" }), + ]); + assert!(t.busy()); + assert!(t.settlements.is_empty()); + t.feed(result("success")); + assert!(!t.busy()); + + t.arm("v").feed(command("v", "queued")); + t.feed(session_state("running")); + assert!(t.ledger.eof()); + assert!(!t.busy()); +} + +/// The trailers after a result (the command's `completed`, the idle) never open +/// a turn: a CLI death after a result but before its trailers owes nothing, so +/// it adds no second ring. +#[test] +fn the_trailers_after_a_result_never_reopen_the_turn() { + let mut t = Trace::default(); + t.arm("u").feed_all([ + session_state("running"), + command("u", "queued"), + command("u", "started"), + assistant(), + result("success"), + command("u", "completed"), + ]); + assert!(!t.ledger.eof(), "nothing was owed after the result"); +} + +/// A turn that ends with an error result settles like any other, and the +/// queued commands behind an interrupted turn survive its aborted result: they +/// will still run (T12), and their own results settle them (the deleted +/// interrupt-attribution tests' property: an aborted result never retires the +/// next queued command). +#[test] +fn an_aborted_result_retires_only_the_running_command() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + assistant(), + ]); + t.arm_compact("c").feed(command("c", "queued")); + t.arm("s").feed(command("s", "queued")); + t.feed(result("error_during_execution")); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + assert_eq!(t.outstanding(), vec!["c", "s"]); + assert!( + t.busy(), + "the queued compact and send keep the session busy" + ); + t.feed_all([ + command("a", "cancelled"), + command("c", "started"), + compacting(), + ]); + // The compact is stopped too: its aborted result retires only the compact. + t.feed(result("error_during_execution")); + assert_eq!(t.outstanding(), vec!["s"]); + assert_eq!(t.status(), "running", "the stopped compaction is over"); + t.feed_all([ + command("c", "cancelled"), + command("s", "started"), + assistant(), + ]); + t.feed(result("success")); + assert!( + !t.busy(), + "the session is idle exactly at the last command's result" + ); + assert_eq!( + t.settlements, + vec![ + vec!["a".to_string()], + vec!["c".to_string()], + vec!["s".to_string()] + ] + ); +} + +/// An automatic compaction inside a running turn is the turn's: it settles no +/// command, and a `/compact` queued behind the turn stays owed until its own +/// result (the deleted compact-candidate tests' property). +#[test] +fn an_automatic_compaction_mid_turn_settles_no_queued_compact() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + ]); + t.arm_compact("c").feed(command("c", "queued")); + t.feed(compacting()); + assert_eq!(t.status(), "compacting"); + t.feed_all([boundary(), status_ended()]); + assert_eq!(t.status(), "running"); + assert_eq!(t.outstanding(), vec!["a", "c"]); + t.feed(result("success")); + assert_eq!( + t.outstanding(), + vec!["c"], + "only the turn's own command settled" + ); + assert!(t.busy()); + t.feed_all([command("c", "started"), compacting()]); + assert_eq!(t.status(), "compacting"); + t.feed(result("success")); + assert!(!t.busy()); + assert_eq!( + t.status(), + "idle", + "a result ends a compaction that never reported its end" + ); +} + +/// A compaction status is busy the moment it folds, even with no command owed +/// (a compaction the server could not attribute still keeps the rollback gate +/// closed; the deleted revived-compact tests' property). +#[test] +fn a_compaction_is_busy_from_its_status() { + let mut t = Trace::default(); + t.feed(compacting()); + assert!(t.busy()); + assert_eq!(t.status(), "compacting"); + t.feed(status_ended()); + assert!(!t.busy()); +} + +/// A command the CLI drops without running it (`discarded`, `cancelled` before +/// it started) ends with its own lifecycle and nothing else: the commands +/// around it are untouched (the deleted dropped-compact tests' property). +#[test] +fn a_dropped_command_ends_with_its_own_lifecycle_only() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + ]); + t.arm_compact("c1").arm("s1").arm_compact("c2").feed_all([ + command("c1", "queued"), + command("s1", "queued"), + command("c2", "queued"), + command("c1", "discarded"), + ]); + assert_eq!(t.outstanding(), vec!["a", "s1", "c2"]); + t.feed(command("c2", "cancelled")); + assert_eq!(t.outstanding(), vec!["a", "s1"]); + // A lifecycle for a command already gone is ignored. + assert!(matches!( + t.ledger.fold(&TurnFact::Command { + uuid: "c1".into(), + state: CommandState::Completed + }), + Folded::UnknownCommand + )); + assert_eq!(t.outstanding(), vec!["a", "s1"]); +} + +/// A lifecycle for a uuid the server never sent is ignored and reported. +#[test] +fn an_unknown_uuid_is_ignored() { + let mut t = Trace::default(); + t.arm("a"); + for state in [ + CommandState::Queued, + CommandState::Started, + CommandState::Replayed, + CommandState::Completed, + ] { + assert!(matches!( + t.ledger.fold(&TurnFact::Command { + uuid: "stranger".into(), + state + }), + Folded::UnknownCommand + )); + } + assert_eq!(t.outstanding(), vec!["a"]); + assert!(!t.ledger.turn_open, "an unknown command never opens a turn"); +} + +/// A replay is the start of a command whose CLI sends no lifecycle; the first +/// of `started` and `replayed` wins and the second changes nothing. +#[test] +fn a_replay_starts_a_command_and_the_first_start_wins() { + let mut t = Trace::default(); + t.arm("a").feed(command("a", "replayed")); + assert!(t.ledger.turn_open); + t.feed(command("a", "started")); + t.feed(result("success")); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + assert_eq!(t.head_retires, 0, "the replayed command settled by itself"); +} + +/// Turn evidence (a model request, assistant text, stream events) opens the +/// turn when the CLI announces no session state, and the result closes it. A +/// subagent's own output (with a parent tool use) is the parent's tool call, +/// not a turn, and an init alone opens nothing: every real turn opened before +/// its init, and the protocol fakes announce an init at create. +#[test] +fn turn_evidence_opens_a_turn_without_session_state() { + let mut t = Trace::default(); + t.feed_all([init(), init_without_lifecycle()]); + assert!(!t.busy(), "an init is not a turn"); + for evidence in [requesting(), assistant(), stream()] { + let mut t = Trace::default(); + t.feed(evidence.clone()); + assert!(t.busy(), "{evidence}"); + t.feed(result("success")); + assert!(!t.busy(), "{evidence}"); + } + let mut t = Trace::default(); + t.feed(json!({ + "type": "sdk.stream", "sessionId": "s", "event": {}, "parentToolUseId": "toolu_1", + })); + t.feed(json!({ + "type": "sdk.assistant", "sessionId": "s", "content": [], "parentToolUseId": "toolu_1", + })); + assert!( + !t.busy(), + "a subagent's output is not a turn of the session's own" + ); +} + +/// The old-CLI fallback (no lifecycle frames, no lifecycle capability on the +/// init): a result that started no command retires the oldest one the server +/// armed (an unknown slash command never replays, T8), and reports it. +#[test] +fn an_old_cli_result_retires_the_oldest_armed_command() { + let mut t = Trace::default(); + t.arm("a") + .arm("b") + .feed_all([init_without_lifecycle(), assistant()]); + t.feed(result("success")); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + assert_eq!(t.head_retires, 1); + assert!(t.busy(), "b is still owed"); + t.feed(result("success")); + assert_eq!(t.head_retires, 2); + assert!(!t.busy()); +} + +/// A lifecycle-capable CLI's result that started no command (a CLI-initiated +/// turn, a slash command the CLI ran before the send reached it) retires none: +/// a send armed and not yet started is never taken for another turn's result. +/// The capability comes from the first lifecycle frame or the init's +/// `msg_lifecycle_v1`. +#[test] +fn a_lifecycle_capable_result_retires_only_started_commands() { + let mut by_init = Trace::default(); + by_init.feed(init()); + by_init.arm("a").feed(result("success")); + assert_eq!(by_init.settlements, vec![Vec::::new()]); + assert_eq!(by_init.outstanding(), vec!["a"]); + + let mut by_lifecycle = Trace::default(); + by_lifecycle.arm("x").feed(command("x", "queued")); + by_lifecycle.arm("a").feed(result("success")); + assert_eq!(by_lifecycle.settlements, vec![Vec::::new()]); + assert_eq!(by_lifecycle.outstanding(), vec!["x", "a"]); +} + +/// A send whose write failed or that the sidecar refused is taken back by its +/// own uuid: exactly that command leaves, whatever else is owed stays (the +/// deleted failed-compact-write tests' property), and a second take-back finds +/// nothing. +#[test] +fn a_send_is_taken_back_by_its_own_uuid() { + let mut t = Trace::default(); + t.arm("a") + .feed_all([session_state("running"), command("a", "started")]); + t.arm("s1").arm_compact("c2"); + let taken = t.ledger.undo_arm("c2").expect("c2 is owed"); + assert_eq!(taken.uuid, "c2"); + assert_eq!(taken.kind, SendKind::Compact); + assert_eq!(taken.request_id, None); + assert_eq!(t.outstanding(), vec!["a", "s1"]); + assert!(t.ledger.undo_arm("c2").is_none(), "taken back once"); + assert!(t.ledger.undo_arm("never-sent").is_none()); + t.feed(result("success")); + assert!(t.busy(), "s1 is still owed after a's result"); + let s1 = t.ledger.undo_arm("s1").expect("s1 is owed"); + assert_eq!(s1.request_id.as_deref(), Some("request-s1")); + assert!(!t.busy()); +} + +/// A send armed while the previous turn is still finishing (its result lands +/// between the arm and the write) is never settled by that turn's result (the +/// deleted write-window tests' property). +#[test] +fn a_result_during_a_sends_write_settles_only_the_running_command() { + let mut t = Trace::default(); + t.arm("a").feed_all([ + session_state("running"), + command("a", "queued"), + command("a", "started"), + assistant(), + ]); + t.arm("b"); + t.feed(result("success")); + assert_eq!(t.settlements, vec![vec!["a".to_string()]]); + assert_eq!(t.outstanding(), vec!["b"]); + assert!(t.busy()); +} + +/// The end of the sidecar's output reports whether anything was owed and +/// clears the ledger. +#[test] +fn the_end_of_output_reports_what_was_owed() { + let mut idle = Trace::default(); + assert!(!idle.ledger.eof(), "an idle session owes nothing"); + + let mut armed = Trace::default(); + armed.arm("a"); + assert!(armed.ledger.eof(), "an armed send is owed"); + assert!(!armed.busy()); + + let mut cli_turn = Trace::default(); + cli_turn.feed(session_state("running")); + assert!(cli_turn.ledger.eof(), "the CLI's own turn is owed"); + + let mut compaction = Trace::default(); + compaction.feed(compacting()); + assert!(compaction.ledger.eof()); +} + +/// `effective_status`: exited, else stuck, else compacting, else running while +/// busy, else idle. +#[test] +fn effective_status_precedence() { + let mut t = Trace::default(); + assert_eq!( + t.ledger.effective_status(false, false), + EffectiveStatus::Idle + ); + assert_eq!( + t.ledger.effective_status(false, true), + EffectiveStatus::Stuck + ); + assert_eq!( + t.ledger.effective_status(true, true), + EffectiveStatus::Exited + ); + t.arm("a"); + assert_eq!( + t.ledger.effective_status(false, false), + EffectiveStatus::Running + ); + t.feed(compacting()); + assert_eq!( + t.ledger.effective_status(false, false), + EffectiveStatus::Compacting + ); + assert_eq!( + t.ledger.effective_status(false, true), + EffectiveStatus::Stuck + ); + assert_eq!( + t.ledger.effective_status(true, false), + EffectiveStatus::Exited + ); + for (status, text) in [ + (EffectiveStatus::Idle, "idle"), + (EffectiveStatus::Running, "running"), + (EffectiveStatus::Compacting, "compacting"), + (EffectiveStatus::Stuck, "stuck"), + (EffectiveStatus::Exited, "exited"), + ] { + assert_eq!(status.as_str(), text); + } +} + +/// The status to publish is reported once per change: a fresh ledger has +/// published `idle`, an arm reports `running` once, and the settle reports +/// `idle` once. +#[test] +fn a_status_change_is_reported_once() { + let mut t = Trace::default(); + assert_eq!(t.ledger.take_status_change(false), None); + t.arm("a"); + assert_eq!( + t.ledger.take_status_change(false), + Some(EffectiveStatus::Running) + ); + assert_eq!(t.ledger.take_status_change(false), None); + t.feed(command("a", "started")); + assert_eq!(t.ledger.take_status_change(false), None, "still running"); + t.feed(compacting()); + assert_eq!( + t.ledger.take_status_change(false), + Some(EffectiveStatus::Compacting) + ); + t.feed(result("success")); + assert_eq!( + t.ledger.take_status_change(false), + Some(EffectiveStatus::Idle) + ); + assert_eq!(t.ledger.published_status(), EffectiveStatus::Idle); + assert_eq!(t.ledger.take_status_change(false), None); +} + +/// Every line kind the ledger folds, and the lines it does not. +#[test] +fn the_lines_the_ledger_folds() { + let facts = [ + ( + command("u", "queued"), + Some(TurnFact::Command { + uuid: "u".into(), + state: CommandState::Queued, + }), + ), + ( + session_state("requires_action"), + Some(TurnFact::SessionState(SessionState::RequiresAction)), + ), + ( + init(), + Some(TurnFact::Init { + lifecycle_capable: true, + }), + ), + ( + init_without_lifecycle(), + Some(TurnFact::Init { + lifecycle_capable: false, + }), + ), + (requesting(), Some(TurnFact::TurnEvidence)), + (assistant(), Some(TurnFact::Content)), + (stream(), Some(TurnFact::Content)), + (compacting(), Some(TurnFact::CompactStarted)), + (status_ended(), Some(TurnFact::CompactEnded)), + (boundary(), Some(TurnFact::CompactEnded)), + ( + result("success"), + Some(TurnFact::Result(ResultFacts { + subtype: "success".into(), + ..ResultFacts::default() + })), + ), + ( + json!({ + "type": "sdk.result", "subtype": "error_during_execution", "isError": true, + "terminalReason": "aborted_tools", "numTurns": 3, "errorText": "boom", + }), + Some(TurnFact::Result(ResultFacts { + subtype: "error_during_execution".into(), + is_error: true, + terminal_reason: Some("aborted_tools".into()), + num_turns: Some(3), + error_text: Some("boom".into()), + })), + ), + ( + json!({ "type": "sdk.status", "status": "running" }), + Some(TurnFact::TurnEvidence), + ), + ( + json!({ "type": "sdk.status", "status": "idle" }), + Some(TurnFact::SessionState(SessionState::Idle)), + ), + (json!({ "type": "sdk.error", "message": "boom" }), None), + (heartbeat(), None), + ( + json!({ "type": "sdk.interrupt_settled", "ok": true }), + Some(TurnFact::InterruptSettled { + stop_id: None, + ok: true, + still_queued: None, + }), + ), + ( + json!({ "type": "sdk.interrupt_settled", "stopId": "s-1", "ok": true, "stillQueued": ["u"] }), + Some(TurnFact::InterruptSettled { + stop_id: Some("s-1".into()), + ok: true, + still_queued: Some(vec!["u".into()]), + }), + ), + (json!({ "type": "sdk.send.rejected", "uuid": "u" }), None), + (json!({ "type": "sdk.turn.complete", "at": 1 }), None), + ( + json!({ "type": "sdk.stream", "event": {}, "parentToolUseId": "t" }), + None, + ), + ]; + for (line, expected) in facts { + assert_eq!(TurnFact::from_line(&line), expected, "{line}"); + } +} + +// ── property tests ─────────────────────────────────────────────────────────── + +/// SplitMix64: a small seeded generator, so every failing interleaving can be +/// replayed from its seed. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + + fn chance(&mut self, percent: u64) -> bool { + self.next() % 100 < percent + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum SimTurn { + /// Streamed text: a send pushed now queues as its own turn. + Text, + /// A tool call: a send pushed while it runs is absorbed into the turn. + Tool, + /// `/compact`. + Compact, +} + +#[derive(Clone, Debug)] +struct SimCommand { + uuid: String, + kind: SimTurn, +} + +/// One step of the CLI's current turn. +enum Step { + Frame(Value), + /// The tool call starts: commands the CLI reads now are absorbed. + OpenTool, + /// The tool result: every absorbed command replays and starts. + CloseTool, + /// The result: the turn ends. + Result, + /// The absorbed commands' `completed` (before or after the result). + CompleteAbsorbed, +} + +/// The CLI as T8-T10 describe it. `lifecycle: false` is an older CLI: no +/// command lifecycle, no session state, no capability on the init, and (so the +/// head fallback can attribute every result) no absorption and no turns of its +/// own; it replays most sends (an unknown slash command is never replayed, T8). +struct CliModel { + lifecycle: bool, + /// Sends the server wrote that the CLI has not read yet. + pipe: VecDeque, + /// Commands the CLI read and has not started. + queue: VecDeque, + /// The CLI announced `running` and not yet `idle`. + running: bool, + /// The CLI's `running` announced work no result has ended yet: the CLI + /// says it is working even when it then drops the command it read. + state_open: bool, + /// A turn started and its result is not out yet. + in_turn: bool, + current: Option, + absorbing: bool, + absorbed: Vec, + script: VecDeque, + results: usize, + /// The current turn's command was replayed. + replayed: bool, + /// Results of an old CLI's turns whose command was never replayed: the + /// head fallback settles them. + unreplayed_results: usize, +} + +impl CliModel { + fn new(lifecycle: bool) -> Self { + Self { + lifecycle, + pipe: VecDeque::new(), + queue: VecDeque::new(), + running: false, + state_open: false, + in_turn: false, + current: None, + absorbing: false, + absorbed: Vec::new(), + script: VecDeque::new(), + results: 0, + replayed: false, + unreplayed_results: 0, + } + } + + /// The CLI owes the server work: a send it has not read, a command it has + /// not started, a turn whose result is not out, or a `running` no result + /// or idle has answered. + fn owes(&self) -> bool { + !self.pipe.is_empty() || !self.queue.is_empty() || self.in_turn || self.state_open + } + + fn idle(&self) -> bool { + !self.owes() && self.script.is_empty() && !self.running + } + + fn lifecycle_frame(&self, uuid: &str, state: &str) -> Option { + self.lifecycle.then(|| command(uuid, state)) + } + + fn init_frame(&self) -> Value { + if self.lifecycle { + init() + } else { + init_without_lifecycle() + } + } + + /// The CLI reads one send off its stdin. + fn read(&mut self) -> Vec { + let cmd = self.pipe.pop_front().expect("a send to read"); + let mut frames = Vec::new(); + if self.lifecycle && !self.running { + frames.push(session_state("running")); + self.state_open = true; + } + self.running = true; + frames.extend(self.lifecycle_frame(&cmd.uuid, "queued")); + if self.absorbing { + self.absorbed.push(cmd); + } else { + self.queue.push_back(cmd); + } + frames + } + + /// Start the next queued command's turn, or announce the idle. + fn start_next(&mut self, rng: &mut Rng) -> Vec { + if let Some(cmd) = self.queue.pop_front() { + self.in_turn = true; + self.replayed = self.lifecycle || rng.chance(70); + self.script = self.turn_script(&cmd, rng); + self.current = Some(cmd); + return Vec::new(); + } + if self.running { + self.running = false; + self.state_open = false; + if self.lifecycle { + return vec![session_state("idle")]; + } + } + Vec::new() + } + + /// A background task finished while the CLI was idle: it starts a turn of + /// its own, with no uuid and no lifecycle (T19). + fn start_cli_turn(&mut self) -> Vec { + self.running = true; + self.state_open = true; + self.in_turn = true; + self.current = None; + self.replayed = true; + self.script = VecDeque::from([ + Step::Frame(self.init_frame()), + Step::Frame(requesting()), + Step::Frame(stream()), + Step::Frame(assistant()), + Step::Result, + ]); + vec![session_state("running")] + } + + fn turn_script(&self, cmd: &SimCommand, rng: &mut Rng) -> VecDeque { + let mut steps = VecDeque::new(); + let started = self.lifecycle_frame(&cmd.uuid, "started"); + // The sidecar reports a replay of a sent uuid on any CLI. + let replayed = self.replayed.then(|| command(&cmd.uuid, "replayed")); + let replay_first = rng.chance(30); + if replay_first { + steps.extend(replayed.clone().map(Step::Frame)); + } + steps.extend(started.map(Step::Frame)); + match cmd.kind { + SimTurn::Compact => { + steps.push_back(Step::Frame(compacting())); + steps.push_back(Step::Frame(status_ended())); + if rng.chance(50) { + steps.push_back(Step::Frame(boundary())); + } + steps.push_back(Step::Frame(self.init_frame())); + steps.push_back(Step::Frame(assistant())); + if !replay_first { + steps.extend(replayed.map(Step::Frame)); + } + } + SimTurn::Text | SimTurn::Tool => { + steps.push_back(Step::Frame(self.init_frame())); + steps.push_back(Step::Frame(requesting())); + if !replay_first { + steps.extend(replayed.map(Step::Frame)); + } + steps.push_back(Step::Frame(stream())); + if rng.chance(15) { + // An automatic compaction inside the turn. + steps.push_back(Step::Frame(compacting())); + steps.push_back(Step::Frame(boundary())); + steps.push_back(Step::Frame(status_ended())); + } + steps.push_back(Step::Frame(assistant())); + if cmd.kind == SimTurn::Tool && self.lifecycle { + steps.push_back(Step::OpenTool); + steps.push_back(Step::Frame(heartbeat())); + steps.push_back(Step::CloseTool); + steps.push_back(Step::Frame(requesting())); + steps.push_back(Step::Frame(stream())); + steps.push_back(Step::Frame(assistant())); + } + } + } + let absorbed_after = rng.chance(30); + if !absorbed_after { + steps.push_back(Step::CompleteAbsorbed); + } + steps.push_back(Step::Result); + if absorbed_after { + steps.push_back(Step::CompleteAbsorbed); + } + let end = if rng.chance(20) { + "cancelled" + } else { + "completed" + }; + steps.extend(self.lifecycle_frame(&cmd.uuid, end).map(Step::Frame)); + steps + } + + /// The CLI's next frames: its current turn's next step, else the next turn + /// (or the idle). + fn advance(&mut self, rng: &mut Rng) -> Vec { + let Some(step) = self.script.pop_front() else { + return self.start_next(rng); + }; + match step { + Step::Frame(frame) => vec![frame], + Step::OpenTool => { + self.absorbing = true; + Vec::new() + } + Step::CloseTool => { + self.absorbing = false; + let mut frames = Vec::new(); + for cmd in &self.absorbed { + if rng.chance(50) { + frames.push(command(&cmd.uuid, "replayed")); + frames.push(command(&cmd.uuid, "started")); + } else { + frames.push(command(&cmd.uuid, "started")); + frames.push(command(&cmd.uuid, "replayed")); + } + } + frames + } + Step::CompleteAbsorbed => { + let absorbed = std::mem::take(&mut self.absorbed); + // A tool that closed with the result still open keeps its + // absorbed commands in the turn until here. + absorbed + .iter() + .map(|cmd| command(&cmd.uuid, "completed")) + .collect() + } + Step::Result => { + self.in_turn = false; + self.state_open = false; + self.results += 1; + if !self.replayed { + self.unreplayed_results += 1; + } + self.current = None; + let subtype = if rng.chance(15) { + "error_during_execution" + } else { + "success" + }; + vec![result(subtype)] + } + } + } + + /// The CLI drops a queued command it has not started (`discarded`). + fn discard(&mut self, rng: &mut Rng) -> Vec { + let at = rng.below(self.queue.len()); + let cmd = self.queue.remove(at).expect("a queued command"); + vec![command(&cmd.uuid, "discarded")] + } +} + +/// Feed `frames` to the ledger one by one, holding the invariant after each: +/// the ledger is busy exactly when the CLI owes work, and only a result +/// settles. +fn deliver( + ledger: &mut ClaudeTurnLedger, + cli: &CliModel, + frames: Vec, + seen: &mut Seen, + seed: u64, +) { + for frame in frames { + let is_result = frame["type"] == "sdk.result"; + let fact = TurnFact::from_line(&frame); + let folded = fact.as_ref().map(|fact| ledger.fold(fact)); + match folded { + Some(Folded::Settled(settlement)) => { + assert!(is_result, "seed {seed}: only a result settles ({frame})"); + seen.settlements += 1; + for command in settlement.retired { + assert!( + seen.retired.insert(command.uuid.clone()), + "seed {seed}: {} was settled twice", + command.uuid + ); + } + if settlement.head_retired { + seen.head_retires += 1; + } + } + _ => assert!(!is_result, "seed {seed}: a result always settles"), + } + assert_eq!( + ledger.busy(), + cli.owes(), + "seed {seed}: after {frame} the ledger is busy exactly when the CLI owes work \ + (outstanding {:?}, pipe {}, queue {}, in turn {})", + ledger.outstanding().collect::>(), + cli.pipe.len(), + cli.queue.len(), + cli.in_turn, + ); + } +} + +#[derive(Default)] +struct Seen { + settlements: usize, + retired: HashSet, + head_retires: usize, +} + +/// Run one random interleaving of sends and CLI frames to quiescence. +fn run_interleaving(seed: u64, lifecycle: bool) { + let mut rng = Rng(seed); + let mut cli = CliModel::new(lifecycle); + let mut ledger = ClaudeTurnLedger::default(); + let mut seen = Seen::default(); + let mut sends_left = 1 + rng.below(6); + let mut cli_turns_left = if lifecycle { rng.below(3) } else { 0 }; + let mut armed = 0usize; + let mut steps = 0; + loop { + steps += 1; + assert!(steps < 10_000, "seed {seed}: the run never settled"); + // The actions open now, picked at random. + let mut actions = Vec::new(); + if sends_left > 0 { + actions.push(0); + } + if !cli.pipe.is_empty() { + actions.push(1); + } + if !cli.script.is_empty() || !cli.queue.is_empty() || cli.running { + actions.push(2); + } + if cli_turns_left > 0 && cli.idle() { + actions.push(3); + } + if lifecycle && !cli.queue.is_empty() && rng.chance(10) { + actions.push(4); + } + if actions.is_empty() { + break; + } + let frames = match actions[rng.below(actions.len())] { + 0 => { + sends_left -= 1; + armed += 1; + let uuid = format!("cmd-{armed}"); + let kind = match rng.below(5) { + 0 => SimTurn::Compact, + 1 | 2 if lifecycle => SimTurn::Tool, + _ => SimTurn::Text, + }; + let send_kind = if kind == SimTurn::Compact { + SendKind::Compact + } else { + SendKind::Turn + }; + // The handler arms before it writes. + ledger.arm(uuid.clone(), Some(format!("r-{uuid}")), send_kind); + cli.pipe.push_back(SimCommand { uuid, kind }); + Vec::new() + } + 1 => cli.read(), + 2 => cli.advance(&mut rng), + 3 => { + cli_turns_left -= 1; + cli.start_cli_turn() + } + _ => cli.discard(&mut rng), + }; + deliver(&mut ledger, &cli, frames, &mut seen, seed); + assert_eq!(ledger.busy(), cli.owes(), "seed {seed}: after an action"); + } + assert!(cli.idle(), "seed {seed}: the CLI ended idle"); + assert!( + !ledger.busy(), + "seed {seed}: the ledger ends idle with the CLI" + ); + assert_eq!( + ledger.effective_status(false, false), + EffectiveStatus::Idle, + "seed {seed}" + ); + assert_eq!( + seen.settlements, cli.results, + "seed {seed}: one settlement per result" + ); + if lifecycle { + assert_eq!( + seen.head_retires, 0, + "seed {seed}: no fallback with lifecycle" + ); + } else { + assert_eq!( + seen.head_retires, cli.unreplayed_results, + "seed {seed}: an old CLI settles a result with no replayed command by the head fallback" + ); + assert_eq!( + seen.retired.len(), + armed, + "seed {seed}: every send settled once" + ); + } +} + +/// Random interleavings consistent with T8-T10 (sends racing the CLI's frames, +/// absorbed and queued sends, compacts, automatic compactions, discards, +/// aborted results, CLI-initiated turns, replays before or after `started`, +/// absorbed completions before or after the result): the ledger is busy +/// exactly while the CLI owes work, ends idle exactly when the CLI is idle, +/// and settles each result exactly once, never retiring a command twice. +#[test] +fn random_interleavings_end_idle_exactly_when_the_cli_is_idle() { + for seed in 0..4_000 { + run_interleaving(seed, true); + } +} + +/// The same for an older CLI without lifecycle frames: a replayed command +/// settles at its result, and the head fallback settles a result whose command +/// was never replayed with the oldest armed command. +#[test] +fn random_interleavings_on_an_old_cli_settle_by_the_head_fallback() { + for seed in 0..2_000 { + run_interleaving(seed, false); + } +} diff --git a/crates/freshell-freshagent/src/claude_turns_stop_tests.rs b/crates/freshell-freshagent/src/claude_turns_stop_tests.rs new file mode 100644 index 000000000..1aee9173a --- /dev/null +++ b/crates/freshell-freshagent/src/claude_turns_stop_tests.rs @@ -0,0 +1,685 @@ +//! The Stop latch and the result verdict (design §5.3, §5.4, §11.2; C18). +//! +//! A Stop targets one thing: the running turn's commands, else the oldest +//! command the CLI has not started, else a turn the CLI runs by itself. A Stop +//! that landed on its target is silent; every other turn end rings. A Stop +//! that missed a command the CLI was still starting (T13) is sent again once +//! that command runs, whichever receipt shape the CLI returned: +//! +//! - case A: the receipt lists the command, or came before any of its +//! lifecycle: re-send once both the receipt and its start have been seen; +//! - case B: the receipt is `[]` and came after the command's lifecycle (the +//! interrupt may have landed on it): re-send on its first content. + +use serde_json::{json, Value}; + +use super::*; + +fn command(uuid: &str, state: &str) -> Value { + json!({ "type": "sdk.command", "sessionId": "s", "uuid": uuid, "state": state }) +} + +fn session_state(state: &str) -> Value { + json!({ "type": "sdk.session.state", "sessionId": "s", "state": state }) +} + +fn assistant() -> Value { + json!({ "type": "sdk.assistant", "sessionId": "s", "content": [{ "type": "text", "text": "ok" }] }) +} + +fn subagent_stream() -> Value { + json!({ "type": "sdk.stream", "sessionId": "s", "event": {}, "parentToolUseId": "toolu_1" }) +} + +fn receipt(stop_id: &str, still_queued: &[&str]) -> Value { + json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "stopId": stop_id, "ok": true, "stillQueued": still_queued }) +} + +fn refused(stop_id: &str) -> Value { + json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "stopId": stop_id, "ok": false, "message": "no" }) +} + +/// The pinned CLI's result for a turn a Stop landed on (T11). +fn aborted() -> Value { + json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "error_during_execution", + "isError": true, "terminalReason": "aborted_streaming", "numTurns": 2, + }) +} + +fn success() -> Value { + json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "success", "isError": false, + "terminalReason": "completed", "numTurns": 1, + }) +} + +/// One ledger driven like the stdout consumer drives it, keeping what each +/// fold asked for: the re-sent interrupts and each result's verdict. +#[derive(Default)] +struct Run { + ledger: ClaudeTurnLedger, + reissues: Vec, + verdicts: Vec, + refusals: Vec, +} + +impl Run { + fn arm(&mut self, uuid: &str) -> &mut Self { + self.ledger + .arm(uuid.into(), Some(format!("request-{uuid}")), SendKind::Turn); + self + } + + fn feed(&mut self, line: Value) -> &mut Self { + // A line the ledger does not fold (a subagent's stream) changes nothing. + let Some(fact) = TurnFact::from_line(&line) else { + return self; + }; + match self.ledger.fold(&fact) { + Folded::Settled(settlement) => self.verdicts.push(settlement.verdict), + Folded::Reissue(reissue) => self.reissues.push(reissue), + Folded::StopRefused { stop_id } => self.refusals.push(stop_id), + Folded::Applied | Folded::UnknownCommand => {} + } + self + } + + fn feed_all(&mut self, lines: impl IntoIterator) -> &mut Self { + for line in lines { + self.feed(line); + } + self + } + + fn stop(&mut self, stop_id: &str) -> Option { + self.ledger.stop(stop_id.to_string()) + } + + fn running(&mut self, uuid: &str) -> &mut Self { + self.arm(uuid).feed_all([ + session_state("running"), + command(uuid, "queued"), + command(uuid, "started"), + command(uuid, "replayed"), + assistant(), + ]) + } +} + +// ── target selection ───────────────────────────────────────────────────────── + +#[test] +fn an_idle_session_has_nothing_to_stop() { + let mut run = Run::default(); + assert_eq!(run.stop("stop-1"), None, "an idle Stop is a no-op"); + assert!(!run.ledger.stop_latched()); + // Nothing armed, so the next turn's result rings. + run.running("u").feed(aborted()); + assert!(matches!(run.verdicts[..], [ResultVerdict::Failed { .. }])); +} + +#[test] +fn a_stop_targets_the_running_turns_commands() { + let mut run = Run::default(); + run.running("a"); + // b is queued behind a: another device's message. + run.arm("b").feed(command("b", "queued")); + let armed = run.stop("stop-1").expect("armed"); + assert_eq!(armed.target, StopTargetKind::Running); + assert_eq!(armed.commands, vec!["a".to_string()]); +} + +#[test] +fn a_stop_with_nothing_started_targets_the_oldest_command_not_started() { + let mut run = Run::default(); + run.arm("a").arm("b").feed(command("a", "queued")); + let armed = run.stop("stop-1").expect("armed"); + assert_eq!(armed.target, StopTargetKind::NotStarted); + assert_eq!(armed.commands, vec!["a".to_string()]); +} + +#[test] +fn a_stop_with_no_command_targets_the_turn_the_cli_runs_by_itself() { + let mut run = Run::default(); + run.feed_all([session_state("running"), assistant()]); + let armed = run.stop("stop-1").expect("armed"); + assert_eq!(armed.target, StopTargetKind::CliTurn); + assert!(armed.commands.is_empty()); + run.feed(aborted()); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); + run.feed_all([session_state("running"), assistant(), success()]); + assert!( + matches!(run.verdicts[1], ResultVerdict::Completed { .. }), + "the latch cleared at the stopped turn's result: {:?}", + run.verdicts + ); +} + +// ── the result verdict (§5.3) ──────────────────────────────────────────────── + +#[test] +fn a_warm_stop_that_landed_is_silent() { + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed_all([receipt("stop-1", &[]), aborted(), command("u", "cancelled")]); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); + assert!( + run.reissues.is_empty(), + "a running turn is never re-stopped" + ); + assert!( + !run.ledger.stop_latched(), + "the latch cleared at the result" + ); +} + +#[test] +fn a_stop_racing_completion_rings() { + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed_all([receipt("stop-1", &[]), success()]); + assert_eq!( + run.verdicts, + vec![ResultVerdict::Completed { is_error: false }] + ); + assert!(!run.ledger.stop_latched()); +} + +#[test] +fn a_queued_command_another_device_sent_runs_and_rings_after_the_stopped_turn() { + let mut run = Run::default(); + run.running("a"); + run.arm("b").feed(command("b", "queued")); + run.stop("stop-1").expect("armed"); + run.feed_all([ + receipt("stop-1", &["b"]), + aborted(), + command("a", "cancelled"), + command("b", "started"), + command("b", "replayed"), + assistant(), + success(), + ]); + assert_eq!( + run.verdicts, + vec![ + ResultVerdict::Silent, + ResultVerdict::Completed { is_error: false } + ] + ); + assert!(run.reissues.is_empty(), "b is not the Stop's target"); +} + +/// A Stop that did not land (the turn failed on its own first) silences +/// nothing: the failed turn rings with its banner. +#[test] +fn a_failed_turn_under_a_stop_that_missed_rings() { + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed_all([ + receipt("stop-1", &[]), + json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "error_max_turns", + "isError": true, "numTurns": 4, + }), + ]); + assert!( + matches!(&run.verdicts[..], [ResultVerdict::Failed { message }] if message.contains("turn limit")), + "{:?}", + run.verdicts + ); +} + +#[test] +fn an_aborted_result_with_no_stop_rings_with_a_banner() { + let mut run = Run::default(); + run.running("u").feed(aborted()); + assert_eq!( + run.verdicts, + vec![ResultVerdict::Failed { + message: ABORTED_TURN_MESSAGE.to_string() + }] + ); +} + +#[test] +fn a_non_target_command_aborted_by_the_stop_rings() { + let mut run = Run::default(); + run.running("a"); + run.stop("stop-1").expect("armed"); + // b is absorbed into the stopped turn after the Stop: not a target. + run.arm("b") + .feed_all([command("b", "queued"), command("b", "started")]); + run.feed(aborted()); + assert!( + matches!(run.verdicts[..], [ResultVerdict::Failed { .. }]), + "{:?}", + run.verdicts + ); +} + +#[test] +fn an_api_error_result_rings_without_a_banner() { + let mut run = Run::default(); + run.running("u").feed(json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "success", "isError": true, + "terminalReason": "api_error", "numTurns": 1, + })); + assert_eq!( + run.verdicts, + vec![ResultVerdict::Completed { is_error: true }] + ); + assert_eq!(run.verdicts[0].banner(), None); + assert_eq!(run.verdicts[0].outcome(), TurnOutcome::Failed); +} + +#[test] +fn a_failed_result_rings_with_its_own_text_or_the_copy_for_its_subtype() { + let mut run = Run::default(); + run.running("a").feed(json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "error_max_turns", "isError": true, + "terminalReason": "max_turns", "numTurns": 9, + })); + run.running("b").feed(json!({ + "type": "sdk.result", "sessionId": "s", "subtype": "error_during_execution", "isError": true, + "numTurns": 1, "errorText": "The model is overloaded.", + })); + run.running("c") + .feed(json!({ "type": "sdk.result", "sessionId": "s", "subtype": "error_unheard_of" })); + assert_eq!( + run.verdicts + .iter() + .map(|verdict| verdict.banner().map(str::to_string)) + .collect::>(), + vec![ + Some("Claude reached its turn limit. Send a message to continue.".to_string()), + Some("The model is overloaded.".to_string()), + Some(DEFAULT_TURN_FAILURE_MESSAGE.to_string()), + ] + ); + assert!(run + .verdicts + .iter() + .all(|verdict| verdict.rings() && verdict.outcome() == TurnOutcome::Failed)); +} + +/// The copy each failed result kind shows when the CLI gave no text of its +/// own (its `[ede_diagnostic]` lines never reach the server). +#[test] +fn the_copy_for_each_failed_result_kind() { + for (subtype, copy) in [ + ( + "error_max_turns", + "Claude reached its turn limit. Send a message to continue.", + ), + ( + "error_max_budget_usd", + "Claude reached the configured spending limit.", + ), + ( + "error_max_structured_output_retries", + "Claude could not produce the requested response format.", + ), + ("error_during_execution", DEFAULT_TURN_FAILURE_MESSAGE), + ] { + let mut run = Run::default(); + run.running("u").feed(json!({ + "type": "sdk.result", "sessionId": "s", "subtype": subtype, "isError": true, "numTurns": 1, + })); + assert_eq!(run.verdicts[0].banner(), Some(copy), "{subtype}"); + } + let mut run = Run::default(); + run.running("u") + .feed(json!({ "type": "sdk.result", "sessionId": "s", "subtype": "success" })); + assert_eq!( + run.verdicts[0].banner(), + None, + "a successful result shows no banner" + ); +} + +#[test] +fn an_older_cli_reports_a_stopped_turn_without_a_terminal_reason() { + let older_aborted = || json!({ "type": "sdk.result", "sessionId": "s", "subtype": "error_during_execution", "numTurns": 1 }); + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed(older_aborted()); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); + + // With no Stop armed, the same result is a failure; and a boot refusal's + // result (no turn ran) is never a stopped turn. + let mut run = Run::default(); + run.running("u").feed(older_aborted()); + assert!(matches!(run.verdicts[..], [ResultVerdict::Failed { .. }])); + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed(json!({ "type": "sdk.result", "sessionId": "s", "subtype": "error_during_execution", "numTurns": 0 })); + assert!(matches!(run.verdicts[..], [ResultVerdict::Failed { .. }])); +} + +#[test] +fn a_protocol_fake_result_names_its_subtype_in_result() { + let mut run = Run::default(); + run.running("u") + .feed(json!({ "type": "sdk.result", "sessionId": "s", "result": "success" })); + assert_eq!( + run.verdicts, + vec![ResultVerdict::Completed { is_error: false }] + ); +} + +// ── cold Stops: the re-send rule (§5.4) ────────────────────────────────────── + +/// Case A, receipt `[]` before any lifecycle (the interrupt overtook the +/// command): re-sent at its start, and the aborted result is silent. +#[test] +fn case_a_a_receipt_before_the_commands_lifecycle_re_sends_at_its_start() { + let mut run = Run::default(); + run.arm("u"); + let armed = run.stop("stop-1").expect("armed"); + assert_eq!(armed.target, StopTargetKind::NotStarted); + run.feed_all([ + receipt("stop-1", &[]), + session_state("running"), + command("u", "queued"), + ]); + assert!(run.reissues.is_empty(), "nothing to stop before it starts"); + run.feed(command("u", "started")); + assert_eq!(run.reissues.len(), 1, "{:?}", run.reissues); + let reissue = &run.reissues[0]; + assert_eq!(reissue.stop_id, "stop-1"); + assert_eq!(reissue.case, ReissueCase::A); + assert_eq!(reissue.target, "u"); + assert_eq!(reissue.reissues, 1); + assert_ne!(reissue.reissue_id, "stop-1", "the re-send has its own id"); + run.feed_all([command("u", "replayed"), assistant(), aborted()]); + assert_eq!(run.reissues.len(), 1, "one re-send"); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); +} + +/// Case A, receipt `[uuid]` (the command was queued): re-sent at its start, +/// whether the start comes after the receipt or before it. +#[test] +fn case_a_a_receipt_listing_the_command_re_sends_once_both_are_seen() { + let mut start_after = Run::default(); + start_after + .arm("u") + .feed_all([session_state("running"), command("u", "queued")]); + start_after.stop("stop-1").expect("armed"); + start_after.feed(receipt("stop-1", &["u"])); + assert!(start_after.reissues.is_empty()); + start_after.feed(command("u", "started")); + assert_eq!(start_after.reissues.len(), 1); + assert_eq!(start_after.reissues[0].case, ReissueCase::A); + + let mut start_before = Run::default(); + start_before + .arm("u") + .feed_all([session_state("running"), command("u", "queued")]); + start_before.stop("stop-1").expect("armed"); + start_before.feed(command("u", "started")); + assert!(start_before.reissues.is_empty(), "no receipt yet"); + start_before.feed(receipt("stop-1", &["u"])); + assert_eq!(start_before.reissues.len(), 1, "re-sent at the receipt"); + assert_eq!(start_before.reissues[0].case, ReissueCase::A); + start_before.feed(aborted()); + assert_eq!(start_before.verdicts, vec![ResultVerdict::Silent]); +} + +/// Case B, receipt `[]` after the command's queued and started: the interrupt +/// may have landed on it, so the re-send waits for its first content. +#[test] +fn case_b_an_empty_receipt_after_the_lifecycle_re_sends_on_first_content() { + let mut run = Run::default(); + run.arm("u") + .feed_all([session_state("running"), command("u", "queued")]); + run.stop("stop-1").expect("armed"); + run.feed_all([command("u", "started"), receipt("stop-1", &[])]); + assert!(run.reissues.is_empty(), "the Stop may have landed"); + run.feed(command("u", "replayed")); + assert_eq!(run.reissues.len(), 1); + assert_eq!(run.reissues[0].case, ReissueCase::B); + run.feed_all([assistant(), aborted()]); + assert_eq!(run.reissues.len(), 1); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); +} + +/// Case B's first content can also be the turn's own assistant or stream +/// frame (an older CLI that replays nothing), but never a subagent's. +#[test] +fn case_b_first_content_is_the_turns_own_assistant_frame() { + let mut run = Run::default(); + run.arm("u") + .feed_all([session_state("running"), command("u", "queued")]); + run.stop("stop-1").expect("armed"); + run.feed_all([ + command("u", "started"), + receipt("stop-1", &[]), + subagent_stream(), + ]); + assert!( + run.reissues.is_empty(), + "a subagent's output is not the turn's" + ); + run.feed(assistant()); + assert_eq!(run.reissues.len(), 1); + assert_eq!(run.reissues[0].case, ReissueCase::B); +} + +/// Case B, the landed Stop: the command settles before any content, so +/// nothing is re-sent and its aborted result is silent. +#[test] +fn case_b_a_stop_that_landed_is_never_re_sent() { + let mut run = Run::default(); + run.arm("u") + .feed_all([session_state("running"), command("u", "queued")]); + run.stop("stop-1").expect("armed"); + run.feed_all([ + command("u", "started"), + receipt("stop-1", &[]), + aborted(), + command("u", "cancelled"), + session_state("idle"), + ]); + assert!(run.reissues.is_empty()); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); +} + +/// A command that ran to success before the re-send could land rings: the +/// user believes it stopped, and it did not. +#[test] +fn a_cold_stop_whose_command_finished_first_rings() { + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.feed_all([ + receipt("stop-1", &[]), + session_state("running"), + command("u", "queued"), + command("u", "started"), + command("u", "replayed"), + assistant(), + success(), + ]); + assert_eq!(run.reissues.len(), 1); + assert_eq!( + run.verdicts, + vec![ResultVerdict::Completed { is_error: false }] + ); +} + +/// A Stop re-sends at most once: the re-send ends its wait, so the command's +/// later lifecycle and content send nothing more. +#[test] +fn a_stop_re_sends_at_most_once() { + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.feed_all([receipt("stop-1", &[]), command("u", "started")]); + assert_eq!(run.reissues.len(), 1); + run.feed_all([ + command("u", "replayed"), + assistant(), + command("u", "started"), + receipt("stop-1", &[]), + ]); + assert_eq!(run.reissues.len(), 1, "{:?}", run.reissues); + assert!(run.reissues[0].reissues <= MAX_STOP_REISSUES); +} + +/// The receipt of a re-sent interrupt, or of an earlier Stop, is not the +/// latch's own receipt. +#[test] +fn only_the_latchs_own_receipt_is_read() { + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.feed(receipt("stop-0", &["u"])); + run.feed(command("u", "started")); + assert!(run.reissues.is_empty(), "a foreign receipt was read"); +} + +/// A protocol fake echoes no stop id: its receipt is the latch's. +#[test] +fn a_receipt_without_a_stop_id_is_the_pending_latchs() { + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.feed(json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "ok": true })); + run.feed(command("u", "started")); + assert_eq!(run.reissues.len(), 1); +} + +/// An older CLI with no command lifecycle: a Stop before its replay waits for +/// the replay to re-send; a stopped turn's result settles by the head fallback +/// and is silent. +#[test] +fn an_older_cli_with_no_lifecycle() { + let mut run = Run::default(); + run.arm("u"); + let armed = run.stop("stop-1").expect("armed"); + assert_eq!(armed.target, StopTargetKind::NotStarted); + run.feed(json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "stopId": "stop-1", "ok": true })); + run.feed(command("u", "replayed")); + assert_eq!(run.reissues.len(), 1, "the replay is its start"); + run.feed(aborted()); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); + + let mut landed = Run::default(); + landed.arm("u"); + landed.stop("stop-1").expect("armed"); + landed.feed_all([ + json!({ "type": "sdk.interrupt_settled", "sessionId": "s", "ok": true }), + json!({ "type": "sdk.status", "sessionId": "s", "status": "running" }), + aborted(), + ]); + assert!(landed.reissues.is_empty()); + assert_eq!(landed.verdicts, vec![ResultVerdict::Silent]); +} + +// ── disarming and clearing ─────────────────────────────────────────────────── + +#[test] +fn a_refused_interrupt_disarms_the_latch() { + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed(refused("stop-1")); + assert_eq!(run.refusals, vec!["stop-1".to_string()]); + assert!(!run.ledger.stop_latched()); + run.feed(aborted()); + assert!( + matches!(run.verdicts[..], [ResultVerdict::Failed { .. }]), + "the turn kept running, so its end rings: {:?}", + run.verdicts + ); +} + +#[test] +fn an_interrupt_that_could_not_be_written_disarms_the_latch() { + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.ledger.stop_unsent("stop-1"); + assert!(!run.ledger.stop_latched()); + run.ledger.stop_unsent("stop-0"); + run.feed(aborted()); + assert!(matches!(run.verdicts[..], [ResultVerdict::Failed { .. }])); +} + +#[test] +fn the_latch_clears_when_its_targets_end() { + // A terminal lifecycle of the target. + let mut run = Run::default(); + run.arm("u").feed(command("u", "queued")); + run.stop("stop-1").expect("armed"); + run.feed(command("u", "discarded")); + assert!(!run.ledger.stop_latched()); + + // An idle retires the started target. + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + run.feed(session_state("idle")); + assert!(!run.ledger.stop_latched()); + + // An idle keeps a target the CLI has not reported (it can race the idle). + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.feed(session_state("idle")); + assert!(run.ledger.stop_latched()); + + // The end of output. + let mut run = Run::default(); + run.running("u"); + run.stop("stop-1").expect("armed"); + assert!(run.ledger.eof()); + assert!(!run.ledger.stop_latched()); + + // A taken-back send. + let mut run = Run::default(); + run.arm("u"); + run.stop("stop-1").expect("armed"); + run.ledger.undo_arm("u"); + assert!(!run.ledger.stop_latched()); +} + +/// A stale latch never silences a later turn: the result after the target's +/// end rings. +#[test] +fn a_cleared_latch_never_silences_the_next_turn() { + let mut run = Run::default(); + run.running("a"); + run.stop("stop-1").expect("armed"); + run.feed_all([receipt("stop-1", &[]), aborted(), command("a", "cancelled")]); + run.running("b").feed(aborted()); + assert_eq!(run.verdicts.len(), 2); + assert_eq!(run.verdicts[0], ResultVerdict::Silent); + assert!( + matches!(run.verdicts[1], ResultVerdict::Failed { .. }), + "{:?}", + run.verdicts + ); +} + +/// A second Stop before the first one's receipt keeps the first one's +/// targets: everything the user stopped stays silent. +#[test] +fn a_second_stop_keeps_the_first_ones_targets() { + let mut run = Run::default(); + run.running("a"); + run.stop("stop-1").expect("armed"); + run.arm("b") + .feed_all([command("b", "queued"), command("b", "started")]); + let second = run.stop("stop-2").expect("armed"); + assert_eq!(second.commands, vec!["a".to_string(), "b".to_string()]); + run.feed_all([receipt("stop-1", &[]), receipt("stop-2", &[]), aborted()]); + assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e312db399..ed3631379 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -62,11 +62,11 @@ use freshell_codex::launch_lifecycle::{ use freshell_codex::launch_plan::{codex_sidecar_spawn_spec, CodexSidecarLaunchContext}; use freshell_codex::transport::{reap_owned_codex_sidecars, TungsteniteTransport}; use freshell_codex::{ - mint_ownership_id, next_monotonic_turn_complete_at, normalize_codex_thread_status, - normalize_freshcodex_effort, normalize_freshcodex_model, read_rollout_history_mode, - to_codex_reasoning_effort, CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, - CodexNotification, CodexStatus, CodexSubscription, HistoryMode, StartThreadParams, - StartTurnParams, ThreadForkParams, CODEX_SIDECAR_OWNERSHIP_ENV, + mint_ownership_id, normalize_codex_thread_status, normalize_freshcodex_effort, + normalize_freshcodex_model, read_rollout_history_mode, to_codex_reasoning_effort, + CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, CodexNotification, CodexStatus, + CodexSubscription, HistoryMode, StartThreadParams, StartTurnParams, ThreadForkParams, + CODEX_SIDECAR_OWNERSHIP_ENV, }; use freshell_protocol::{ ErrorCode, ErrorMsg, FreshAgentAttach, FreshAgentCompact, FreshAgentConfigure, @@ -302,11 +302,10 @@ struct CodexConsumerRuntime { /// Seed the quiet window from `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS` (positive integer /// milliseconds; missing or unparseable falls back to [`DEFAULT_CODEX_QUIET_WINDOW_MS`]). fn codex_quiet_window_ms_from_env() -> u64 { - std::env::var("FRESHELL_FRESHCODEX_QUIET_WINDOW_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .filter(|ms| *ms > 0) - .unwrap_or(DEFAULT_CODEX_QUIET_WINDOW_MS) + crate::quiet_deadman::window_from_env( + "FRESHELL_FRESHCODEX_QUIET_WINDOW_MS", + DEFAULT_CODEX_QUIET_WINDOW_MS, + ) } /// Shared, cheaply-cloneable freshcodex WS state (mergeable into the server app + WsState). @@ -674,46 +673,23 @@ enum CodexTeardownSettlement { PlatformLimited, } -/// Per-session state of the wedged-sidecar quiet deadman: while a turn is in flight -/// (the turn-in-flight latch armed), a window is armed; if no lane-visible push -/// notification feeds it before the deadline elapses, the pane is flagged `stuck` -/// (via a `freshAgent.status` broadcast) AND the unified `freshAgent.turn.complete` -/// attention edge rings on the session's shared clock — a wedged turn ENDED as far -/// as the user can tell, the same binary "needs attention" signal as any other -/// unwitnessed turn end. Any feed resets the window (generation bump) and resolves a -/// flagged state; every mutation bumps `generation`, so a stale waiter task observes -/// the mismatch and exits without firing. +/// The wedged-sidecar quiet deadman (the shared [`crate::quiet_deadman`] +/// state machine). While a turn is in flight (the turn-in-flight latch +/// armed), a window is armed; if no lane-visible push notification feeds it +/// before the deadline elapses, the pane is flagged `stuck` (via a +/// `freshAgent.status` broadcast) AND the unified `freshAgent.turn.complete` +/// attention edge rings on the session's shared clock — a wedged turn ENDED as +/// far as the user can tell, the same binary "needs attention" signal as any +/// other unwitnessed turn end. /// -/// LOCK DISCIPLINE: this is a plain `std::sync::Mutex` that is only ever held briefly -- -/// never across an `.await`, never while broadcasting. When it is nested with the -/// session's `active_turn` mutex (the U-1 mid-flight seed), `quiet_deadman` is ALWAYS -/// the outer lock and `active_turn` the inner one (the file-wide order). +/// LOCK DISCIPLINE (codex): when the deadman's lock is nested with the +/// session's `active_turn` mutex (the U-1 mid-flight seed), `quiet_deadman` is +/// ALWAYS the outer lock and `active_turn` the inner one (the file-wide order). /// -/// `pub(crate)` only because the `pub(crate)` [`spawn_exit_watcher`] names it in its -/// signature (its cross-module callers construct a deadman with -/// [`QuietDeadman::new_shared`]); no field is ever exposed. -pub(crate) struct QuietDeadman { - /// Bumped on EVERY state mutation (arm, feed/reset, disarm, resolve). A waiter - /// fires only when the generation it was armed under is still current. - generation: u64, - /// The armed deadline; `Some` only while a turn is in flight (or was when the - /// epoch fired -- firing leaves it set so snapshot polls never re-arm a flagged - /// epoch). - deadline: Option, - /// When the deadman fired (the pane is flagged `stuck`); `None` while quiet. - stuck_since: Option, -} - -impl QuietDeadman { - /// A fresh, disarmed deadman behind its shared lock (the `active_turn` field pattern). - pub(crate) fn new_shared() -> Arc> { - Arc::new(StdMutex::new(Self { - generation: 0, - deadline: None, - stuck_since: None, - })) - } -} +/// Re-exported `pub(crate)` because the `pub(crate)` [`spawn_exit_watcher`] +/// names it in its signature (its cross-module callers construct a deadman +/// with [`QuietDeadman::new_shared`]). +pub(crate) use crate::quiet_deadman::QuietDeadman; /// The result of [`FreshCodexState::ensure_session_alive`]. #[derive(Debug, PartialEq)] @@ -7775,14 +7751,12 @@ impl FreshCodexState { /// after it seeds an observed mid-flight turn (the U-1 repair). fn arm_codex_quiet_if_absent(&self, thread_id: &str, quiet: &Arc>) { let window_ms = self.codex_quiet_window_ms(); - let generation = { - let mut q = quiet.lock().expect("quiet deadman lock"); - if q.deadline.is_some() || q.stuck_since.is_some() { - return; - } - q.generation += 1; - q.deadline = Some(tokio::time::Instant::now() + Duration::from_millis(window_ms)); - q.generation + let Some(generation) = quiet + .lock() + .expect("quiet deadman lock") + .arm_if_absent(Duration::from_millis(window_ms)) + else { + return; }; tracing::info!(provider = PROVIDER, session_id = %thread_id, phase = "armed", quiet_window_ms = window_ms, "freshagent.codex.quiet_deadman"); tokio::spawn( @@ -7791,7 +7765,8 @@ impl FreshCodexState { ); } - /// One quiet-window waiter for one armed deadman epoch. Sleeps until the armed + /// One quiet-window waiter for one armed deadman epoch (the shared + /// [`crate::quiet_deadman::wait_for_quiet_deadline`]). Sleeps until the armed /// deadline, then fires ONLY if nothing changed since its arming (generation and /// deadline still current, turn still in flight, not already flagged) — a stale /// waiter (generation mismatch) or a disarmed/untracked session exits without any @@ -7805,34 +7780,19 @@ impl FreshCodexState { /// orphaned waiter on stale per-session Arcs would broadcast a phantom stuck /// status AND a phantom attention edge after the deadline. async fn watch_codex_quiet_deadline(self, thread_id: String, generation: u64) { - // Snapshot the per-session handles + the armed deadline WITHOUT holding the - // sessions lock across the sleep (it is never held across an await). + // Snapshot the per-session handles WITHOUT holding the sessions lock across + // the sleep (it is never held across an await). let Some((_active_turn, quiet, turn_in_flight, last_turn_complete_at, _retired_turn_id)) = self.quiet_handles(&thread_id).await else { return; }; - let Some(deadline) = quiet.lock().expect("quiet deadman lock").deadline else { - return; - }; - tokio::time::sleep_until(deadline).await; - // Fire iff NOTHING changed since the arm: same generation, same armed deadline, - // turn still in flight (the dispatch-time latch — the lane's turn-in-flight - // authority), not already flagged. - let fired = { - let mut q = quiet.lock().expect("quiet deadman lock"); - let has_turn = turn_in_flight.load(Ordering::SeqCst); - if has_turn - && q.generation == generation - && q.deadline == Some(deadline) - && q.stuck_since.is_none() - { - q.stuck_since = Some(Instant::now()); - true - } else { - false - } - }; + // "Turn still in flight" reads the dispatch-time latch — the lane's + // turn-in-flight authority — at the deadline. + let fired = crate::quiet_deadman::wait_for_quiet_deadline(&quiet, generation, || { + turn_in_flight.load(Ordering::SeqCst) + }) + .await; if !fired { return; } @@ -7975,11 +7935,7 @@ impl FreshCodexState { // The snapshot overlay mirrors the wire contract: `stuck` while the deadman // has flagged the session and the fresh read still shows it running. let stuck = handles.as_ref().is_some_and(|(_, quiet, _, _, _)| { - quiet - .lock() - .expect("quiet deadman lock") - .stuck_since - .is_some() + quiet.lock().expect("quiet deadman lock").is_stuck() }); // Kata 1wxv Task 5: the capability stamp is SESSION-SCOPED by the // recorded DURABLE history mode (correction item 1, r3 — never live-probed @@ -10478,35 +10434,23 @@ fn note_codex_activity( ) { let window_ms = state.codex_quiet_window_ms(); let window = Duration::from_millis(window_ms); - let (was_stuck, arm) = { + let feed = { let mut q = quiet.lock().expect("quiet deadman lock"); - let was_stuck = q.stuck_since.take().is_some(); - let has_turn = turn_in_flight.load(Ordering::SeqCst); - let arm = if has_turn { - // A reset of an already-armed window is NOT a fresh arming; `armed` logs - // only on the disarmed→armed transition (no per-feed log spam). - let fresh_arm = q.deadline.is_none() && !was_stuck; - q.generation += 1; - q.deadline = Some(tokio::time::Instant::now() + window); - Some((q.generation, fresh_arm)) - } else { - q.generation += 1; - q.deadline = None; - None - }; - (was_stuck, arm) + q.feed(turn_in_flight.load(Ordering::SeqCst), window) }; - if was_stuck { + if feed.resolved { tracing::info!(provider = PROVIDER, session_id = %thread_id, phase = "resolved", "freshagent.codex.quiet_deadman"); } - if let Some((generation, fresh_arm)) = arm { - if fresh_arm { + if let Some(armed) = feed.armed { + // A reset of an already-armed window is NOT a fresh arming; `armed` logs + // only on the disarmed→armed transition (no per-feed log spam). + if armed.fresh { tracing::info!(provider = PROVIDER, session_id = %thread_id, phase = "armed", quiet_window_ms = window_ms, "freshagent.codex.quiet_deadman"); } tokio::spawn( state .clone() - .watch_codex_quiet_deadline(thread_id.to_string(), generation), + .watch_codex_quiet_deadline(thread_id.to_string(), armed.generation), ); } } @@ -10523,12 +10467,7 @@ fn disarm_codex_quiet( thread_id: &str, resolution: &'static str, ) { - let was_stuck = { - let mut q = quiet.lock().expect("quiet deadman lock"); - q.generation += 1; - q.deadline = None; - q.stuck_since.take().is_some() - }; + let was_stuck = quiet.lock().expect("quiet deadman lock").disarm(); if was_stuck { tracing::info!(provider = PROVIDER, session_id = %thread_id, phase = "resolved", resolution, "freshagent.codex.quiet_deadman"); } @@ -11139,11 +11078,9 @@ fn adapter_event_to_frame(event: &CodexAdapterEvent, thread_id: &str) -> Option< } Value::Object(map) } - CodexAdapterEvent::TurnComplete { session_id, at } => json!({ - "type": "freshAgent.turn.complete", - "sessionId": session_id, - "at": at, - }), + CodexAdapterEvent::TurnComplete { session_id, at } => { + crate::attention::turn_complete_event(session_id, *at) + } CodexAdapterEvent::Status { session_id, status } => json!({ "type": "freshAgent.status", "sessionId": session_id, @@ -11164,26 +11101,19 @@ fn adapter_event_to_frame(event: &CodexAdapterEvent, thread_id: &str) -> Option< /// sidecar that died with a turn in flight) and the quiet-deadman fire (a /// wedged turn the user can only read as ended). The `at` comes from the /// session's ONE shared turn-complete clock (the same `Arc` the consumer's -/// [`CodexSubscription`] mints on), so synthesized and ordinary edges share a -/// single strictly-monotonic timeline per session and can never collide or -/// regress. +/// [`CodexSubscription`] mints on, adopted as a shared +/// [`crate::attention::AttentionClock`]), so synthesized and ordinary edges +/// share a single strictly-monotonic timeline per session and can never +/// collide or regress. The shared minter logs the edge (DIAG-01: session_id, +/// source and `at`, never the turn's text/response content). fn mint_synthesized_attention_edge( last_turn_complete_at: &Arc>>, thread_id: &str, broadcast_tx: &tokio::sync::broadcast::Sender, source: &'static str, ) { - let at = { - let mut guard = last_turn_complete_at - .lock() - .expect("last_turn_complete_at mutex"); - let at = next_monotonic_turn_complete_at(*guard, now_ms()); - *guard = Some(at); - at - }; - // DIAG-01: the attention edge only -- session_id + source, never the - // turn's text/response content. - tracing::info!(provider = PROVIDER, session_id = %thread_id, source, "freshagent.turn.complete"); + let clock = crate::attention::AttentionClock::from(Arc::clone(last_turn_complete_at)); + let at = crate::attention::mint_synthesized_edge(&clock, PROVIDER, thread_id, source); let event = CodexAdapterEvent::TurnComplete { session_id: thread_id.to_string(), at, diff --git a/crates/freshell-freshagent/src/codex/controls.rs b/crates/freshell-freshagent/src/codex/controls.rs index 61ead0fc3..9b12dde6f 100644 --- a/crates/freshell-freshagent/src/codex/controls.rs +++ b/crates/freshell-freshagent/src/codex/controls.rs @@ -9,7 +9,9 @@ pub(super) type ControlRegistry = #[derive(Default)] pub(super) struct PendingControls { requests: Vec, - last_waiting_at: Option, + /// The session's waiting-edge clock: each 0→≥1 pending transition rings + /// `freshAgent.turn.waiting` at a strictly-monotonic `at`. + waiting_clock: crate::attention::AttentionClock, } struct PendingRequest { @@ -189,11 +191,7 @@ impl FreshCodexState { pending.requests.push(request); self.control_event(session_id, event); if was_empty { - let at = freshell_codex::next_monotonic_turn_complete_at( - pending.last_waiting_at, - now_ms(), - ); - pending.last_waiting_at = Some(at); + let at = pending.waiting_clock.mint(); self.control_event(session_id, json!({ "type": "freshAgent.turn.waiting", "sessionId": session_id, "at": at })); } tracing::info!( diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ade478f29..dff7664ca 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -36,8 +36,12 @@ //! [`FreshAgentState::shutdown`] (SIGTERM + the `/proc` ownership sweep) and, as a //! backstop, by the harness sentinel sweep — no orphans. +/// The per-session attention clock and edge builders shared by the fresh-agent +/// lanes (design 2026-10-22 §3.2, §5.5). +pub(crate) mod attention; pub mod claude; pub(crate) mod claude_dead_sessions; +pub(crate) mod claude_edges; pub mod claude_freshell_mcp; pub(crate) mod claude_handshake; pub(crate) mod claude_launch; @@ -50,6 +54,7 @@ pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; pub mod claude_spawn; pub(crate) mod claude_test_holds; +pub(crate) mod claude_turns; pub mod codex; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; @@ -68,6 +73,9 @@ mod pane_resize; #[cfg(all(test, unix))] #[path = "../tests/support/process_probe.rs"] mod process_probe; +/// The wedged-runtime quiet deadman shared by the fresh-agent lanes (design +/// 2026-10-22 §3.2, §5.6). +pub(crate) mod quiet_deadman; pub mod rollback_record; pub mod session_handoff; pub mod session_lease; @@ -80,7 +88,9 @@ pub mod terminal_tabs; #[cfg(test)] mod test_environment; -pub use claude::{ClaudeCreateOutcome, FreshClaudeState}; +pub use claude::{ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; +pub use claude_edges::{AttentionObserver, ClaudeTurnEvent}; +pub use claude_turns::TurnOutcome; // Kata 09v1 + SYNC-06 + G28: the claude_snapshot items visible outside this // crate — the ONE shared claude store-root rule (`claude_home_candidates`, // also read by freshell-ws's PTY truth probe), the three-state transcript diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 5f58d3ec3..b75687db6 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -65,6 +65,11 @@ use tokio::sync::broadcast::error::RecvError; use tokio::sync::Mutex as TokioMutex; use freshell_codex::next_monotonic_turn_complete_at; +// `sdk.turn.complete → freshAgent.turn.complete` (sdk-events.ts:71-72; the +// unified turn-end attention edge — emitted for ANY turn end except a +// user-initiated interrupt, deliberately diverging from the legacy +// status-guarded positive-completion chime of adapter.ts:377-381). +use crate::attention::turn_complete_event; use freshell_opencode::{ normalize_opencode_effort, normalize_opencode_model, ChangedReason, DaemonSignal, OpencodeServeManager, SdkProviderEvent, ServeError, SessionSignal, SnapshotStatus, @@ -8852,14 +8857,6 @@ fn error_event(session_id: &str, message: &str) -> Value { json!({ "type": "freshAgent.error", "sessionId": session_id, "message": message }) } -/// `sdk.turn.complete → freshAgent.turn.complete` (sdk-events.ts:71-72; the -/// unified turn-end attention edge — emitted for ANY turn end except a -/// user-initiated interrupt, deliberately diverging from the legacy -/// status-guarded positive-completion chime of adapter.ts:377-381). -fn turn_complete_event(session_id: &str, at: i64) -> Value { - json!({ "type": "freshAgent.turn.complete", "sessionId": session_id, "at": at }) -} - /// Stamp the session's strictly-monotonic turn-complete clock and broadcast the /// unified `freshAgent.turn.complete` edge — the shared mint of /// [`settle_turn_outcome`]'s tail, so a later edge can never collide with or diff --git a/crates/freshell-freshagent/src/quiet_deadman.rs b/crates/freshell-freshagent/src/quiet_deadman.rs new file mode 100644 index 000000000..1af478b4b --- /dev/null +++ b/crates/freshell-freshagent/src/quiet_deadman.rs @@ -0,0 +1,364 @@ +//! The wedged-runtime quiet deadman every fresh-agent lane shares (design +//! 2026-10-22 §3.2, §5.6; moved out of `codex.rs`). +//! +//! A wedged-but-ALIVE sidecar (process running, no events, no exit) must not +//! leave a pane asserting "working" forever. While a turn is in flight, the +//! lane feeds the deadman on every piece of lane-visible activity and each feed +//! re-arms a quiet window; a waiter ([`wait_for_quiet_deadline`]) fires iff the +//! window elapses with no feed and the turn still in flight. The lane then +//! flags the pane `stuck` and rings its attention edge. Any later feed resolves +//! the flag. +//! +//! This module owns only the state machine and the waiter. The lane decides +//! what counts as activity, what "in flight" means, how to broadcast the stuck +//! status and the edge, and how to log each phase. +//! +//! Every mutation bumps a generation, so a waiter armed under an older +//! generation observes the mismatch and exits without firing. That keeps the +//! waiters from accumulating beyond the feeds in one window. +//! +//! LOCK DISCIPLINE: the deadman sits behind a plain `std::sync::Mutex` that is +//! only ever held briefly: never across an `.await`, never while broadcasting. + +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; + +/// Per-session state of the quiet deadman. No field is exposed: every change +/// goes through the methods below, each of which bumps the generation. +pub(crate) struct QuietDeadman { + /// Bumped on EVERY state mutation (arm, feed/reset, disarm, resolve). A + /// waiter fires only when the generation it was armed under is current. + generation: u64, + /// The armed deadline; `Some` only while a turn is in flight (or was when + /// the epoch fired: firing leaves it set so an arm-if-absent never re-arms + /// a flagged epoch). + deadline: Option, + /// When the deadman fired (the pane is flagged `stuck`); `None` while + /// quiet. + stuck_since: Option, +} + +/// What one [`QuietDeadman::feed`] did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct QuietFeed { + /// The feed cleared a fired epoch (the pane was flagged `stuck`). + pub(crate) resolved: bool, + /// The epoch the feed armed (a turn is in flight); the lane spawns a + /// waiter for it. `None` when the feed disarmed the window. + pub(crate) armed: Option, +} + +/// An armed quiet window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ArmedEpoch { + /// The generation a waiter for this window must still see to fire. + pub(crate) generation: u64, + /// `true` on the disarmed-to-armed transition; `false` when the feed + /// re-armed an armed or fired window (a reset, logged as no new arming). + pub(crate) fresh: bool, +} + +impl QuietDeadman { + /// A fresh, disarmed deadman behind its shared lock. + pub(crate) fn new_shared() -> Arc> { + Arc::new(StdMutex::new(Self { + generation: 0, + deadline: None, + stuck_since: None, + })) + } + + /// Lane-visible activity proves the sidecar is alive. Resolves a fired + /// epoch first, then re-arms the window `window` from now while a turn is + /// `in_flight`, or disarms it when none is. + pub(crate) fn feed(&mut self, in_flight: bool, window: Duration) -> QuietFeed { + let resolved = self.stuck_since.take().is_some(); + self.generation += 1; + let armed = if in_flight { + let fresh = self.deadline.is_none() && !resolved; + self.deadline = Some(tokio::time::Instant::now() + window); + Some(ArmedEpoch { + generation: self.generation, + fresh, + }) + } else { + self.deadline = None; + None + }; + QuietFeed { resolved, armed } + } + + /// Arm the window ONLY if none is armed and the epoch has not fired (a + /// poll that observes a turn in flight must never reset an armed window). + /// Returns the armed generation, or `None` when nothing changed. + pub(crate) fn arm_if_absent(&mut self, window: Duration) -> Option { + if self.deadline.is_some() || self.stuck_since.is_some() { + return None; + } + self.generation += 1; + self.deadline = Some(tokio::time::Instant::now() + window); + Some(self.generation) + } + + /// Disarm on a turn's end or the session's teardown: invalidates every + /// outstanding waiter. Returns whether a fired epoch was resolved. + pub(crate) fn disarm(&mut self) -> bool { + self.generation += 1; + self.deadline = None; + self.stuck_since.take().is_some() + } + + /// Whether the deadman has fired and nothing has resolved it since. + pub(crate) fn is_stuck(&self) -> bool { + self.stuck_since.is_some() + } + + /// Fire iff NOTHING changed since the epoch was armed: the same + /// generation and deadline, a turn still `in_flight`, not already fired. + /// Returns whether it fired (and flagged the session stuck). + pub(crate) fn try_fire( + &mut self, + generation: u64, + deadline: tokio::time::Instant, + in_flight: bool, + ) -> bool { + if in_flight + && self.generation == generation + && self.deadline == Some(deadline) + && self.stuck_since.is_none() + { + self.stuck_since = Some(Instant::now()); + true + } else { + false + } + } +} + +/// One waiter for one armed epoch: sleeps until the armed deadline, then fires +/// iff the epoch is still current ([`QuietDeadman::try_fire`]), reading +/// `in_flight` under the deadman's lock at the deadline. Returns whether it +/// fired; the caller then flags the pane and rings the edge. A deadman with +/// nothing armed returns `false` at once. +/// +/// The waiter validates only the captured deadman state, never that the lane +/// still holds this session: whoever replaces a session record must disarm the +/// old record's deadman first, or an orphaned waiter fires for it. +pub(crate) async fn wait_for_quiet_deadline( + quiet: &Arc>, + generation: u64, + in_flight: impl FnOnce() -> bool, +) -> bool { + let Some(deadline) = quiet.lock().expect("quiet deadman lock").deadline else { + return false; + }; + tokio::time::sleep_until(deadline).await; + let mut q = quiet.lock().expect("quiet deadman lock"); + q.try_fire(generation, deadline, in_flight()) +} + +/// A quiet window in milliseconds from the environment variable `var`: a +/// positive integer, else `default_ms` (missing, unparseable, or zero). +pub(crate) fn window_from_env(var: &str, default_ms: u64) -> u64 { + parse_window_ms(std::env::var(var).ok().as_deref(), default_ms) +} + +fn parse_window_ms(raw: Option<&str>, default_ms: u64) -> u64 { + raw.and_then(|raw| raw.parse::().ok()) + .filter(|ms| *ms > 0) + .unwrap_or(default_ms) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + const WINDOW: Duration = Duration::from_millis(100); + + #[test] + fn a_feed_with_a_turn_in_flight_arms_a_fresh_epoch_and_later_feeds_reset_it() { + let quiet = QuietDeadman::new_shared(); + let mut q = quiet.lock().unwrap(); + assert_eq!(q.deadline, None, "a new deadman is disarmed"); + + let before = tokio::time::Instant::now(); + let first = q.feed(true, WINDOW); + assert!(!first.resolved); + let armed = first.armed.expect("a turn in flight arms the window"); + assert!(armed.fresh, "disarmed -> armed is a fresh arming"); + let deadline = q.deadline.expect("armed"); + assert!(deadline >= before + WINDOW); + + let second = q.feed(true, WINDOW); + let reset = second.armed.expect("a feed re-arms the window"); + assert!(!reset.fresh, "re-arming an armed window is a reset"); + assert!(reset.generation > armed.generation); + assert!(q.deadline.expect("still armed") >= deadline); + assert!(!q.is_stuck()); + } + + #[test] + fn a_feed_with_no_turn_in_flight_disarms() { + let quiet = QuietDeadman::new_shared(); + let mut q = quiet.lock().unwrap(); + q.feed(true, WINDOW); + let feed = q.feed(false, WINDOW); + assert_eq!(feed.armed, None); + assert!(!feed.resolved); + assert_eq!(q.deadline, None); + } + + #[tokio::test(start_paused = true)] + async fn the_waiter_fires_once_when_the_window_elapses_with_no_feed() { + let quiet = QuietDeadman::new_shared(); + let generation = quiet + .lock() + .unwrap() + .feed(true, WINDOW) + .armed + .expect("armed") + .generation; + let started = tokio::time::Instant::now(); + + let fired = wait_for_quiet_deadline(&quiet, generation, || true).await; + + assert!(fired, "a silent window with a turn in flight fires"); + assert!(tokio::time::Instant::now() >= started + WINDOW); + let mut q = quiet.lock().unwrap(); + assert!(q.is_stuck()); + let deadline = q.deadline.expect("a fired epoch keeps its deadline"); + assert!( + !q.try_fire(generation, deadline, true), + "a fired epoch never fires twice" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_feed_supersedes_the_waiter_armed_before_it() { + let quiet = QuietDeadman::new_shared(); + let stale = quiet.lock().unwrap().feed(true, WINDOW).armed.unwrap(); + let stale_waiter = tokio::spawn({ + let quiet = Arc::clone(&quiet); + async move { wait_for_quiet_deadline(&quiet, stale.generation, || true).await } + }); + tokio::time::sleep(WINDOW / 2).await; + let current = quiet.lock().unwrap().feed(true, WINDOW).armed.unwrap(); + + assert!( + !stale_waiter.await.unwrap(), + "the stale epoch's waiter exits without firing" + ); + assert!(!quiet.lock().unwrap().is_stuck()); + assert!(wait_for_quiet_deadline(&quiet, current.generation, || true).await); + assert!(quiet.lock().unwrap().is_stuck()); + } + + #[tokio::test(start_paused = true)] + async fn the_waiter_does_not_fire_when_no_turn_is_in_flight_at_the_deadline() { + let quiet = QuietDeadman::new_shared(); + let generation = quiet + .lock() + .unwrap() + .feed(true, WINDOW) + .armed + .unwrap() + .generation; + let in_flight = Arc::new(AtomicBool::new(true)); + let waiter = tokio::spawn({ + let quiet = Arc::clone(&quiet); + let in_flight = Arc::clone(&in_flight); + async move { + wait_for_quiet_deadline(&quiet, generation, || in_flight.load(Ordering::SeqCst)) + .await + } + }); + // The turn ends mid-window without any lane-visible feed (e.g. a + // requested stop): the waiter reads the latch at the deadline. + tokio::time::sleep(WINDOW / 2).await; + in_flight.store(false, Ordering::SeqCst); + + assert!(!waiter.await.unwrap()); + assert!(!quiet.lock().unwrap().is_stuck()); + } + + #[tokio::test(start_paused = true)] + async fn the_waiter_returns_without_firing_when_nothing_is_armed() { + let quiet = QuietDeadman::new_shared(); + assert!(!wait_for_quiet_deadline(&quiet, 0, || true).await); + assert!(!quiet.lock().unwrap().is_stuck()); + } + + #[tokio::test(start_paused = true)] + async fn disarm_invalidates_the_waiter_and_resolves_a_fired_epoch() { + let quiet = QuietDeadman::new_shared(); + let first = quiet.lock().unwrap().feed(true, WINDOW).armed.unwrap(); + assert!(wait_for_quiet_deadline(&quiet, first.generation, || true).await); + + assert!( + quiet.lock().unwrap().disarm(), + "disarm resolves the stuck flag" + ); + let mut q = quiet.lock().unwrap(); + assert!(!q.is_stuck()); + assert_eq!(q.deadline, None); + assert!(!q.disarm(), "nothing left to resolve"); + + let second = q.feed(true, WINDOW).armed.unwrap(); + let deadline = q.deadline.unwrap(); + q.disarm(); + assert!( + !q.try_fire(second.generation, deadline, true), + "a waiter armed before the disarm never fires" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_feed_after_a_fire_resolves_stuck_and_rearms_as_a_reset() { + let quiet = QuietDeadman::new_shared(); + let first = quiet.lock().unwrap().feed(true, WINDOW).armed.unwrap(); + assert!(wait_for_quiet_deadline(&quiet, first.generation, || true).await); + + let feed = quiet.lock().unwrap().feed(true, WINDOW); + + assert!(feed.resolved, "activity after the fire resolves the flag"); + let rearmed = feed.armed.expect("the turn is still in flight"); + assert!( + !rearmed.fresh, + "re-arming a fired epoch is not a fresh arming" + ); + assert!(!quiet.lock().unwrap().is_stuck()); + assert!(wait_for_quiet_deadline(&quiet, rearmed.generation, || true).await); + } + + #[tokio::test(start_paused = true)] + async fn arm_if_absent_never_resets_an_armed_or_fired_epoch() { + let quiet = QuietDeadman::new_shared(); + { + let mut q = quiet.lock().unwrap(); + let generation = q.arm_if_absent(WINDOW).expect("arms a disarmed deadman"); + let deadline = q.deadline.unwrap(); + assert_eq!(q.arm_if_absent(WINDOW), None, "already armed"); + assert_eq!(q.deadline, Some(deadline), "the armed window is not reset"); + assert!(q.try_fire(generation, deadline, true)); + } + let mut q = quiet.lock().unwrap(); + assert_eq!( + q.arm_if_absent(WINDOW), + None, + "a fired epoch is not re-armed" + ); + q.disarm(); + assert!(q.arm_if_absent(WINDOW).is_some(), "disarmed again: arms"); + } + + #[test] + fn window_ms_parsing_takes_positive_integers_and_falls_back_otherwise() { + assert_eq!(parse_window_ms(None, 600_000), 600_000); + assert_eq!(parse_window_ms(Some("250"), 600_000), 250); + assert_eq!(parse_window_ms(Some("0"), 600_000), 600_000); + assert_eq!(parse_window_ms(Some("-5"), 600_000), 600_000); + assert_eq!(parse_window_ms(Some("ten"), 600_000), 600_000); + assert_eq!(parse_window_ms(Some(""), 600_000), 600_000); + } +} diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index 042de35db..c24aad32f 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -17,10 +17,9 @@ //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected //! — every dispatched line of JS is the real source, read at test time. //! -//! Observable contract (fire-and-forget interrupt, per the comment above -//! `handleInterrupt`): an interrupt for an unknown session emits an -//! `sdk.error "session not found"` frame on stdout. Pre-fix behavior was a -//! stderr `unknown request type: interrupt` log and NO stdout frame. +//! Protocol v2 answers an interrupt for an unknown session with a failed +//! `sdk.interrupt_settled` carrying the submitted Stop id. A missing dispatch +//! produces no stdout response; a generic error cannot settle the Stop latch. //! //! Requires `node` on PATH — the same requirement the crate's existing //! fake-sidecar tests already impose. @@ -168,22 +167,21 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let mut stdin = sidecar.child.stdin.take().expect("piped stdin"); stdin - .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\"}\n") + .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\",\"stopId\":\"missing-session-stop\"}\n") .expect("write interrupt frame"); stdin.flush().expect("flush interrupt frame"); - // Post-fix observable: handleInterrupt emits the session-not-found sdk.error - // frame on stdout. Pre-fix: the frame is dropped by the `default:` arm and - // stderr logs `unknown request type: interrupt` instead. - let mut error_frame: Option = None; + // The real dispatch must settle this exact Stop, even when its session is + // missing. The old default arm dropped it without answering. + let mut settled_frame: Option = None; let deadline = std::time::Instant::now() + Duration::from_secs(30); - while error_frame.is_none() { + while settled_frame.is_none() { let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else { break; }; match rx.recv_timeout(remaining) { Ok(("stdout", line)) => { - error_frame = + settled_frame = Some(serde_json::from_str(&line).expect("sidecar stdout is newline-JSON")); } Ok((_, line)) => stderr_lines.push(line), @@ -200,10 +198,17 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { "pre-fix behavior: the interrupt frame fell into the dispatch default arm and was \ dropped; stderr: {stderr_lines:?}" ); - let frame = error_frame.expect( + let frame = settled_frame.expect( "no stdout frame arrived: the interrupt request was not dispatched to handleInterrupt", ); - assert_eq!(frame["type"], "sdk.error", "frame: {frame}"); + assert_eq!(frame["type"], "sdk.interrupt_settled", "frame: {frame}"); assert_eq!(frame["sessionId"], "nope", "frame: {frame}"); - assert_eq!(frame["message"], "session not found", "frame: {frame}"); + assert_eq!(frame["stopId"], "missing-session-stop", "frame: {frame}"); + assert_eq!(frame["ok"], false, "frame: {frame}"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "a refused Stop includes its reason: {frame}" + ); } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 66bcfb67b..1d4ed6484 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -554,7 +554,10 @@ impl FreshAgentTransport for HostedTransport { }), }; match &self.state { - ProviderState::Claude(state) => state.handle_send(send).await, + // The acceptance is read from the bus below, as for every provider. + ProviderState::Claude(state) => { + state.handle_send(send).await; + } ProviderState::Codex(state) => state.handle_send(send).await, ProviderState::Opencode { runtime, .. } => runtime.handle_send(send).await, } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 186694eee..804836cae 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -2000,7 +2000,10 @@ async fn handle_client_text( } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); - Box::pin(async move { fresh_claude.handle_send(send).await }) + // The WS lane answers on the bus; the outcome is for in-process callers. + Box::pin(async move { + fresh_claude.handle_send(send).await; + }) } freshell_protocol::AgentProvider::Opencode => { let fresh_opencode = state.fresh_opencode.clone(); diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index 565bab14d..5c7f1ca61 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -29,8 +29,9 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// the SAME newline-JSON protocol `spawn_sidecar()` drives the vendored package with. /// On `create` it replies `created`, then `sdk.session.init` echoing the resume boot's id /// as the durable `cliSessionId` (resume continuity -- exactly what the real sidecar's -/// SDK init does), then `sdk.status idle`; on `send` it emits `sdk.status busy` (the -/// Task 10b event-after-rebind knob); on `shutdown` it exits. Every inbound request is +/// SDK init does), then `sdk.status idle`; on `send` it emits an `sdk.assistant` content +/// frame (the Task 10b event-after-rebind knob; the server publishes statuses itself, so +/// a sidecar status is never forwarded, C17); on `shutdown` it exits. Every inbound request is /// appended (with the sidecar's pid) to the JSONL file named by /// `FAKE_SIDECAR_REQUEST_LOG` -- distinct pids == spawn count, `msg.type` rows prove /// what each sidecar actually received. @@ -64,7 +65,7 @@ rl.on('line', (line) => { process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'send') { - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'busy' }) + '\n') + process.stdout.write(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'pong' }] }) + '\n') } else if (msg.type === 'shutdown') { process.exit(0) } @@ -608,12 +609,10 @@ async fn send_by_durable_id_routes_to_the_live_session() { .await; assert_eq!(accepted["sessionId"], DURABLE_FALLBACK); - // The sidecar answers every `send` with `sdk.status busy` -- awaiting it proves the - // sidecar PROCESSED the routed send (the accepted broadcast races the log append). + // The sidecar answers every `send` with an assistant frame -- awaiting it proves + // the sidecar PROCESSED the routed send (the accepted broadcast races the log append). await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.status" - && v["event"]["status"] == "busy" + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.assistant" }) .await; @@ -655,7 +654,7 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { }) .await; - // Drive the fake to emit an sdk.status (it answers every `send` with status busy). + // Drive the fake to emit a content frame (it answers every `send` with one). send_json( &mut ws2, &serde_json::json!({ @@ -669,14 +668,12 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { ) .await; - let status = await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.status" - && v["event"]["status"] == "busy" + let event = await_frame(&mut ws2, Duration::from_secs(10), |v| { + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.assistant" }) .await; assert_eq!( - status["sessionId"], DURABLE_FALLBACK, + event["sessionId"], DURABLE_FALLBACK, "post-rebind envelopes must be stamped with the durable id" ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index 71a50f4f7..0cc848377 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -44,9 +44,9 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// - `first-prompt`: never before the first prompt runs, like the real CLI; /// - `create`: also right after `created`, like the e2e fake sidecar. /// -/// Every prompt is one turn: assistant text, a `success` result, one -/// `sdk.turn.complete` with a strictly increasing `at`, then idle. Each prompt's -/// text is appended to `FRESHELL_TEST_CLAUDE_SEND_LOG`. +/// Every prompt is one turn: assistant text, a `success` result (the server +/// rings it, C18), then idle. Each prompt's text is appended to +/// `FRESHELL_TEST_CLAUDE_SEND_LOG`. const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" import fs from 'node:fs' import readline from 'node:readline' @@ -56,7 +56,6 @@ const initAt = process.env.FRESHELL_TEST_CLAUDE_INIT_AT const sendLog = process.env.FRESHELL_TEST_CLAUDE_SEND_LOG const sessions = new Map() let counter = 0 -let lastAt = 0 const emit = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') function reportSession(sessionId, st) { @@ -86,11 +85,9 @@ rl.on('line', (line) => { reportSession(msg.sessionId, st) emit({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: `echo: ${msg.text}` }], model: 'fake-model' }) emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success' }) - lastAt = Math.max(Date.now(), lastAt + 1) - emit({ type: 'sdk.turn.complete', sessionId: msg.sessionId, at: lastAt }) emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) } else if (msg.type === 'interrupt') { - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: false, message: 'no in-flight SDK query' }) + emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: false, message: 'no in-flight SDK query' }) } else if (msg.type === 'shutdown') { process.exit(0) } diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 8ba66f76c..9d91492e6 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -25,7 +25,8 @@ //! (`server/fresh-agent/adapters/claude/adapter.ts:163-168`'s //! `interrupt(sessionId) { mapMissingResult(deps.sdkBridge.interrupt(sessionId), ...) }`, //! `server/sdk-bridge.ts:785-793`'s `sp.query.interrupt().catch(warn)`): a known session -//! forwards an `interrupt` request to the sidecar (observed here via a log file the fake +//! with a turn in flight (C18: an idle Stop sends nothing) forwards an `interrupt` +//! request to the sidecar (observed here via a log file the fake //! sidecar appends to -- the process-boundary analog of "the SDK's `query.interrupt()` //! was actually invoked"), and legacy's `mapMissingResult` throw (surfaced by //! `ws-handler.ts:3513-3514`'s `catch` as an `INTERNAL_ERROR` frame) is mirrored for an @@ -58,6 +59,7 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( /// - `{"type":"create",...}` -> replies `{"type":"created",requestId,sessionId}` /// with a fresh id every call (never replays), so a genuinely-new spawn is /// observable by session id alone. +/// - `{"type":"send",...}` -> ignored: the turn stays in flight. /// - `{"type":"interrupt",sessionId}` -> appends `sessionId` to /// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` (this test's observable proxy for "the /// sidecar's `query.interrupt()` was actually invoked"). @@ -87,7 +89,7 @@ rl.on('line', (line) => { if (log) fs.appendFileSync(log, `${msg.sessionId}\n`) // ep4-r1 F1 wire parity: settle the awaited interrupt (the real sidecar // emits this after query.interrupt() resolves). - console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: true })) + console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: true })) } else if (msg.type === 'shutdown') { process.exit(0) } @@ -399,6 +401,23 @@ async fn claude_interrupt_frame_reaches_the_sidecar_for_a_known_session() { }) .await; let session_id = created["sessionId"].as_str().unwrap().to_string(); + // A turn in flight (the fake never answers it): C18 sends a Stop only then. + send_json( + &mut ws, + &serde_json::json!({ + "type": "freshAgent.send", + "provider": "claude", + "sessionId": session_id, + "sessionType": "freshclaude", + "requestId": "req-claude-ws-send", + "text": "a turn to stop", + }), + ) + .await; + await_frame(&mut ws, Duration::from_secs(10), |v| { + v["type"] == "freshAgent.send.accepted" && v["requestId"] == "req-claude-ws-send" + }) + .await; send_json( &mut ws, diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index 16e0a472e..e6aa94ddc 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -255,9 +255,11 @@ async fn an_idle_cli_death_reports_the_exit_without_an_edge() { "{:?}", conn.frames ); + // The server announces the new session idle (C17), and it ends exited: + // never running or compacting. assert_eq!( statuses(&conn, &session_id), - vec!["exited".to_string()], + vec!["idle".to_string(), "exited".to_string()], "an idle session that ends reports exited; frames: {:?}", conn.frames ); diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle_capacity.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle_capacity.rs new file mode 100644 index 000000000..93194368e --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle_capacity.rs @@ -0,0 +1,89 @@ +//! A queued command stays attributable until its terminal lifecycle fact, even +//! when one session has more than the sidecar's former 256-id retention limit. +//! The real sidecar runs over the CLI simulator behind the real WS server. + +mod common; + +use std::time::Duration; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RIG_FRAME_BUDGET}; +use serde_json::json; + +const QUEUED_COMMANDS: usize = 257; + +async fn wait_for_results(conn: &mut RigConnection, count: usize) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") < count { + assert!( + tokio::time::Instant::now() < deadline, + "only {} of {count} results arrived; frames: {:?}", + conn.events_seen("freshAgent.result"), + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +async fn wait_for_idle(conn: &mut RigConnection, session_id: &str, result_count: usize) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let last_status = conn.frames.iter().rev().find_map(|frame| { + (frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.status" + && frame["sessionId"] == session_id) + .then(|| frame["event"]["status"].as_str()) + .flatten() + }); + if conn.events_seen("freshAgent.result") >= result_count && last_status == Some("idle") { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "the session did not become idle after {result_count} results; last status: {last_status:?}; frames: {:?}", + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_backlog_over_256_commands_returns_the_turn_ledger_to_idle() { + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_TIME_SCALE", "0")]).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-capacity").await; + + conn.send_text(&session_id, "send-hang", "HANG").await; + conn.wait_for_event("freshAgent.session.init").await; + + // Keep the first turn in flight while every following send waits in the + // CLI queue. Await each acceptance so every queued command reached the sidecar. + for index in 0..QUEUED_COMMANDS { + let request_id = format!("queued-{index}"); + conn.send_text(&session_id, &request_id, "queued command") + .await; + conn.wait_for(&format!("acceptance for {request_id}"), |frame| { + frame["type"] == "freshAgent.send.accepted" && frame["requestId"] == request_id + }) + .await; + } + + conn.send(json!({ + "type": "freshAgent.interrupt", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + })) + .await; + + // Stop ends the in-flight HANG command; all queued commands still run. + // Each terminal result must retire its own started UUID from the ledger. + let expected_results = QUEUED_COMMANDS + 1; + wait_for_results(&mut conn, expected_results).await; + wait_for_idle(&mut conn, &session_id, expected_results).await; + assert_eq!( + conn.events_seen("freshAgent.result"), + expected_results, + "the interrupted turn and every queued send settled" + ); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 281784af9..81f1d51a5 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -178,10 +178,10 @@ async fn sidecar_log_records_reach_the_server_log(kind: RigKind) { } /// Every attention edge is logged as it leaves the server (G14, design §5.5): -/// a forwarded sidecar `turn.complete` is the info event +/// the `turn.complete` the server mints for a result (C18) is the info event /// `freshagent.turn.complete` with the session, its type, the source and the /// edge's `at`. -async fn a_forwarded_turn_complete_is_logged(kind: RigKind) { +async fn a_minted_turn_complete_is_logged(kind: RigKind) { let logs = log_capture::install(); let rig = ClaudeRig::start(kind).await; let mut conn = rig.connect().await; @@ -202,7 +202,7 @@ async fn a_forwarded_turn_complete_is_logged(kind: RigKind) { assert_eq!(logged.level, Level::INFO, "{logged:?}"); assert_eq!(logged.field("provider"), Some("claude")); assert_eq!(logged.field("session_type"), Some("freshclaude")); - assert_eq!(logged.field("source"), Some("freshclaude/sidecar")); + assert_eq!(logged.field("source"), Some("result")); assert_eq!(logged.field("at"), Some(at.as_str())); conn.kill(&session_id).await; @@ -528,14 +528,14 @@ mod sim { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_forwarded_turn_complete_is_logged() { - super::a_forwarded_turn_complete_is_logged(RigKind::Sim).await; + async fn a_minted_turn_complete_is_logged() { + super::a_minted_turn_complete_is_logged(RigKind::Sim).await; } - /// A forwarded `turn.waiting` (a question raised mid-turn) is logged as - /// `freshagent.turn.waiting`. + /// The `turn.waiting` the server mints for a question raised mid-turn + /// (C18) is logged as `freshagent.turn.waiting`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_forwarded_turn_waiting_is_logged() { + async fn a_minted_turn_waiting_is_logged() { let logs = log_capture::install(); let program = json!({ "rules": [{ "on": "msg:prompt", "match": { "text": "please ask" }, @@ -566,7 +566,7 @@ mod sim { assert_eq!(logged.level, Level::INFO, "{logged:?}"); assert_eq!(logged.field("provider"), Some("claude")); assert_eq!(logged.field("session_type"), Some("freshclaude")); - assert_eq!(logged.field("source"), Some("freshclaude/sidecar")); + assert_eq!(logged.field("source"), Some("pending")); assert_eq!(logged.field("at"), Some(at.as_str())); conn.kill(&session_id).await; } @@ -606,8 +606,8 @@ mod cli { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_forwarded_turn_complete_is_logged() { - super::a_forwarded_turn_complete_is_logged(RigKind::Cli).await; + async fn a_minted_turn_complete_is_logged() { + super::a_minted_turn_complete_is_logged(RigKind::Cli).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index ebfa95503..20d1f2bdc 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -528,9 +528,7 @@ async fn undoing_the_only_turn_starts_a_fresh_conversation_and_redo_restores_it( /// so the fork's drops-turn guard is refused at boot. The undo answers /// ROLLBACK_REFUSED with a plain reason, is never retried without the guard, /// writes no rollback record, and the original stays live and takes a send. (A -/// send absorbed during a tool call is refused the same way by the CLI, pinned -/// by the `absorbed-undo` contract scenario; until the per-command ledger (C17) -/// such a session still reads busy, so its undo answers BUSY_TURN first, G08.) +/// send absorbed during a tool call is refused the same way, below.) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_undo_of_a_turn_claude_folded_a_message_into_is_refused_and_the_session_stays_live() { let rig = rollback_rig().await; @@ -590,6 +588,145 @@ async fn an_undo_of_a_turn_claude_folded_a_message_into_is_refused_and_the_sessi ); } +/// G08 at the rollback gate (C17: the gate reads the per-command ledger): a +/// send absorbed into a tool call (T10) is one of two commands the turn's one +/// result settles, so the session is idle afterwards and an undo reaches the +/// CLI instead of answering BUSY_TURN. The turn being undone absorbed the +/// second message, so the fork's drops-turn guard refuses it (decision 2): +/// ROLLBACK_REFUSED, one guarded boot and no guard-less retry, and the original +/// stays live and takes a send. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_of_a_turn_that_absorbed_a_send_is_refused_by_the_cli_not_busy() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-tool", "TOOL:sleep=2").await; + conn.wait_for("the tool call", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.assistant" + && v["event"]["content"] + .as_array() + .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use")) + }) + .await; + conn.send_text(&pane, "send-absorbed", "and one more thing") + .await; + wait_for_results(&mut conn, results + 1, "the absorbing turn").await; + let a = conn.init_session_id(&pane); + let a_path = rig.wait_for_transcript(&a).await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while !chain_lines(&a_path) + .iter() + .any(|line| line["type"] == "attachment" && line["attachment"]["type"] == "queued_command") + { + assert!( + tokio::time::Instant::now() < deadline, + "the absorbed send never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.fresh_claude.session_busy(&pane).await != Some(false) { + assert!( + tokio::time::Instant::now() < deadline, + "the absorbed send left the session busy" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let boots_before = queries(&rig).len(); + + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + let refused = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(refused["type"], "freshAgent.error", "{refused}"); + assert_eq!(refused["code"], "ROLLBACK_REFUSED", "{refused}"); + let forks: Vec = queries(&rig).into_iter().skip(boots_before).collect(); + assert_eq!( + forks.len(), + 1, + "one guarded fork, never a guard-less retry: {forks:?}" + ); + assert!(forks[0]["resumeDropsTurn"].is_string(), "{:?}", forks[0]); + assert!(ledger(&rig).load_rollback_row("claude", &a).is_none()); + + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-after", "still here?").await; + wait_for_results(&mut conn, results + 1, "the send after the refusal").await; + assert_eq!( + conn.request_errors("send-after"), + Vec::::new(), + "A took the send" + ); +} + +/// The rollback gate's recheck (C17): the gate admitted an undo while the +/// session was idle, but a background agent finished while the undo read the +/// transcript, and its completion started a turn the CLI runs by itself (T19). +/// The ledger is busy again when the undo would boot its replacement, so the +/// undo answers BUSY_TURN, boots nothing and leaves no rollback record, and the +/// CLI's own turn finishes on the original. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_refuses_when_the_cli_starts_a_turn_of_its_own_after_the_gate() { + // A slower clock gives the undo seconds to reach its hold before the + // background agent finishes. + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + env: vec![("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "3".into())], + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-agent", "AGENT:async").await; + wait_for_results(&mut conn, results + 1, "the launching turn").await; + let a = conn.init_session_id(&pane); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.fresh_claude.session_busy(&pane).await != Some(false) { + assert!( + tokio::time::Instant::now() < deadline, + "the launching turn left the session busy" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let boots_before = queries(&rig).len(); + + let hold = rig.fresh_claude.hold_rollback_boot(); + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + tokio::time::timeout(RIG_FRAME_BUDGET, hold.parked()) + .await + .expect("the gate admitted the undo while the session was idle"); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.fresh_claude.session_busy(&pane).await != Some(true) { + assert!( + tokio::time::Instant::now() < deadline, + "the background agent's completion never started a turn" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + hold.release(); + + let refused = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(refused["type"], "freshAgent.error", "{refused}"); + assert_eq!(refused["code"], "BUSY_TURN", "{refused}"); + assert_eq!( + queries(&rig).len(), + boots_before, + "the refused undo booted nothing" + ); + assert!( + ledger(&rig).load_rollback_row("claude", &a).is_none(), + "the pre-written record was compensated" + ); + wait_for_results(&mut conn, results + 2, "the CLI's own turn").await; + assert_eq!(conn.init_session_id(&pane), a); +} + /// The replacement fails to boot (here: A's transcript is moved away while the /// undo is parked before the boot, so the CLI cannot find the conversation to /// fork). The undo answers a retryable error, compensates its record, and A is diff --git a/crates/freshell-ws/tests/freshagent_claude_turns.rs b/crates/freshell-ws/tests/freshagent_claude_turns.rs new file mode 100644 index 000000000..02926a43a --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_turns.rs @@ -0,0 +1,1444 @@ +//! freshclaude turns (core design §5 and §11.3 of +//! docs/plans/2026-10-22-freshclaude-core-runtime.md; C16 adds this file, C17 and +//! C18 grow it). Every case runs the REAL claude sidecar +//! (`crates/freshell-claude-sidecar/index.mjs`) behind a real freshell-ws server. +//! +//! C16, uuid-stamped sends: the server mints a uuid for every send and gives it +//! to the sidecar, which puts it on the SDKUserMessage. The CLI keeps it for the +//! command and writes the user line with it, so it is also the turn id the +//! snapshot shows: `freshAgent.send.accepted.submittedTurnId` is that uuid, and +//! a pane matches its sent message to the landed turn by id. `handle_send` also +//! answers its caller ([`freshell_freshagent::ClaudeSendOutcome`], design §9.3 +//! I2), and a send the sidecar refuses (a bad image) is taken back: the message +//! returns to its sender, the session is not left busy, and it keeps working. +//! +//! C17, the per-command turn ledger (design §5.2): the server folds those facts +//! into one busy truth per session and publishes the session's status itself. +//! A send absorbed into a tool call (T10) leaves the session idle after its one +//! result, so the next configure is accepted and the next turn rings (G08). A +//! send queued behind streamed text keeps the client running between the two +//! results (`freshAgent.result.sessionBusy`). The status is `running` from the +//! accept, before any assistant frame, and a turn the CLI starts by itself (a +//! background agent's completion, T19) is busy too. +//! +//! C18, the server mints every attention edge (design §5.3-§5.5): a result rings +//! unless the user's own Stop landed on it, and a failed one shows why; a death +//! with a turn owed rings; a pending approval or question rings the waiting +//! edge. The sidecar mints none. A Stop targets only the running turn +//! (decision 1: another device's queued message runs and rings), an idle Stop +//! sends nothing, and a Stop that missed a command the CLI was still starting +//! (T13: every receipt shape, through the simulator's cold-command knob) is +//! sent again and the turn ends aborted and silent. In-process observers get +//! the same truth as typed turn events (§9.3 I3) and attention hooks (§9.2 I-1). + +mod common; + +use std::time::Duration; + +use serde_json::{json, Value}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::log_capture; +use freshell_freshagent::{AttentionObserver, ClaudeSendOutcome, ClaudeTurnEvent, TurnOutcome}; +use freshell_protocol::{AgentProvider, FreshAgentImage, FreshAgentSend, SessionType}; + +fn is_uuid(id: &str) -> bool { + uuid::Uuid::parse_str(id).is_ok() +} + +/// The `freshAgent.send.accepted` answering `request_id`. +async fn accepted(conn: &mut RigConnection, request_id: &str) -> Value { + conn.wait_for(&format!("send.accepted for {request_id}"), |v| { + v["type"] == "freshAgent.send.accepted" && v["requestId"] == request_id + }) + .await +} + +async fn wait_for_results(conn: &mut RigConnection, count: usize, what: &str) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") < count { + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {what:?} never finished; frames: {:?}", + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +/// The user turns of the session's snapshot, as (turnId, text). +async fn user_turns(rig: &ClaudeRig, session_id: &str) -> Vec<(String, String)> { + let snapshot = rig + .fresh_claude + .get_snapshot(SessionType::Freshclaude, session_id) + .await + .unwrap_or_else(|error| panic!("a snapshot of {session_id}: {error}")); + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter(|turn| turn["role"] == "user") + .map(|turn| { + ( + turn["turnId"].as_str().unwrap_or_default().to_string(), + turn["items"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect() +} + +/// The landed user turns, once `count` of them are on disk (the CLI writes a +/// line 95-200 ms after its SDK frame, T3). +async fn wait_for_user_turns( + rig: &ClaudeRig, + session_id: &str, + count: usize, +) -> Vec<(String, String)> { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let turns = user_turns(rig, session_id).await; + if turns.len() >= count { + return turns; + } + assert!( + tokio::time::Instant::now() < deadline, + "the snapshot never showed {count} user turns: {turns:?}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// `send.accepted.submittedTurnId` is a UUID, and the turn the message lands as +/// carries it as its `turnId`: the CLI wrote the user line under the uuid the +/// server minted (on the simulator, the prompt also ran under it). +async fn submitted_turn_id_is_the_snapshot_turn_id(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + + let mut submitted = Vec::new(); + for (n, text) in ["hello", "and again"].into_iter().enumerate() { + let request_id = format!("send-{n}"); + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(&session_id, &request_id, text).await; + let accepted = accepted(&mut conn, &request_id).await; + let turn_id = accepted["submittedTurnId"] + .as_str() + .unwrap_or_else(|| panic!("send.accepted carries submittedTurnId: {accepted}")) + .to_string(); + assert!(is_uuid(&turn_id), "{turn_id}"); + wait_for_results(&mut conn, results_before + 1, text).await; + submitted.push((turn_id, text.to_string())); + } + assert_ne!(submitted[0].0, submitted[1].0, "each send has its own id"); + + let durable = conn.init_session_id(&session_id); + assert_eq!( + wait_for_user_turns(&rig, &durable, 2).await, + submitted, + "each landed user turn carries the id its send was accepted with" + ); + if kind == RigKind::Sim { + let prompts: Vec<(String, String)> = rig + .ledger_rows("prompt") + .iter() + .map(|row| { + ( + row["uuid"].as_str().unwrap_or_default().to_string(), + row["text"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(); + assert_eq!(prompts, submitted, "the CLI ran each prompt under its id"); + } + conn.kill(&session_id).await; +} + +/// The CLI's lifecycle facts for a sent command come through the sidecar to the +/// server: the command's lifecycle under the send's uuid, with its replay (T8), +/// the session's state (T9) and its model requests. They are the server's own +/// (logged, and folded into the turn ledger) and never reach a client: no +/// client is shown a model request or the end of a status as a status. +async fn lifecycle_facts_reach_the_server_and_no_client(kind: RigKind) { + let logs = log_capture::install(); + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "hello").await; + let uuid = accepted(&mut conn, "send-1").await["submittedTurnId"] + .as_str() + .expect("submittedTurnId") + .to_string(); + wait_for_results(&mut conn, 1, "hello").await; + + let is_command = |event: &log_capture::CapturedEvent| { + event.message == "freshagent.claude.command" && event.field("uuid") == Some(uuid.as_str()) + }; + logs.wait_for("the completed lifecycle", |event| { + is_command(event) && event.field("state") == Some("completed") + }) + .await; + let commands = logs.matching(is_command); + assert_eq!( + commands + .iter() + .map(|event| event.field("state").unwrap_or_default()) + .collect::>(), + ["queued", "started", "replayed", "completed"], + "{commands:?}" + ); + assert!(commands + .iter() + .all(|event| event.level == tracing::Level::DEBUG + && event.field("session_id") == Some(session_id.as_str()))); + + let is_state = |event: &log_capture::CapturedEvent| { + event.message == "freshagent.claude.session_state" + && event.field("session_id") == Some(session_id.as_str()) + }; + logs.wait_for("the idle session state", |event| { + is_state(event) && event.field("state") == Some("idle") + }) + .await; + assert_eq!( + logs.matching(is_state) + .iter() + .map(|event| event.field("state").unwrap_or_default().to_string()) + .collect::>(), + ["running", "idle"] + ); + logs.wait_for("the model request", |event| { + event.message == "freshagent.claude.status_fact" + && event.field("session_id") == Some(session_id.as_str()) + && event.field("status") == Some("requesting") + }) + .await; + + conn.read_for(Duration::from_millis(300)).await; + for frame in &conn.frames { + if frame["event"]["type"] == "freshAgent.status" { + let status = frame["event"]["status"].as_str(); + assert!( + matches!(status, Some(shown) if shown != "requesting"), + "a client is shown only statuses it knows: {frame}" + ); + } + let event_type = frame["event"]["type"].as_str().unwrap_or_default(); + assert!( + !matches!( + event_type, + "freshAgent.command" | "freshAgent.session.state" | "freshAgent.heartbeat" + ) && !event_type.starts_with("sdk."), + "a lifecycle fact reached a client: {frame}" + ); + } + conn.kill(&session_id).await; +} + +/// The session's `freshAgent.status` values this connection read, in order, +/// with each one's index in [`RigConnection::frames`]. +fn statuses(conn: &RigConnection, session_id: &str) -> Vec<(usize, String)> { + conn.frames + .iter() + .enumerate() + .filter(|(_, v)| { + v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == "freshAgent.status" + }) + .map(|(at, v)| { + ( + at, + v["event"]["status"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect() +} + +/// The index of the first frame matching `predicate`. +fn index_of(conn: &RigConnection, what: &str, predicate: impl Fn(&Value) -> bool) -> usize { + conn.frames + .iter() + .position(predicate) + .unwrap_or_else(|| panic!("no {what} among {:?}", conn.frames)) +} + +/// The `freshAgent.result` frames this connection read, with their indexes. +fn results(conn: &RigConnection) -> Vec<(usize, Value)> { + conn.frames + .iter() + .enumerate() + .filter(|(_, v)| { + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.result" + }) + .map(|(at, v)| (at, v["event"].clone())) + .collect() +} + +/// Wait until the server reports the session `want` busy, or the budget ends. +async fn wait_until_busy(rig: &ClaudeRig, session_id: &str, want: bool, what: &str) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.fresh_claude.session_busy(session_id).await != Some(want) { + assert!( + tokio::time::Instant::now() < deadline, + "{what}: the session never became {}", + if want { "busy" } else { "idle" } + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// G08 (design §11.5 item 4): a send from a second connection lands while the +/// first connection's turn runs a tool, so the CLI absorbs it into that turn +/// (T10): two commands, one result. The session is idle afterwards on the +/// server and on both connections, a model change is accepted (the session is +/// not left waiting for a second result that never comes), and the next turn +/// rings once on each connection. +async fn an_absorbed_send_from_a_second_connection_leaves_both_idle(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut first = rig.connect().await; + let mut second = rig.connect().await; + // Bash runs without asking under bypassPermissions. + let created = first + .request_create("create-1", json!({ "permissionMode": "bypassPermissions" })) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let session_id = created["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + + // 1.5 s keeps the Bash call clear of the CLI's ~3 s background-task + // promotion (the contract suite's absorbed scenario). + first + .send_text(&session_id, "send-tool", "please TOOL:sleep=1.5") + .await; + first + .wait_for("the tool call", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.assistant" + && v["event"]["content"] + .as_array() + .is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_use")) + }) + .await; + second + .send_text(&session_id, "send-absorbed", "and one more thing") + .await; + accepted(&mut second, "send-absorbed").await; + + wait_for_results(&mut first, 1, "the absorbing turn").await; + wait_until_busy(&rig, &session_id, false, "after the absorbing turn").await; + for conn in [&mut first, &mut second] { + wait_for_results(conn, 1, "the absorbing turn").await; + conn.read_for(Duration::from_millis(500)).await; + assert_eq!( + conn.events_seen("freshAgent.result"), + 1, + "one result for the two commands: {:?}", + conn.frames + ); + assert_eq!( + results(conn)[0].1["sessionBusy"], + false, + "{:?}", + conn.frames + ); + assert_eq!( + statuses(conn, &session_id).last().map(|(_, s)| s.as_str()), + Some("idle"), + "{:?}", + conn.frames + ); + } + + second + .send(json!({ + "type": "freshAgent.configure", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "configure-1", + "settings": { "model": "claude-haiku-4-5-20251001" }, + })) + .await; + let answer = second + .wait_for("the configure's metadata or its failure", |v| { + v["type"] == "freshAgent.event" + && (v["event"]["type"] == "freshAgent.session.metadata" + || v["event"]["type"] == "freshAgent.error") + }) + .await; + assert_eq!( + answer["event"]["type"], "freshAgent.session.metadata", + "the idle session takes the model change: {answer}" + ); + + let rings_before = [&first, &second].map(|conn| conn.events_seen("freshAgent.turn.complete")); + first.send_text(&session_id, "send-next", "hello").await; + for (conn, before) in [&mut first, &mut second].into_iter().zip(rings_before) { + wait_for_results(conn, 2, "the next turn").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(300)).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + before + 1, + "the next turn rings once: {:?}", + conn.frames + ); + } + first.kill(&session_id).await; +} + +fn send_msg(session_id: &str, request_id: &str, text: &str) -> FreshAgentSend { + FreshAgentSend { + provider: AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + text: text.to_string(), + cwd: None, + images: None, + request_id: Some(request_id.to_string()), + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + } +} + +// ── C18: the server mints every attention edge; the Stop latch ─────────────── + +/// The user's Stop. +async fn stop(conn: &mut RigConnection, session_id: &str) { + conn.send(json!({ + "type": "freshAgent.interrupt", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + })) + .await; +} + +/// The session's events of this inner type this connection read, in order. +fn session_events<'a>( + conn: &'a RigConnection, + session_id: &str, + event_type: &str, +) -> Vec<&'a Value> { + conn.frames + .iter() + .filter(|v| { + v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == event_type + }) + .collect() +} + +/// The first streamed text delta: the turn runs (its command started). +async fn wait_for_streamed_text(conn: &mut RigConnection) { + conn.wait_for("the first streamed text", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.stream" + && v["event"]["event"]["type"] == "content_block_delta" + }) + .await; +} + +/// The turn ended aborted (T11), with no edge and no banner: the user's own +/// Stop. The session settles idle. +fn assert_stopped_silently(conn: &RigConnection, session_id: &str) { + let results = session_events(conn, session_id, "freshAgent.result"); + let result = &results.last().expect("a result")["event"]; + assert_eq!(result["subtype"], "error_during_execution", "{result}"); + assert!( + result["terminalReason"] + .as_str() + .is_some_and(|reason| reason.starts_with("aborted_")), + "the turn was aborted: {result}" + ); + assert_eq!( + session_events(conn, session_id, "freshAgent.turn.complete").len(), + 0, + "the user's own Stop never rings: {:?}", + conn.frames + ); + assert_eq!( + session_events(conn, session_id, "freshAgent.error").len(), + 0, + "the user's own Stop shows no banner: {:?}", + conn.frames + ); + assert_eq!( + statuses(conn, session_id).last().map(|(_, s)| s.as_str()), + Some("idle"), + "{:?}", + conn.frames + ); +} + +/// The next turn runs and rings exactly once more. +async fn the_next_turn_rings_once(conn: &mut RigConnection, session_id: &str) { + let rings = session_events(conn, session_id, "freshAgent.turn.complete").len(); + let results = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, "send-next", "hello").await; + wait_for_results(conn, results + 1, "the next turn").await; + conn.wait_for("the next turn's edge", |v| { + v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + conn.read_for(Duration::from_millis(300)).await; + assert_eq!( + session_events(conn, session_id, "freshAgent.turn.complete").len(), + rings + 1, + "{:?}", + conn.frames + ); +} + +/// Design §11.5 item 2: a Stop while the turn streams text ends it aborted, +/// silently (no edge, no "Agent error" banner, G03), and the next turn rings. +async fn a_warm_stop_is_silent(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "SLOW stop me").await; + wait_for_streamed_text(&mut conn).await; + stop(&mut conn, &session_id).await; + wait_for_results(&mut conn, 1, "the stopped turn").await; + conn.read_for(Duration::from_millis(500)).await; + assert_stopped_silently(&conn, &session_id); + the_next_turn_rings_once(&mut conn, &session_id).await; + conn.kill(&session_id).await; +} + +/// Design §11.5 item 3: a Stop sent right after the send, while the CLI may +/// still be starting the command (T13). Whichever way the race goes - the +/// Stop lands, or it misses and is sent again once the command runs - the turn +/// ends aborted and silent. +async fn a_stop_right_after_the_send_is_silent(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "SLOW stop me early") + .await; + accepted(&mut conn, "send-1").await; + stop(&mut conn, &session_id).await; + wait_for_results(&mut conn, 1, "the stopped turn").await; + conn.read_for(Duration::from_millis(500)).await; + assert_stopped_silently(&conn, &session_id); + the_next_turn_rings_once(&mut conn, &session_id).await; + conn.kill(&session_id).await; +} + +/// The interrupts the simulated CLI took. +fn sim_interrupts(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("interrupt") +} + +/// The Stop decisions logged for this session. +fn stop_logs(logs: &log_capture::LogCapture, session_id: &str) -> Vec { + logs.matching(|event| { + event.message == "freshagent.claude.stop" && event.field("session_id") == Some(session_id) + }) +} + +/// A cold Stop (T13): the simulator's cold-command knob makes the first +/// command miss a Stop that arrives while it still starts, with the receipt +/// shape `shape`. The server sends the Stop again under re-send rule `case` +/// once the command runs, and the turn ends aborted and silent. +async fn a_cold_stop_is_aborted_and_silent(shape: &str, case: &str) { + let logs = log_capture::install(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_COLD_COMMAND", shape)]) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "SLOW cold").await; + let uuid = accepted(&mut conn, "send-1").await["submittedTurnId"] + .as_str() + .expect("submittedTurnId") + .to_string(); + stop(&mut conn, &session_id).await; + wait_for_results(&mut conn, 1, "the stopped turn").await; + conn.read_for(Duration::from_millis(500)).await; + assert_stopped_silently(&conn, &session_id); + + let interrupts = sim_interrupts(&rig); + assert_eq!( + interrupts.len(), + 2, + "the Stop and its re-send: {interrupts:?}" + ); + assert_eq!(interrupts[0]["missed"], json!(uuid), "{interrupts:?}"); + assert_eq!(interrupts[1]["running"], true, "{interrupts:?}"); + let stops = stop_logs(&logs, &session_id); + let outcomes: Vec<(Option<&str>, Option<&str>)> = stops + .iter() + .map(|event| (event.field("outcome"), event.field("case"))) + .collect(); + assert_eq!( + outcomes, + vec![(Some("sent"), None), (Some("reissued"), Some(case))], + "{stops:?}" + ); + assert_eq!(stops[0].field("target"), Some("not_started")); + the_next_turn_rings_once(&mut conn, &session_id).await; + conn.kill(&session_id).await; +} + +/// Records every attention hook it is given. +#[derive(Default)] +struct RecordingObserver(std::sync::Mutex>); + +impl AttentionObserver for RecordingObserver { + fn on_registered(&self, id: &str, session_type: &str) { + self.0 + .lock() + .unwrap() + .push(format!("registered {id} {session_type}")); + } + fn on_turn_complete(&self, id: &str, _session_type: &str, at: i64) { + self.0.lock().unwrap().push(format!("complete {id} {at}")); + } + fn on_waiting(&self, id: &str, _session_type: &str, at: i64) { + self.0.lock().unwrap().push(format!("waiting {id} {at}")); + } + fn on_pending_drained(&self, id: &str) { + self.0.lock().unwrap().push(format!("drained {id}")); + } + fn on_forgotten(&self, id: &str) { + self.0.lock().unwrap().push(format!("forgotten {id}")); + } +} + +impl RecordingObserver { + fn calls(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +mod sim { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_warm_stop_is_silent() { + super::a_warm_stop_is_silent(RigKind::Sim).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stop_right_after_the_send_is_silent() { + super::a_stop_right_after_the_send_is_silent(RigKind::Sim).await; + } + + /// Case A, receipt `[]` before the command's lifecycle (the Stop overtook + /// the command): re-sent at its start. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_cold_stop_overtaking_the_command_is_aborted_and_silent() { + super::a_cold_stop_is_aborted_and_silent("empty", "A").await; + } + + /// Case A, receipt `[uuid]` (the command was queued): re-sent at its start. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_cold_stop_listing_the_command_is_aborted_and_silent() { + super::a_cold_stop_is_aborted_and_silent("queued", "A").await; + } + + /// Case B, receipt `[]` after the command's queued and started: re-sent on + /// its replay, its first content. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_cold_stop_after_the_commands_start_is_aborted_on_the_replay() { + super::a_cold_stop_is_aborted_and_silent("started", "B").await; + } + + /// A Stop that races the turn's completion: it misses a command that is + /// still starting, and the command finishes before the re-sent interrupt + /// lands. The user believes it stopped, and it did not: it rings, once, + /// with no banner (G06). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stop_racing_completion_rings() { + let rig = ClaudeRig::start_with_env( + RigKind::Sim, + &[("FRESHELL_CLAUDE_SIM_COLD_COMMAND", "started")], + ) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + // A plain text turn: once it produces content it finishes at once. + conn.send_text(&session_id, "send-1", "hello").await; + accepted(&mut conn, "send-1").await; + stop(&mut conn, &session_id).await; + wait_for_results(&mut conn, 1, "the turn").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(500)).await; + let result = &session_events(&conn, &session_id, "freshAgent.result")[0]["event"]; + assert_eq!(result["subtype"], "success", "{result}"); + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.complete").len(), + 1, + "{:?}", + conn.frames + ); + assert!(session_events(&conn, &session_id, "freshAgent.error").is_empty()); + assert_eq!(sim_interrupts(&rig).len(), 2, "the Stop was sent again"); + the_next_turn_rings_once(&mut conn, &session_id).await; + conn.kill(&session_id).await; + } + + /// Decision 1: a Stop targets only the running turn. A message another + /// device queued behind it runs as its own turn and rings, on every + /// device; the stopped turn is silent, and no Stop reaches the queued one. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stop_leaves_another_devices_queued_send_to_run_and_ring() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut first = rig.connect().await; + let mut second = rig.connect().await; + let session_id = first.create("create-1").await; + first.send_text(&session_id, "send-1", "SLOW stop me").await; + wait_for_streamed_text(&mut first).await; + second + .send_text(&session_id, "send-queued", "the other device's message") + .await; + accepted(&mut second, "send-queued").await; + stop(&mut first, &session_id).await; + for conn in [&mut first, &mut second] { + wait_for_results(conn, 2, "the stopped turn and the queued one").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(500)).await; + let results = session_events(conn, &session_id, "freshAgent.result"); + assert!( + results[0]["event"]["terminalReason"] + .as_str() + .is_some_and(|reason| reason.starts_with("aborted_")), + "{:?}", + conn.frames + ); + assert_eq!(results[1]["event"]["subtype"], "success"); + assert_eq!(results[1]["event"]["sessionBusy"], false); + assert_eq!( + session_events(conn, &session_id, "freshAgent.turn.complete").len(), + 1, + "only the queued turn rings: {:?}", + conn.frames + ); + let second_result_at = conn + .frames + .iter() + .position(|v| std::ptr::eq(v, results[1])) + .unwrap(); + let ring_at = conn + .frames + .iter() + .position(|v| v["event"]["type"] == "freshAgent.turn.complete") + .unwrap(); + assert!(ring_at > second_result_at, "the queued turn's result rings"); + assert!(session_events(conn, &session_id, "freshAgent.error").is_empty()); + } + assert_eq!( + sim_interrupts(&rig).len(), + 1, + "no Stop reached the queued turn" + ); + first.kill(&session_id).await; + } + + /// A Stop with nothing running sends nothing (logged `idle`) and leaves + /// nothing behind: the next turn rings. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_idle_stop_is_a_no_op() { + let logs = log_capture::install(); + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + stop(&mut conn, &session_id).await; + logs.wait_for("the idle Stop", |event| { + event.message == "freshagent.claude.stop" + && event.field("session_id") == Some(session_id.as_str()) + }) + .await; + conn.read_for(Duration::from_millis(300)).await; + assert_eq!( + stop_logs(&logs, &session_id) + .iter() + .map(|event| event.field("outcome").unwrap_or_default().to_string()) + .collect::>(), + ["idle"] + ); + assert!( + sim_interrupts(&rig).is_empty(), + "no interrupt reached the CLI" + ); + assert!(session_events(&conn, &session_id, "freshAgent.error").is_empty()); + the_next_turn_rings_once(&mut conn, &session_id).await; + conn.kill(&session_id).await; + } + + /// A turn the CLI starts by itself (a background agent finished, T19) rings + /// when it ends, like any other turn. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_cli_initiated_turn_rings() { + let logs = log_capture::install(); + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-agent", "AGENT:async") + .await; + wait_for_results(&mut conn, 2, "the launching turn and the CLI's own").await; + conn.read_for(Duration::from_millis(500)).await; + let rings = session_events(&conn, &session_id, "freshAgent.turn.complete"); + assert_eq!(rings.len(), 2, "{:?}", conn.frames); + let (second_result_at, _) = results(&conn)[1]; + assert!( + conn.frames + .iter() + .skip(second_result_at) + .any(|v| v["event"]["type"] == "freshAgent.turn.complete"), + "the CLI's own turn rings after its result" + ); + // Minted by the server from each result. + let minted = logs.matching(|event| { + event.message == "freshagent.turn.complete" + && event.field("session_id") == Some(session_id.as_str()) + }); + assert_eq!( + minted + .iter() + .map(|event| event.field("source").unwrap_or_default().to_string()) + .collect::>(), + ["result", "result"] + ); + conn.kill(&session_id).await; + } + + /// An API error (T18) rings once and shows no banner: the transcript shows + /// the error itself. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_api_error_result_rings_with_no_banner() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "APIERR:500").await; + wait_for_results(&mut conn, 1, "the API error").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(500)).await; + let result = &session_events(&conn, &session_id, "freshAgent.result")[0]["event"]; + assert_eq!(result["subtype"], "success", "{result}"); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.complete").len(), + 1 + ); + assert!( + session_events(&conn, &session_id, "freshAgent.error").is_empty(), + "{:?}", + conn.frames + ); + conn.kill(&session_id).await; + } + + /// A turn that ends `error_max_turns` rings once and shows why, after its + /// result (the nested `CLAUDE_TURN_FAILED` banner; the CLI gave no text of + /// its own, so the banner is the copy for the result kind). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn error_max_turns_rings_with_a_banner() { + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "max turns" }, + "emit": [{ "kind": "completion", "data": { "subtype": "error_max_turns", "errors": [] } }], + }] }) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "max turns").await; + wait_for_results(&mut conn, 1, "the failed turn").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(500)).await; + let errors = session_events(&conn, &session_id, "freshAgent.error"); + assert_eq!(errors.len(), 1, "{:?}", conn.frames); + assert_eq!(errors[0]["event"]["code"], "CLAUDE_TURN_FAILED"); + assert_eq!( + errors[0]["event"]["message"], + "Claude reached its turn limit. Send a message to continue." + ); + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.complete").len(), + 1 + ); + let (result_at, _) = results(&conn)[0]; + let error_at = index_of(&conn, "the banner", |v| { + v["event"]["type"] == "freshAgent.error" + }); + let ring_at = index_of(&conn, "the edge", |v| { + v["event"]["type"] == "freshAgent.turn.complete" + }); + assert!( + result_at < error_at && error_at < ring_at, + "result, banner, edge" + ); + conn.kill(&session_id).await; + } + + /// G24: the result's edge and the death's edge come from one clock: the + /// death of a queued turn right after a result rings with a strictly + /// larger `at` (the client drops an edge whose `at` is not above the last). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_death_after_a_result_rings_with_a_strictly_larger_at() { + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "die" }, "emit": [{ "kind": "crash" }], + }] }) + .to_string(); + let rig = ClaudeRig::start_with_env( + RigKind::Sim, + &[ + ("FRESHELL_CLAUDE_SIM_PROGRAM", program.as_str()), + ("FRESHELL_CLAUDE_SIM_TIME_SCALE", "0"), + ], + ) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "hello").await; + conn.send_text(&session_id, "send-2", "die").await; + conn.wait_for("the exit report", |v| { + v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + conn.read_for(Duration::from_millis(500)).await; + let rings: Vec = session_events(&conn, &session_id, "freshAgent.turn.complete") + .iter() + .map(|v| v["event"]["at"].as_i64().expect("a numeric at")) + .collect(); + assert_eq!( + rings.len(), + 2, + "the result and the death: {:?}", + conn.frames + ); + assert!(rings[1] > rings[0], "{rings:?}"); + } + + /// The waiting edge is the server's: on the 0 -> >=1 pending transition, + /// after the approval request it follows, once, logged with source + /// `pending`; the turn's end then rings once. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_waiting_edge_is_minted_by_the_server() { + let logs = log_capture::install(); + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "ask" }, + "emit": [{ "kind": "approval", "data": { "tool": "Bash", "input": { "command": "ls" } } }], + }] }) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("create-1", json!({ "permissionMode": "default" })) + .await; + let session_id = created["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + conn.send_text(&session_id, "send-1", "ask").await; + let request = conn.wait_for_event("freshAgent.permission.request").await; + let waiting = conn.wait_for_event("freshAgent.turn.waiting").await; + assert!(waiting["event"]["at"].as_i64().is_some(), "{waiting}"); + let request_at = index_of(&conn, "the request", |v| { + v["event"]["type"] == "freshAgent.permission.request" + }); + let waiting_at = index_of(&conn, "the edge", |v| { + v["event"]["type"] == "freshAgent.turn.waiting" + }); + assert!(request_at < waiting_at, "the edge follows the request"); + let minted = logs + .wait_for("the minted waiting edge", |event| { + event.message == "freshagent.turn.waiting" + && event.field("session_id") == Some(session_id.as_str()) + }) + .await; + assert_eq!(minted.field("source"), Some("pending")); + + conn.send(json!({ + "type": "freshAgent.approval.respond", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": request["event"]["requestId"], + "decision": { "behavior": "allow", "updatedInput": { "command": "ls" } }, + })) + .await; + wait_for_results(&mut conn, 1, "the approved turn").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(300)).await; + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.waiting").len(), + 1 + ); + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.complete").len(), + 1 + ); + conn.kill(&session_id).await; + } + + /// Answering one approval then receiving another creates another waiting + /// interval. Each request is announced before its one edge; the result + /// remains a separate single completion edge. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sequential_approvals_each_raise_one_waiting_edge() { + let program = json!({"rules": [{ + "on": "msg:prompt", "match": {"text": "ask twice"}, + "emit": [ + {"kind": "approval", "data": {"tool":"Bash", "input":{"command":"ls"}}}, + {"kind": "approval", "data": {"tool":"Bash", "input":{"command":"pwd"}}}, + {"kind": "text", "data":{"text":"both answered"}}, + ], + }]}) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("create-1", json!({"permissionMode":"default"})) + .await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + conn.send_text(&session_id, "send-1", "ask twice").await; + let mut previous_at = 0i64; + for command in ["ls", "pwd"] { + let request = conn + .wait_for("the next approval", |frame| { + frame["event"]["type"] == "freshAgent.permission.request" + && frame["event"]["tool"]["input"]["command"] == command + }) + .await; + let waiting = conn + .wait_for("the next waiting edge", |frame| { + frame["event"]["type"] == "freshAgent.turn.waiting" + && frame["event"]["at"] + .as_i64() + .is_some_and(|at| at > previous_at) + }) + .await; + previous_at = waiting["event"]["at"].as_i64().unwrap(); + conn.send(json!({ + "type":"freshAgent.approval.respond", "provider":"claude", "sessionType":"freshclaude", + "sessionId":session_id, "requestId":request["event"]["requestId"], + "decision":{"behavior":"allow", "updatedInput":{"command":command}}, + })).await; + } + wait_for_results(&mut conn, 1, "the twice-approved turn").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(200)).await; + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.waiting").len(), + 2 + ); + assert_eq!( + session_events(&conn, &session_id, "freshAgent.turn.complete").len(), + 1 + ); + conn.kill(&session_id).await; + } + + /// In-process observers (design §9.3 I3, §9.2 I-1): every settled turn is + /// published with the requests it retired and its edge's `at`, a turn the + /// user's Stop silenced with no `at`; the attention observer hears the + /// registration, each edge and the close. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn turn_events_and_attention_hooks_follow_every_settled_turn() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions::default()).await; + let observer = std::sync::Arc::new(RecordingObserver::default()); + let _ = rig + .fresh_claude + .clone() + .with_attention_observer(observer.clone()); + let mut events = rig.fresh_claude.subscribe_turn_events(); + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + + conn.send_text(&session_id, "send-1", "hello").await; + let uuid = accepted(&mut conn, "send-1").await["submittedTurnId"] + .as_str() + .expect("submittedTurnId") + .to_string(); + let ring = conn.wait_for_event("freshAgent.turn.complete").await; + let at = ring["event"]["at"].as_i64().expect("at"); + let settled = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) + .await + .expect("a turn event") + .expect("the channel is open"); + assert_eq!( + settled, + ClaudeTurnEvent::Settled { + session_key: session_id.clone(), + retired_request_ids: vec!["send-1".to_string()], + retired_command_ids: vec![uuid], + outcome: TurnOutcome::Completed, + at: Some(at), + } + ); + + conn.send_text(&session_id, "send-2", "SLOW stop me").await; + wait_for_streamed_text(&mut conn).await; + stop(&mut conn, &session_id).await; + let silenced = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) + .await + .expect("a turn event") + .expect("the channel is open"); + assert!( + matches!( + &silenced, + ClaudeTurnEvent::Settled { retired_request_ids, outcome: TurnOutcome::Interrupted, at: None, .. } + if retired_request_ids == &vec!["send-2".to_string()] + ), + "{silenced:?}" + ); + conn.kill(&session_id).await; + assert_eq!( + observer.calls(), + vec![ + format!("registered {session_id} freshclaude"), + format!("complete {session_id} {at}"), + format!("forgotten {session_id}"), + ] + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn submitted_turn_id_is_the_snapshot_turn_id() { + super::submitted_turn_id_is_the_snapshot_turn_id(RigKind::Sim).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn lifecycle_facts_reach_the_server_and_no_client() { + super::lifecycle_facts_reach_the_server_and_no_client(RigKind::Sim).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_absorbed_send_from_a_second_connection_leaves_both_idle() { + super::an_absorbed_send_from_a_second_connection_leaves_both_idle(RigKind::Sim).await; + } + + /// A send pushed while the first turn streams text queues as its own turn + /// (T10). The first result tells the client the session is still busy + /// (`sessionBusy: true`) and the server says `running` again after it, so + /// the pane stays running until the queued turn's own result settles it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_queued_send_keeps_the_client_running_between_its_results() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-slow", "SLOW").await; + conn.wait_for("the first streamed text", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.stream" + && v["event"]["event"]["type"] == "content_block_delta" + }) + .await; + conn.send_text(&session_id, "send-queued", "queued behind") + .await; + accepted(&mut conn, "send-queued").await; + wait_for_results(&mut conn, 2, "both turns").await; + conn.read_for(Duration::from_millis(300)).await; + + let results = results(&conn); + assert_eq!(results.len(), 2, "{:?}", conn.frames); + let (first_at, first) = &results[0]; + let (second_at, second) = &results[1]; + assert_eq!( + first["sessionBusy"], true, + "the queued send is owed: {first}" + ); + assert_eq!(second["sessionBusy"], false, "{second}"); + let statuses = statuses(&conn, &session_id); + assert!( + statuses + .iter() + .any(|(at, status)| at > first_at && at < second_at && status == "running"), + "running again after the first result: {statuses:?}" + ); + assert!( + !statuses + .iter() + .any(|(at, status)| at > first_at && at < second_at && status == "idle"), + "never idle between the two results: {statuses:?}" + ); + assert_eq!( + statuses.last().map(|(_, s)| s.as_str()), + Some("idle"), + "{statuses:?}" + ); + assert!( + statuses.last().is_some_and(|(at, _)| at > second_at), + "idle after the last result: {statuses:?}" + ); + conn.kill(&session_id).await; + } + + /// A new session's status is the server's from the start: right after + /// `freshAgent.created` the server says it is `idle` (ready, nothing + /// owed), so a pane never waits for its first turn to learn it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_new_session_is_announced_idle_right_after_its_creation() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + let status = conn + .wait_for("the session's first status", |v| { + v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == "freshAgent.status" + }) + .await; + assert_eq!(status["event"]["status"], "idle", "{status}"); + let created_at = index_of(&conn, "created", |v| v["type"] == "freshAgent.created"); + assert!( + statuses(&conn, &session_id)[0].0 > created_at, + "the status follows the creation" + ); + conn.kill(&session_id).await; + } + + /// The server publishes `running` when it accepts the send, before the CLI + /// reports anything: before the acceptance and before any assistant or + /// stream frame. The session settles `idle` after the result. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn running_is_published_at_accept_before_any_assistant_frame() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + let sent_at = conn.frames.len(); + conn.send_text(&session_id, "send-1", "hello").await; + wait_for_results(&mut conn, 1, "hello").await; + conn.read_for(Duration::from_millis(300)).await; + + let statuses = statuses(&conn, &session_id); + let (running_at, _) = statuses + .iter() + .find(|(at, status)| *at >= sent_at && status == "running") + .unwrap_or_else(|| panic!("no running status: {:?}", conn.frames)); + let accepted_at = index_of(&conn, "send.accepted", |v| { + v["type"] == "freshAgent.send.accepted" && v["requestId"] == "send-1" + }); + let content_at = index_of(&conn, "an assistant or stream frame", |v| { + v["type"] == "freshAgent.event" + && matches!( + v["event"]["type"].as_str(), + Some("freshAgent.assistant" | "freshAgent.stream") + ) + }); + assert!( + *running_at < accepted_at, + "{statuses:?} accepted at {accepted_at}" + ); + assert!( + *running_at < content_at, + "{statuses:?} content at {content_at}" + ); + let (result_at, _) = results(&conn)[0]; + assert!( + statuses + .iter() + .any(|(at, status)| *at > result_at && status == "idle"), + "idle after the result: {statuses:?}" + ); + conn.kill(&session_id).await; + } + + /// A background agent's completion starts a turn the CLI runs by itself + /// (T19): no uuid, no lifecycle, only the session state and the turn. The + /// session is busy for it: the server says `running` after the launching + /// turn's result and `idle` after the CLI turn's own result. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_cli_initiated_turn_is_busy() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-agent", "AGENT:async") + .await; + wait_for_results(&mut conn, 2, "the launching turn and the CLI's own").await; + conn.read_for(Duration::from_millis(300)).await; + + let results = results(&conn); + let (first_at, _) = results[0]; + let (second_at, second) = &results[1]; + assert_eq!(second["sessionBusy"], false, "{second}"); + let statuses = statuses(&conn, &session_id); + assert!( + statuses + .iter() + .any(|(at, status)| *at > first_at && at < second_at && status == "running"), + "the CLI's own turn is busy: {statuses:?}" + ); + assert_eq!( + statuses.last().map(|(_, s)| s.as_str()), + Some("idle"), + "{statuses:?}" + ); + assert!(statuses.last().is_some_and(|(at, _)| at > second_at)); + assert_eq!( + rig.fresh_claude.session_busy(&session_id).await, + Some(false) + ); + conn.kill(&session_id).await; + } + + /// `handle_send` answers its in-process caller (design §9.3 I2): an accepted + /// send names the session it went to and its turn id (the one broadcast as + /// `submittedTurnId`); a refused send names the refusal. `session_busy` reports + /// whether a live session is working, and nothing for an id with no live session. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn handle_send_answers_with_the_accepted_turn_or_the_refusal() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + assert_eq!( + rig.fresh_claude.session_busy(&session_id).await, + Some(false) + ); + assert_eq!(rig.fresh_claude.session_busy("no-such-session").await, None); + + // HANG: the turn's model request never completes, so the session stays busy. + let outcome = rig + .fresh_claude + .handle_send(send_msg(&session_id, "send-1", "HANG")) + .await; + let ClaudeSendOutcome::Accepted { + session_key, + submitted_turn_id, + } = outcome + else { + panic!("the send was accepted: {outcome:?}"); + }; + assert_eq!(session_key, session_id); + assert!(is_uuid(&submitted_turn_id), "{submitted_turn_id}"); + assert_eq!( + accepted(&mut conn, "send-1").await["submittedTurnId"], + json!(submitted_turn_id) + ); + assert_eq!(rig.fresh_claude.session_busy(&session_id).await, Some(true)); + + let refused = rig + .fresh_claude + .handle_send(send_msg("no-such-session", "send-2", "hello")) + .await; + assert!( + matches!(&refused, ClaudeSendOutcome::Refused { code, .. } if code == "SESSION_NOT_FOUND"), + "{refused:?}" + ); + // The refusal reached the sender too. + conn.wait_for("the send-2 request error", |v| { + v["type"] == "error" && v["requestId"] == "send-2" + }) + .await; + conn.kill(&session_id).await; + assert_eq!(rig.fresh_claude.session_busy(&session_id).await, None); + } + + /// The sidecar refuses a send whose image Claude cannot read + /// (`sdk.send.rejected`, INVALID_IMAGE) and lives on. The server takes the + /// send back: the sender gets a request error (the composer gets the message + /// back), the session is not left busy waiting for a turn that never runs, + /// nothing rings, and the next message runs and rings once. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_send_with_a_bad_image_is_taken_back_and_the_session_keeps_working() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + + let mut send = send_msg(&session_id, "send-bad", "look at this"); + send.images = Some(vec![FreshAgentImage { + data: "PHN2Zy8+".to_string(), + media_type: "image/svg+xml".to_string(), + }]); + conn.send( + serde_json::to_value(freshell_protocol::ClientMessage::FreshAgentSend(send)).unwrap(), + ) + .await; + let error = conn + .wait_for("the rejected send's request error", |v| { + v["type"] == "error" && v["requestId"] == "send-bad" + }) + .await; + let message = error["message"].as_str().unwrap_or_default(); + assert!(message.starts_with("CLAUDE_SEND_REJECTED: "), "{error}"); + assert!(message.contains("image/svg+xml"), "{error}"); + + // The send is taken back: nothing is owed for it. + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.fresh_claude.session_busy(&session_id).await != Some(false) { + assert!( + tokio::time::Instant::now() < deadline, + "the rejected send left the session busy" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + conn.send_text(&session_id, "send-next", "hello").await; + conn.wait_for_event("freshAgent.result").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(300)).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "{:?}", + conn.frames + ); + assert_eq!(conn.events_seen("freshAgent.result"), 1); + assert!( + !conn + .frames + .iter() + .any(|v| v["event"]["code"] == "SIDECAR_EXITED"), + "the sidecar lives on: {:?}", + conn.frames + ); + let prompts: Vec = rig + .ledger_rows("prompt") + .iter() + .map(|row| row["text"].clone()) + .collect(); + assert_eq!(prompts, vec![json!("hello")], "the rejected send never ran"); + conn.kill(&session_id).await; + } +} + +mod cli { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn submitted_turn_id_is_the_snapshot_turn_id() { + super::submitted_turn_id_is_the_snapshot_turn_id(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn lifecycle_facts_reach_the_server_and_no_client() { + super::lifecycle_facts_reach_the_server_and_no_client(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_absorbed_send_from_a_second_connection_leaves_both_idle() { + super::an_absorbed_send_from_a_second_connection_leaves_both_idle(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_warm_stop_is_silent() { + super::a_warm_stop_is_silent(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stop_right_after_the_send_is_silent() { + super::a_stop_right_after_the_send_is_silent(RigKind::Cli).await; + } +} diff --git a/crates/freshell-ws/tests/freshagent_claude_writer.rs b/crates/freshell-ws/tests/freshagent_claude_writer.rs new file mode 100644 index 000000000..e383cd8f8 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_writer.rs @@ -0,0 +1,148 @@ +//! A stalled sidecar stdin belongs to one runtime. Other sessions remain usable, +//! and a timed-out write ends the stalled runtime through the normal EOF path. +//! Run in the disposable runtime sandbox: these tests deliberately wedge a child. + +mod common; + +use std::time::Duration; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RIG_FRAME_BUDGET}; +use freshell_freshagent::ClaudeSendOutcome; +use freshell_protocol::{FreshAgentKill, FreshAgentSend, SessionType}; +use serde_json::json; + +fn send(session_id: &str, request_id: &str, text: String) -> FreshAgentSend { + serde_json::from_value(json!({ + "type": "freshAgent.send", "provider": "claude", "sessionType": "freshclaude", + "sessionId": session_id, "requestId": request_id, "text": text, + })) + .unwrap() +} + +async fn stalled_rig(timeout_ms: &str) -> (ClaudeRig, RigConnection, String) { + let program = json!({"rules": [{ + "on": "msg:prompt", "match": {"text": "stall now"}, + "emit": [{"kind": "stall_stdin"}, {"kind": "hang"}], + }]}) + .to_string(); + let rig = ClaudeRig::start_with_env( + RigKind::Sim, + &[ + ("FRESHELL_CLAUDE_SIM_PROGRAM", &program), + ("FRESHELL_FRESHCLAUDE_WRITE_TIMEOUT_MS", timeout_ms), + ], + ) + .await; + let mut conn = rig.connect().await; + let stalled = conn.create("create-stalled").await; + conn.send_text(&stalled, "stall", "stall now").await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while rig.ledger_rows("stall_stdin").is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "stdin never stalled" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + (rig, conn, stalled) +} + +fn block_stdin(rig: &ClaudeRig, stalled: &str) -> tokio::task::JoinHandle { + let state = rig.fresh_claude.clone(); + // Larger than the OS pipe and Node stream buffers; the paused sidecar cannot + // consume it. Call the runtime directly to avoid the WS message-size limit. + let request = send(stalled, "blocked", "x".repeat(2 * 1024 * 1024)); + tokio::spawn(async move { state.handle_send(request).await }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stalled_stdin_does_not_block_another_sessions_send_snapshot_or_kill() { + // Longer than the healthy-operation budget: a global map lock with just a + // write timeout would still fail this test. + let (rig, mut conn, stalled) = stalled_rig("5000").await; + let healthy = conn.create("create-healthy").await; + let durable = rig.converse(&mut conn, &healthy, &["before stall"]).await; + let blocked = block_stdin(&rig, &stalled); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!blocked.is_finished(), "the sidecar must really be blocked"); + + let operations = async { + let outcome = rig + .fresh_claude + .handle_send(send(&healthy, "healthy", "still works".into())) + .await; + assert!(matches!(outcome, ClaudeSendOutcome::Accepted { .. })); + rig.fresh_claude + .get_snapshot(SessionType::Freshclaude, &durable) + .await + .expect("healthy snapshot"); + let kill: FreshAgentKill = serde_json::from_value(json!({ + "type": "freshAgent.kill", "provider": "claude", "sessionType": "freshclaude", + "sessionId": healthy, + })) + .unwrap(); + rig.fresh_claude.handle_kill(kill).await; + }; + let result = tokio::time::timeout(Duration::from_secs(1), operations).await; + // Cancel the test's own pending send even on red, so its held lock cannot + // obstruct cleanup in the old implementation. + blocked.abort(); + let _ = blocked.await; + assert!( + result.is_ok(), + "a stalled sidecar blocked a healthy session" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_500ms_write_timeout_kills_only_its_runtime_and_rings_the_owed_turn_once() { + let (rig, mut conn, stalled) = stalled_rig("500").await; + let healthy = conn.create("create-healthy").await; + let blocked = block_stdin(&rig, &stalled); + let refused = tokio::time::timeout(Duration::from_secs(3), blocked).await; + assert!(refused.is_ok(), "the stalled write never timed out"); + assert!(matches!( + refused.unwrap().unwrap(), + ClaudeSendOutcome::Refused { .. } + )); + conn.wait_for("the wedged sidecar's EOF report", |frame| { + frame["type"] == "freshAgent.event" + && frame["sessionId"] == stalled + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + conn.read_for(Duration::from_millis(200)).await; + assert_eq!( + conn.frames + .iter() + .filter(|frame| frame["sessionId"] == stalled + && frame["event"]["type"] == "freshAgent.turn.complete") + .count(), + 1, + "the original stalled turn is owed exactly one attention edge" + ); + let stalled_durable = conn.init_session_id(&stalled); + assert!(!rig.fresh_claude.has_live_session(&stalled_durable).await); + assert!(matches!( + rig.fresh_claude + .handle_send(send(&healthy, "after-timeout", "still works".into())) + .await, + ClaudeSendOutcome::Accepted { .. } + )); + conn.wait_for("the other runtime still completes turns", |frame| { + frame["sessionId"] == healthy && frame["event"]["type"] == "freshAgent.result" + }) + .await; + assert!( + rig.fresh_claude + .has_live_session(&conn.init_session_id(&healthy)) + .await + ); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&stalled).await["status"], + "exited" + ); + rig.fresh_claude.shutdown().await; +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 3a7272915..ade49f51d 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -581,6 +581,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh costUsd: event.costUsd as number | undefined, durationMs: event.durationMs as number | undefined, usage: event.usage as { input_tokens?: number; output_tokens?: number } | undefined, + sessionBusy: event.sessionBusy === true, })) return true case 'freshAgent.rolledBack': diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index b44411063..1e7b4bd35 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -567,10 +567,13 @@ const freshAgentSlice = createSlice({ costUsd?: number durationMs?: number usage?: { input_tokens?: number; output_tokens?: number } + /** The server still owes the session work (a send queued behind this turn). */ + sessionBusy?: boolean }>) { - const session = resolveOrEnsureSession(state, action.payload, 'idle') + const status = action.payload.sessionBusy === true ? 'running' : 'idle' + const session = resolveOrEnsureSession(state, action.payload, status) if (!session) return - writeSessionStatus(session, 'idle') + writeSessionStatus(session, status) session.streamingActive = false // The SDK's total_cost_usd already covers the whole session so far: it // is the total, never an increment. (Token usage is per turn, so it adds.) diff --git a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs index b469c7ead..5863e24ba 100644 --- a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs +++ b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs @@ -11,7 +11,7 @@ // FIELD shapes come from the REAL sidecar (crates/freshell-claude-sidecar/ // index.mjs:15-30) + the client consumer (src/lib/fresh-agent-ws.ts:195-284): // - sdk.assistant content MUST be an ARRAY of blocks (fresh-agent-ws.ts:260-265); -// - sdk.turn.complete MUST carry a numeric `at` (fresh-agent-ws.ts:233-240); +// - no attention edge: the server rings each turn from its sdk.result (C18); // - sdk.session.init cliSessionId MUST be a canonical UUID // (shared/session-contract.ts:34) or no durable sessionRef ever lands. // The process MUST stay alive (no EOF) until shutdown/kill -- an early exit @@ -80,9 +80,13 @@ function transcriptPath(cliSessionId) { // Claude-code transcript line shape (what the Rust snapshot adapter parses). // `cwd` is load-bearing: the attach arm resumes with the transcript's ORIGINAL // cwd (ledger A15 -- real lines carry cwd on 100% of user/assistant lines). -function appendTranscript(cliSessionId, role, text, cwd) { +// A user line takes the send's own uuid when the server gave one, as the real +// CLI writes the prompt under its SDKUserMessage's uuid (the server's +// submittedTurnId). +function appendTranscript(cliSessionId, role, text, cwd, uuid) { const line = { type: role, + ...(typeof uuid === 'string' && uuid ? { uuid } : {}), timestamp: new Date().toISOString(), cwd: cwd ?? process.cwd(), message: { role, content: [{ type: 'text', text }] }, @@ -143,7 +147,7 @@ rl.on('line', (line) => { const { cliSessionId, cwd, model, effort, permissionMode } = sessions.get(msg.sessionId) ?? { cliSessionId: CLI_SESSION_ID } emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) if (INIT_AT_TURN) emitSessionInit(msg.sessionId, { cliSessionId, cwd, model }) - appendTranscript(cliSessionId, 'user', msg.text, cwd) + appendTranscript(cliSessionId, 'user', msg.text, cwd, msg.uuid) const holdOnce = HOLD_ONCE_MARKER && !fs.existsSync(HOLD_ONCE_MARKER) if (holdOnce) { fs.mkdirSync(path.dirname(HOLD_ONCE_MARKER), { recursive: true }) @@ -161,10 +165,9 @@ rl.on('line', (line) => { content: [{ type: 'text', text }], model, }) - // A turn ends the way the real sidecar ends it: its result, then the - // attention edge. No per-turn idle (the real sidecar sends no idle at all). - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } }) - emit({ type: 'sdk.turn.complete', sessionId: msg.sessionId, at: Date.now() }) + // A turn ends the way the real sidecar ends it: its result, which the + // server rings (C18). No per-turn idle (the real sidecar sends no idle at all). + emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1, durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } }) } else if (msg.type === 'shutdown') { process.exit(0) } diff --git a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs index a96d5e4b0..742aa5111 100644 --- a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs +++ b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs @@ -7,7 +7,7 @@ // // in : {"type":"create",protocol:2,requestId,cwd,model,permissionMode,effort, // boot:{kind:'fresh'}|{kind:'resume',resume}} (protocol v2, crates/freshell-claude-sidecar/boot.mjs) -// {"type":"send",sessionId,text} {"type":"interrupt",sessionId} {"type":"shutdown"} +// {"type":"send",sessionId,uuid,text} {"type":"interrupt",sessionId,stopId} {"type":"shutdown"} // out: {"type":"created","requestId","sessionId",protocol:2} FIRST (bare nanoid placeholder, // read_created discards any earlier sdk.* line), then: // sdk.session.init {sessionId,cliSessionId,model,cwd,tools:[]} @@ -15,17 +15,18 @@ // sdk.status {sessionId,status} (running at a send; idle only after // create and an interrupt with nothing // in flight: a turn ends with its result) -// sdk.turn.waiting {sessionId,at} (approval edge; before assistant) +// sdk.permission.request {sessionId,requestId,subtype,tool} (approval knob; before +// the assistant, then cancelled when the +// fake allows it on its own) // sdk.assistant {sessionId,content:[],model} -// sdk.result {sessionId,result:,durationMs,costUsd,usage} -// sdk.turn.complete {sessionId,at} on EVERY result subtype — success, -// error, … — EXCEPT the stopped turn's -// own aborted result under a pending -// accepted user-interrupt mark -// (the REAL gate, imported from -// crates/freshell-claude-sidecar/) -// sdk.interrupt_settled {sessionId,ok} (interrupt; the interrupted turn's -// own non-success sdk.result follows) +// sdk.result {sessionId,result,subtype,isError,terminalReason?,numTurns,…} +// the facts the server's verdict reads +// sdk.interrupt_settled {sessionId,stopId,ok} (interrupt; the interrupted turn's +// own aborted sdk.result follows) +// +// Like the real sidecar (C18) it mints NO attention edge: the server rings +// every turn end itself from the results, minus the one the user's own Stop +// landed on, and mints the waiting edge from the pending approval. // // Every inbound request is appended to FAKE_KILROY_LOG as JSONL // ({pid,t,msg}) BEFORE handling — "records Kilroy invocations". @@ -34,36 +35,22 @@ // FAKE_KILROY_LOG JSONL request ledger path // FAKE_KILROY_CLI_SESSION_ID fixed durable UUID (default: per-process random) // FAKE_KILROY_HOLD_TURN=1 send starts running and never completes on its -// own (interrupt is the only turn exit; its result -// is consumed by the armed gate) -// FAKE_KILROY_APPROVAL=1 send surfaces sdk.turn.waiting first, then -// auto-allows after FAKE_KILROY_APPROVAL_DELAY_MS -// (default 250) — the real sidecar's waiting-edge -// shape ("surfaced, then allowed") -// FAKE_KILROY_FAIL_RESULT=1 result subtype 'error' — rings the unified -// attention edge like any error turn end +// own (interrupt is the only turn exit) +// FAKE_KILROY_APPROVAL=1 send surfaces an approval request first, then +// allows it on its own after +// FAKE_KILROY_APPROVAL_DELAY_MS (default 250) +// FAKE_KILROY_FAIL_RESULT=1 the turn ends error_during_execution (a failed turn) // FAKE_KILROY_CRASH_ON_SEND=1 process.exit(3) mid-turn — no completion ever // (a real crash screams no protocol frame; the -// Rust unrequested-death synthesis rings for it) +// server rings the death of an owed turn) // -// Turn semantics mirror the real sidecar exactly: sdk.result carries the -// subtype in `result`; the unified attention edge `sdk.turn.complete` fires on -// EVERY result subtype unless the REAL gate's pending accepted user-interrupt -// mark silences it as the stopped turn's aborted result (an interrupt arms the mark only while a turn is awaiting a -// terminal frame; the settle is ok:true — the SDK contract documents -// RESOLUTION, not rejection — and the interrupted turn's own NON-SUCCESS -// result follows, silently). Interrupt ends the TURN, never the session: NO -// sdk.exit, the session stays alive, and the NEXT turn rings again. `at` -// clocks are per-session monotonic via the sidecar package's shared -// monotonic-clock.mjs (never go backwards, strictly increasing across -// same-ms edges). +// Interrupt ends the TURN, never the session: NO sdk.exit, the session stays +// alive, and the NEXT turn runs as usual. import readline from 'node:readline' import fs from 'node:fs' import path from 'node:path' import { randomBytes, randomUUID } from 'node:crypto' -import { createTurnCompleteGate } from '../../../crates/freshell-claude-sidecar/turn-complete-gate.mjs' -import { nextMonotonic } from '../../../crates/freshell-claude-sidecar/monotonic-clock.mjs' const LOG = process.env.FAKE_KILROY_LOG const HOLD_TURN = process.env.FAKE_KILROY_HOLD_TURN === '1' @@ -72,8 +59,17 @@ const APPROVAL_DELAY_MS = Number(process.env.FAKE_KILROY_APPROVAL_DELAY_MS ?? 25 const FAIL_RESULT = process.env.FAKE_KILROY_FAIL_RESULT === '1' const CRASH_ON_SEND = process.env.FAKE_KILROY_CRASH_ON_SEND === '1' const CLI_SESSION_ID = process.env.FAKE_KILROY_CLI_SESSION_ID ?? randomUUID() -/** The SDK result the pinned CLI reports for a turn a Stop landed on (T11). */ -const STOPPED_RESULT = Object.freeze({ type: 'result', subtype: 'error_during_execution', terminal_reason: 'aborted_streaming' }) +/** The result facts the real sidecar forwards for a turn a Stop landed on (T11). */ +const STOPPED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 }) +/** The result facts of a turn that ran to its end. */ +const ENDED_RESULT = Object.freeze({ subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1 }) +/** The result facts of a failed turn (FAKE_KILROY_FAIL_RESULT). */ +const FAILED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, numTurns: 1, errorText: 'Fixture turn failed' }) + +/** One `sdk.result` frame with `facts`, in the real sidecar's shape. */ +function resultFrame(sessionId, facts) { + return { type: 'sdk.result', sessionId, result: facts.subtype, ...facts, durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } } +} const NANOID_ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' function nanoid(size = 21) { @@ -93,24 +89,13 @@ function logRequest(msg) { fs.appendFileSync(LOG, `${JSON.stringify({ pid: process.pid, t: Date.now(), msg })}\n`) } -// sessionId -> { cliSessionId, cwd, lastTurnCompleteAt?, lastWaitingAt?, -// turnCompleteGate, pendingResults, turnOpen, sendInFlight, -// interrupted } +// sessionId -> { cliSessionId, cwd, pendingResults, sendInFlight, interrupted } const sessions = new Map() -/** Unified per-session attention bookkeeping (mirrors the real sidecar and - * the claude-SDK e2e fake): the REAL gate, the accepted-sends counter, the - * last minted monotonic `at`, and the in-flight/interrupt render flags. */ -function unifiedSessionState(cliSessionId, cwd) { - return { - cliSessionId, - cwd, - turnCompleteGate: createTurnCompleteGate(), - pendingResults: 0, - turnOpen: false, - sendInFlight: false, - interrupted: false, - } +/** Per-session turn bookkeeping: the turns owed a result, and the in-flight + * and interrupt render flags. */ +function sessionState(cliSessionId, cwd) { + return { cliSessionId, cwd, pendingResults: 0, sendInFlight: false, interrupted: false } } const rl = readline.createInterface({ input: process.stdin }) @@ -131,7 +116,7 @@ async function handle(msg) { const resumeSessionId = msg.boot?.resume const cliSessionId = resumeSessionId ?? CLI_SESSION_ID const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, unifiedSessionState(cliSessionId, cwd)) + sessions.set(sessionId, sessionState(cliSessionId, cwd)) // `created` MUST be the first line written for this request. emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) emit({ @@ -150,27 +135,27 @@ async function handle(msg) { } if (msg.type === 'send') { - const st = sessions.get(msg.sessionId) ?? unifiedSessionState(CLI_SESSION_ID, process.cwd()) + const st = sessions.get(msg.sessionId) ?? sessionState(CLI_SESSION_ID, process.cwd()) sessions.set(msg.sessionId, st) emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) if (CRASH_ON_SEND) { process.stderr.write('[fake-kilroy] FAKE_KILROY_CRASH_ON_SEND: exiting 3 mid-turn\n') process.exit(3) } - // The send is ACCEPTED (mirrors the real sidecar's pendingResults - // bookkeeping): a turn is open until its terminal frame, so an interrupt - // arriving mid-render arms the REAL gate against it. + // A turn is owed until its result, so an interrupt arriving mid-render + // ends it. st.pendingResults += 1 - st.turnOpen = true if (HOLD_TURN) return // wedged mid-turn; interrupt is the only turn exit st.sendInFlight = true try { if (APPROVAL) { - const at = nextMonotonic(st.lastWaitingAt, Date.now()) - st.lastWaitingAt = at - emit({ type: 'sdk.turn.waiting', sessionId: msg.sessionId, at }) + // The real sidecar's approval shape: the request (the server mints the + // waiting edge from it), then the fake allows it on its own. + const requestId = nanoid() + emit({ type: 'sdk.permission.request', sessionId: msg.sessionId, requestId, subtype: 'can_use_tool', tool: { name: 'Bash', input: { command: 'ls' } } }) await new Promise((r) => setTimeout(r, APPROVAL_DELAY_MS)) + emit({ type: 'sdk.permission.cancelled', sessionId: msg.sessionId, requestId }) } emit({ @@ -180,34 +165,13 @@ async function handle(msg) { model: 'claude-opus-4-6', }) // An in-flight interrupt deferred to this scripted completion: the - // interrupted turn's own terminal result is the CLI's aborted shape (T11 - // — an interrupted turn never ends 'success'). - const result = st.interrupted - ? STOPPED_RESULT - : { type: 'result', subtype: FAIL_RESULT ? 'error' : 'success' } + // interrupted turn's own result is the CLI's aborted shape (T11 — an + // interrupted turn never ends 'success'). + const facts = st.interrupted ? STOPPED_RESULT : FAIL_RESULT ? FAILED_RESULT : ENDED_RESULT st.interrupted = false st.pendingResults = Math.max(0, st.pendingResults - 1) - emit({ - type: 'sdk.result', - sessionId: msg.sessionId, - result: result.subtype, - durationMs: 1, - costUsd: 0, - usage: { input_tokens: 1, output_tokens: 1 }, - }) - // Unified attention edge (the REAL gate): every result rings except the - // stopped turn's own aborted result under a pending accepted - // user-interrupt mark. The `at` - // clamps through the sidecar package's shared monotonic clock — two - // same-ms edges stay strictly increasing or the client's `at <= last` - // dedupe silently drops the second ring. - if (st.turnCompleteGate.resultEmitsAttention(result)) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId: msg.sessionId, at }) - } // The result ends the turn: no idle follows (the real sidecar sends none). - st.turnOpen = false + emit(resultFrame(msg.sessionId, facts)) } finally { st.sendInFlight = false } @@ -216,49 +180,33 @@ async function handle(msg) { if (msg.type === 'interrupt') { const st = sessions.get(msg.sessionId) + const settled = (fields) => emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId ?? null, ...fields }) if (!st) { - // Real-sidecar parity (index.mjs handleInterrupt): an interrupt against - // an unknown session is a session-scoped error frame, never a silent - // drop. - emit({ type: 'sdk.error', sessionId: msg.sessionId, message: 'session not found', sessionNotFound: true }) + // Real-sidecar parity (index.mjs handleInterrupt): a session this fake + // does not host settles ok:false. + settled({ ok: false, message: 'Claude session is no longer available.' }) return } if (st.pendingResults > 0) { - // A turn is plausibly awaiting a terminal frame — arm the REAL gate - // BEFORE the "interrupt()" call (real-sidecar parity: - // `if (st.pendingResults > 0) st.turnCompleteGate.noteInterruptRequest()`), - // then settle ok:true (the SDK contract documents RESOLUTION, not - // rejection, for interrupting — sdk.d.ts:2384-2394). The interrupted - // turn's own NON-SUCCESS result follows with its ring consumed by the - // mark — the interrupt stays silent while the next turn rings again. - // NEVER an sdk.exit: interrupt ends the TURN, never the session. - st.turnCompleteGate.noteInterruptRequest() + // A turn is owed: settle ok:true (the receipt comes before the stopped + // turn's result, sdk.d.ts:3765), then the stopped turn's own aborted + // result. NEVER an sdk.exit: interrupt ends the TURN, never the session. st.pendingResults = 0 - // Force the interrupted turn's eventual completion to the non-success - // subtype (the real SDK contract — an interrupted turn never ends - // 'success'), for BOTH exits below: the deferred in-flight render and - // the synthesized held-turn result. st.interrupted = true - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: true }) + settled({ ok: true, stillQueued: [] }) if (st.sendInFlight) { // The scripted turn is still mid-render (the approval lane's await): - // its completion lands next with the forced non-success subtype and - // the gate-consumed ring — do not synthesize a second terminal frame. + // its completion lands next with the aborted result. return } - // Held turn: nothing else will end it, so synthesize the interrupted - // turn's own non-success terminal result — the armed gate consumes its - // ring. The result ends the turn (no idle follows). Still NEVER an sdk.exit. + // Held turn: nothing else will end it, so the stopped turn's own result + // follows here. The result ends the turn (no idle follows). st.interrupted = false - st.turnOpen = false - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: STOPPED_RESULT.subtype }) - st.turnCompleteGate.resultEmitsAttention(STOPPED_RESULT) + emit(resultFrame(msg.sessionId, STOPPED_RESULT)) return } - // No turn awaits a terminal frame — the SDK RESOLVES an idle-session - // interrupt, so the settle lands ok:true and NOTHING arms: no stray mark - // may survive to eat the NEXT unrelated turn's ring. - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: true }) + // Nothing owed: the SDK resolves an idle interrupt (sdk.d.ts:2384-2394). + settled({ ok: true, stillQueued: [] }) emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) return } diff --git a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs index 124b63a09..777f67607 100755 --- a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs +++ b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs @@ -17,29 +17,32 @@ // raw-chain successor of the resume point refuses with the SDK-documented // "Resume rejected by --resume-drops-turn:" prefix — NO init, NO durable session; // plain resume keeps the same-id behavior) -// {"type":"send",sessionId,text} {"type":"interrupt",sessionId} {"type":"shutdown"} +// {"type":"send",sessionId,uuid,text} {"type":"interrupt",sessionId,stopId} {"type":"shutdown"} // {"type":"permission.respond",sessionId,requestId,decision} // {"type":"question.respond",sessionId,requestId,answers} // out: {"type":"created",requestId,sessionId,protocol:2} FIRST (claude_handshake.rs // discards any earlier line), then sdk.* frames: // sdk.session.init {cliSessionId: CANONICAL UUID}, sdk.status, -// sdk.assistant (content MUST be an ARRAY), sdk.result {result} on EVERY -// turn result + sdk.turn.complete (numeric `at`) on EVERY result subtype -// EXCEPT the interrupted turn's own (the accepted-interrupt mark consumes -// it — the REAL gate, imported from crates/freshell-claude-sidecar/ -// turn-complete-gate.mjs), sdk.permission.request -// / sdk.question.request (server sdk-bridge-types.ts shapes), -// sdk.permission.cancelled / sdk.question.cancelled (interrupt-time -// pending cancellation, the real sidecar's cancelPending — -// index.mjs:289-295), sdk.turn.waiting (0→≥1 pending edge), +// sdk.assistant (content MUST be an ARRAY), sdk.result on EVERY turn +// result with the facts the server's verdict reads (subtype, isError, +// terminalReason: aborted_streaming for a stopped turn, numTurns), +// sdk.permission.request / sdk.question.request (server +// sdk-bridge-types.ts shapes), sdk.permission.cancelled / +// sdk.question.cancelled (interrupt-time pending cancellation, the real +// sidecar's cancelPending), sdk.interrupt_settled {stopId, ok}, // sdk.session.snapshot (resume). // +// Like the real sidecar (C18) it mints NO attention edge: the server rings +// every turn end from its result (minus the one the user's own Stop landed +// on), mints the waiting edge from the first pending request, and rings the +// death of an owed turn. +// // Turn semantics: `send` ALWAYS opens with sdk.status running (bookkeeping the // real bridge performs unconditionally); a matching program rule then owns // the turn; when no rule emitted completion/crash — AND no approval/question -// parked the turn — the canned assistant+result+turn.complete success turn -// closes it. Like the real sidecar, a turn ends with its result alone: there -// is no per-turn idle (the real sidecar sends no idle at all). +// parked the turn — the canned assistant+result success turn closes it. Like +// the real sidecar, a turn ends with its result alone: there is no per-turn +// idle (the real sidecar sends no idle at all). // A turn that raised an approval/question PARKS: it stays open // until a matching permission.respond/question.respond (or interrupt) arrives, // exactly like the real sidecar's parked canUseTool promise. @@ -47,26 +50,18 @@ // Respond arms (AGENT-05/06 e2e): permission.respond/question.respond are // routed into the program engine (decision points `msg:permission.respond` / // `msg:question.respond` — e.g. a `kind:'completion'` emission continues the -// turn). Each respond REMOVES the tracked pending entry and DECREMENTS the -// per-session pending counter, so a later raise re-crosses 0→≥1 and -// `sdk.turn.waiting` re-fires. Unknown requestIds are lose-safely ignored -// (the Rust dispatch validates against its own pending set first). +// turn). Each respond REMOVES the tracked pending entry. Unknown requestIds +// are lose-safely ignored (the Rust dispatch validates against its own +// pending set first). // // Interrupt semantics: every parked entry gets ONE sdk.permission.cancelled / -// sdk.question.cancelled frame and the pending counter resets. The arm -// condition mirrors the real sidecar exactly: a turn plausibly awaiting a -// terminal frame (pendingResults > 0) arms the gate BEFORE the "interrupt()" -// call, the settle is ok:true, and the interrupted turn's own NON-SUCCESS +// sdk.question.cancelled frame. While a turn is owed a result the settle is +// ok:true (echoing the Stop's id) and the interrupted turn's own aborted // sdk.result follows (the real SDK contract orders the receipt BEFORE the -// result — sdk.d.ts:3765); the armed gate consumes that result's ring, so an -// interrupt stays silent while the next turn rings again. With NOTHING -// pending the settle is STILL ok:true — the SDK contract documents -// RESOLUTION, not rejection, for interrupting with nothing in flight -// (sdk.d.ts:2384-2394; the real sidecar's ok:false 'no in-flight SDK query' -// shape fires only when the SDK surface lacks the interrupt method entirely) -// — and NOTHING arms, so no stray mark survives to eat the NEXT unrelated -// turn's ring. Either way it is NEVER an sdk.exit (the real sidecar keeps the -// session alive across interrupt; AGENT-03's interrupt/kill separation). +// result — sdk.d.ts:3765). With NOTHING owed the settle is STILL ok:true — +// the SDK contract documents RESOLUTION, not rejection, for interrupting with +// nothing in flight (sdk.d.ts:2384-2394). Either way it is NEVER an sdk.exit +// (the real sidecar keeps the session alive across interrupt). // // Raw-stdin audit (AGENT-05/06 e2e): when FRESHELL_FAKE_STDIN= is set, // EVERY raw stdin line is appended there as a JSONL row {t, pid, line} — @@ -74,27 +69,19 @@ // server wrote to the sidecar" and for zero-before-click proofs. // (FRESHELL_FAKE_* keys are auto-recorded into the launch ledger.) // -// Wire-truth completion invariant (unified needs-attention contract; the REAL -// gate imported above): a closing turn emits `sdk.result{result:}` -// ALWAYS and the attention edge `sdk.turn.complete{sessionId,at}` on EVERY -// subtype — success, error, max-turns, denied — EXCEPT the stopped turn's own -// aborted result under a pending accepted user-interrupt mark (the user's own -// interrupt never rings). // The `stream-error` lane models the real sidecar's end of stream (its -// consumeStream finally): cancel the parked cards, the finally-minted edge for -// a turn that ends with no result, then +// consumeStream finally): cancel the parked cards, then // `sdk.stream.ended {reason, message}` as the LAST line and exit code 3 (each // sidecar hosts one session, so its stream's end is the sidecar's end). The -// Rust server reports the exit (SIDECAR_EXITED) and, because the end was -// reported, does not ring again. The `crash` lane stays as-is (a real Node -// death screams no protocol frame and exits — the Rust unrequested-death -// synthesis rings for it, and an edge from the crash lane would double-ring). +// Rust server reports the exit (SIDECAR_EXITED) and rings the cut-off turn. +// The `crash` lane stays as-is (a real Node death screams no protocol frame +// and exits). // // Wire audit (D1-F2): every OUTBOUND frame is ALSO recorded into the // FRESHELL_FAKE_EVENTS ledger as a `{t,pid,provider,kind:'wire',frame}` row, -// so specs assert on what actually crossed stdout (e.g. the unified -// sdk.turn.complete edge ringing for a denied/errored turn) instead of -// trusting the program emission ledger alone. Program rows and wire rows +// so specs assert on what actually crossed stdout (e.g. the result a +// denied/errored turn ended with) instead of trusting the program emission +// ledger alone. Program rows and wire rows // coexist there; filter on `kind === 'wire'` for wire truth (event-kind // consumers see no shape change). // @@ -120,16 +107,21 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import readline from 'node:readline' import { appendJsonl, appendLaunchLedger, EVENTS_ENV, FixtureEngine, keepAlive, loadProgram } from './fixture-core.mjs' -import { createTurnCompleteGate } from '../../../../crates/freshell-claude-sidecar/turn-complete-gate.mjs' -import { nextMonotonic } from '../../../../crates/freshell-claude-sidecar/monotonic-clock.mjs' const provider = process.env.FRESHELL_FAKE_PROVIDER ?? 'kilroy' /** The real sidecar's exit code after its SDK stream ends (stream-end.mjs). */ const STREAM_ENDED_EXIT_CODE = 3 /** Set by the stream-error lane: the fake is exiting and reads no more requests. */ let exiting = false -/** The SDK result the pinned CLI reports for a turn a Stop landed on (T11). */ -const STOPPED_RESULT = Object.freeze({ type: 'result', subtype: 'error_during_execution', terminal_reason: 'aborted_streaming' }) +/** The result facts the real sidecar forwards for a turn a Stop landed on (T11). */ +const STOPPED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 }) + +/** The result facts of a turn that ended with `subtype` on its own. */ +function endedResult(subtype) { + return subtype === 'success' + ? { subtype, isError: false, terminalReason: 'completed', numTurns: 1 } + : { subtype, isError: true, numTurns: 1 } +} const env = process.env appendLaunchLedger({ provider, argv: process.argv.slice(2), env }) const program = loadProgram(env) @@ -171,10 +163,15 @@ function transcriptPath(cliSessionId, cwd) { // uuid + parentUuid backbone, chained per cliSessionId (the real CLI's shape). const lastUuidBySession = new Map() -/** Append one transcript line in parse_transcript_turns' accepted shape. */ -function appendTranscript(cliSessionId, cwd, role, text, transcriptOverride) { +/** + * Append one transcript line in parse_transcript_turns' accepted shape. A + * user line takes the send's own uuid when the server gave one: the real CLI + * writes the prompt under the uuid of its SDKUserMessage, which is the turn + * id the server announced as submittedTurnId. + */ +function appendTranscript(cliSessionId, cwd, role, text, transcriptOverride, lineUuid) { const parentUuid = lastUuidBySession.get(cliSessionId) ?? null - const uuid = randomUUID() + const uuid = typeof lineUuid === 'string' && lineUuid ? lineUuid : randomUUID() lastUuidBySession.set(cliSessionId, uuid) const line = { type: role, @@ -188,16 +185,6 @@ function appendTranscript(cliSessionId, cwd, role, text, transcriptOverride) { } // ── pending-request tracking (mirrors the real sidecar's permission-channel) ── -/** Waiting edge on the 0→>=1 pending transition (sdk-bridge.ts emitWaitingEdge). */ -function waitingEdgeIfFirstPending(sessionId) { - const st = sessions.get(sessionId) - if (!st) return - if (st.pending === 0) { - emit({ type: 'sdk.turn.waiting', sessionId, at: Date.now() }) - } - st.pending += 1 -} - function trackPending(sessionId, kind, requestId) { const st = sessions.get(sessionId) if (!st) return @@ -206,11 +193,7 @@ function trackPending(sessionId, kind, requestId) { /** * Resolve ONE parked request (permission.respond/question.respond): drop the - * tracked entry and decrement the counter so a later raise re-fires the - * 0→≥1 waiting edge. Unknown requestIds are a lose-safely no-op — the - * decrement is gated on having actually resolved a tracked entry, or a - * foreign respond would desync pending vs pendingEntries and let the next - * raise spuriously re-fire the waiting edge (task-008-review N-2). + * tracked entry. Unknown requestIds are a lose-safely no-op. */ function resolvePending(sessionId, kind, requestId) { const st = sessions.get(sessionId) @@ -220,7 +203,6 @@ function resolvePending(sessionId, kind, requestId) { ) if (idx === -1) return st.pendingEntries.splice(idx, 1) - if (st.pending > 0) st.pending -= 1 } async function render(event) { @@ -244,7 +226,6 @@ async function render(event) { emit({ type: 'sdk.status', sessionId, status: data.status ?? 'running' }) break case 'approval': { - waitingEdgeIfFirstPending(sessionId) const input = typeof data.input === 'object' && data.input !== null ? data.input : { command: data.input } const requestId = String(data.id ?? `perm-${randomUUID()}`) trackPending(sessionId, 'permission', requestId) @@ -258,7 +239,6 @@ async function render(event) { break } case 'question': { - waitingEdgeIfFirstPending(sessionId) const questions = Array.isArray(data.questions) ? data.questions : [{ question: data.text ?? '', header: 'Fixture', options: [], multiSelect: false }] @@ -277,7 +257,7 @@ async function render(event) { // Real-sidecar parity (index.mjs handleSdkMessage drops the whole // message for a missing session): a completion targeting a session // that no longer exists (e.g. after the stream-error lane's teardown) - // is dropped ENTIRELY — no assistant, no result, no edge, no idle. + // is dropped ENTIRELY — no assistant, no result, no idle. // The fake must not invent frames the real sidecar never emits. if (!st) return emit({ @@ -290,34 +270,20 @@ async function render(event) { // An in-flight interrupt deferred to this scripted completion: the // interrupted turn's own terminal result is the CLI's aborted shape // (T11 — an interrupted turn never ends 'success'). - const result = st.interrupted - ? STOPPED_RESULT - : { type: 'result', subtype: data.subtype ?? 'success' } + const facts = st.interrupted ? STOPPED_RESULT : endedResult(data.subtype ?? 'success') st.interrupted = false st.pendingResults = Math.max(0, st.pendingResults - 1) - emit({ type: 'sdk.result', sessionId, result: result.subtype }) - // Unified attention edge (the REAL gate): every result rings except the - // stopped turn's own aborted result under a pending accepted user - // interrupt mark. The `at` mint clamps through the REAL sidecar's shared - // clock (monotonic-clock.mjs) — two same-ms edges stay strictly - // increasing, or the client's `at <= last` dedupe silently drops the - // second ring. - if (st.turnCompleteGate.resultEmitsAttention(result)) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId, at }) - } - st.turnOpen = false + // The facts the server's verdict reads: it rings the turn (or not, for + // the user's own Stop) and shows a failed turn's banner. + emit({ type: 'sdk.result', sessionId, result: facts.subtype, ...facts }) break } case 'stream-error': { // The real sidecar's end of stream (index.mjs consumeStream finally): - // cancel every parked entry, mint the unified attention edge (a turn - // ended with NO result frame: pendingResults > 0; a pending Stop mark - // never silences a result-less end), then sdk.stream.ended as the LAST - // line and exit code 3 (no stream-end status: the server reports the - // ended session as exited). The separate - // `crash` lane deliberately stays different: no protocol frame + exit. + // cancel every parked entry, then sdk.stream.ended as the LAST line and + // exit code 3 (no stream-end status: the server reports the ended + // session as exited, and rings a cut-off turn). The separate `crash` + // lane deliberately stays different: no protocol frame + exit. const st = sessions.get(sessionId) if (st) { for (const entry of st.pendingEntries.splice(0)) { @@ -327,12 +293,6 @@ async function render(event) { requestId: entry.requestId, }) } - st.pending = 0 - if (st.pendingResults > 0) { - const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) - st.lastTurnCompleteAt = at - emit({ type: 'sdk.turn.complete', sessionId, at }) - } } if (st) sessions.delete(sessionId) emit({ @@ -347,7 +307,6 @@ async function render(event) { } case 'marker': if (data.signal === 'interrupt') { - emit({ type: 'sdk.exit', sessionId }) emit({ type: 'sdk.status', sessionId, status: 'idle' }) } break @@ -410,16 +369,13 @@ async function handleInput(line) { ? path.basename(resumePath).replace(/\.jsonl$/, '') : (resumeRaw ?? program.sessionId ?? randomUUID())) const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, { cliSessionId, cwd, pending: 0, pendingEntries: [], + sessions.set(sessionId, { cliSessionId, cwd, pendingEntries: [], transcriptOverride: resumePath, settings: { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd }, - // Unified attention bookkeeping (mirrors the real sidecar's per-session - // state): the gate, the accepted-sends counter, and the last minted - // monotonic `at` — the interrupt arm, the stream-error lane, and every - // turn-complete mint consult them. - turnCompleteGate: createTurnCompleteGate(), pendingResults: 0, - lastTurnCompleteAt: undefined, - turnOpen: false, sendInFlight: false, interrupted: false }) + // Turn bookkeeping: the sends owed a result (an interrupt ends the + // owed turn with its aborted result) and the in-flight/interrupt + // render flags. + pendingResults: 0, sendInFlight: false, interrupted: false }) // A durable transcript EXISTS from create on (the reload-while-pending // snapshot route reads it before any turn completes) — touch, no bogus row. // A path-resumed session's transcript IS the named file. @@ -498,15 +454,13 @@ async function handleInput(line) { } else if (msg.type === 'send') { activeSessionId = msg.sessionId ?? activeSessionId const st = sessions.get(msg.sessionId) - if (st) appendTranscript(st.cliSessionId, st.cwd, 'user', msg.text, st.transcriptOverride) + if (st) appendTranscript(st.cliSessionId, st.cwd, 'user', msg.text, st.transcriptOverride, msg.uuid) if (st) { - // Unified attention bookkeeping (mirrors the real sidecar): the send is - // ACCEPTED (pendingResults += 1) and a turn is open until its terminal - // frame; sendInFlight marks the awaited program-handling window, so an - // interrupt arriving during a delayed scripted emission defers to that - // completion instead of synthesizing a second terminal frame. + // The send is owed a result; sendInFlight marks the awaited + // program-handling window, so an interrupt arriving during a delayed + // scripted emission defers to that completion instead of synthesizing + // a second terminal frame. st.pendingResults += 1 - st.turnOpen = true st.sendInFlight = true } try { @@ -534,92 +488,56 @@ async function handleInput(line) { } else if (msg.type === 'interrupt') { activeSessionId = msg.sessionId ?? activeSessionId const st = sessions.get(msg.sessionId) + const settled = (fields) => emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId ?? null, ...fields }) if (!st) { - // Real-sidecar parity (index.mjs handleInterrupt): an interrupt against - // an unknown session is a session-scoped error frame, never a settle. - emit({ type: 'sdk.error', sessionId: msg.sessionId, message: 'session not found', sessionNotFound: true }) + // Real-sidecar parity (index.mjs handleInterrupt): a session this + // sidecar does not host settles ok:false. + settled({ ok: false, message: 'Claude session is no longer available.' }) return } - let interruptedInFlight = false - if (st) { - // Mirror the real sidecar's interrupt path (index.mjs — the transport is - // still open, so cancelPending emits one cancel frame per parked entry, - // never a fabricated user respond), then NO sdk.exit: interrupt ends the - // TURN, never the session (AGENT-03). - for (const entry of st.pendingEntries.splice(0)) { - emit({ - type: entry.kind === 'question' ? 'sdk.question.cancelled' : 'sdk.permission.cancelled', - sessionId: msg.sessionId, - requestId: entry.requestId, - }) - } - st.pending = 0 - if (st.pendingResults > 0) { - // A turn is plausibly awaiting a terminal frame — arm the REAL gate - // BEFORE the "interrupt()" call, exactly like the real sidecar - // (index.mjs: `if (st.pendingResults > 0) - // st.turnCompleteGate.noteInterruptRequest()`); the SDK contract - // orders the settle receipt BEFORE the interrupted turn's own result - // (sdk.d.ts:3765), so the mark deterministically consumes that - // result's ring (the interrupt stays silent; the next turn rings). - st.turnCompleteGate.noteInterruptRequest() - interruptedInFlight = true - } + // Mirror the real sidecar's interrupt path (index.mjs — the transport is + // still open, so cancelPending emits one cancel frame per parked entry, + // never a fabricated user respond), then NO sdk.exit: interrupt ends the + // TURN, never the session (AGENT-03). + for (const entry of st.pendingEntries.splice(0)) { + emit({ + type: entry.kind === 'question' ? 'sdk.question.cancelled' : 'sdk.permission.cancelled', + sessionId: msg.sessionId, + requestId: entry.requestId, + }) } - if (interruptedInFlight) { - // Force the interrupted turn's eventual completion to the non-success - // subtype BEFORE program rules run, so a completion scripted on - // msg:interrupt renders the interrupted turn truthfully. + if (st.pendingResults > 0) { + // A turn is owed: the receipt comes BEFORE the stopped turn's own result + // (sdk.d.ts:3765). Force that result to the aborted shape BEFORE program + // rules run, so a completion scripted on msg:interrupt renders the + // interrupted turn truthfully. st.interrupted = true - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: true }) + settled({ ok: true, stillQueued: [] }) const emitted = await engine.handleMessage(msg) if (emitted.has('crash')) return // The program ended the interrupted turn itself (its completion render - // already forced the non-success subtype and consumed the gate mark). + // already took the aborted shape). if (emitted.has('completion')) return - // A delayed scripted completion is still mid-flight: let it land (it - // renders the forced subtype with the gate-suppressed edge). + // A delayed scripted completion is still mid-flight: let it land. if (st.sendInFlight) return - // Parked/plain in-flight turn: nothing else will end it, so synthesize - // the interrupted turn's own non-success terminal result — the armed - // gate consumes its ring. Still NEVER an sdk.exit. The - // counter goes to ZERO, not minus one: the fake models ONE in-flight - // turn, so the interrupt ends everything pending — a stale-positive - // counter here would let a later stream-error lane mint a phantom ring - // (task-004-review F-N1). + // Parked/plain in-flight turn: nothing else will end it, so the + // interrupted turn's own aborted result follows here. The counter goes + // to ZERO: the fake models ONE in-flight turn, so the interrupt ends + // everything owed. st.pendingResults = 0 - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: STOPPED_RESULT.subtype }) - st.turnCompleteGate.resultEmitsAttention(STOPPED_RESULT) - st.turnOpen = false st.interrupted = false + emit({ type: 'sdk.result', sessionId: msg.sessionId, result: STOPPED_RESULT.subtype, ...STOPPED_RESULT }) return } - // No turn awaits a terminal frame — real-sidecar parity: the SDK RESOLVES - // an idle-session interrupt (sdk.d.ts:2384-2394 — resolution, not - // rejection; the ok:false 'no in-flight SDK query' shape fires only when - // the SDK surface lacks the interrupt method entirely), so the settle - // lands ok:true and NOTHING armed — no stray mark can eat the NEXT - // unrelated turn's ring. The receipt must precede any of the frames - // below in stream order (the consumer folds the receipt only after - // provably-earlier evidence). - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: true }) + // Nothing owed: the SDK RESOLVES an idle-session interrupt + // (sdk.d.ts:2384-2394), so the settle lands ok:true. The receipt must + // precede any of the frames below in stream order. + settled({ ok: true, stillQueued: [] }) const emitted = await engine.handleMessage(msg) if (emitted.has('crash')) return if (!emitted.has('activity') && !emitted.has('marker')) { await engine.emitEvent('activity', { status: 'idle' }, 'msg:interrupt:idle') } - } else if (msg.type === 'rollback.quiesce') { - // kata 1wxv ep4-r3: rollback's pre-teardown quiesce probe. This faker has - // no SDK-input queue — every sent turn settles immediately on the drive - // side — so the answer is always all-clear with a probeId echo. - emit({ - type: 'sdk.rollback.quiesced', - sessionId: msg.sessionId, - probeId: msg.probeId ?? null, - cancelledQueue: 0, - inFlightTurn: false, - handedCompactLikely: false, - }) } else if (msg.type === 'shutdown') { process.exit(0) } diff --git a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts index 9012231dd..32eab1e64 100644 --- a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts +++ b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts @@ -1,8 +1,9 @@ // Unit coverage for the AGENT-04/05/06 arms of the HARNESS-03 fake claude-SDK // sidecar (`test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs`): -// permission.respond/question.respond pending-resolution (waiting edge -// re-fires), interrupt-time pending cancellation (cancel frames, never an -// sdk.exit), the parked-turn rule (no canned completion while parked), the +// permission.respond/question.respond pending-resolution, interrupt-time +// pending cancellation (cancel frames, never an sdk.exit), the result facts +// the server's verdict reads (C18: the fake mints no attention edge, like the +// real sidecar), the parked-turn rule (no canned completion while parked), the // stream-error lane's exit (the real sidecar's end of stream), the // FRESHELL_FAKE_STDIN raw-stdin audit, and the durable-transcript writes the // reload-while-pending snapshot route depends on. Process/wire-level contract @@ -147,7 +148,7 @@ const RAISE_PROGRAM = { describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', () => { it( - 'responds resolve the parked request and the waiting edge re-fires on a later raise', + 'responds resolve the parked requests; the fake mints no waiting or completion edge (the server does, C18)', async () => { const stdinLog = path.join(tmp, 'stdin.jsonl') const fx = launch(RAISE_PROGRAM, { FRESHELL_FAKE_STDIN: stdinLog }) @@ -164,38 +165,32 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', subtype: 'can_use_tool', tool: { name: 'Bash', input: { command: 'ls' } }, }) - // Parked: NO completion frame may arrive before the respond. - expect(fx.stdoutLines().some((o) => o.type === 'sdk.turn.complete')).toBe(false) - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting'), - 'first raise emits the 0->>=1 waiting edge exactly once', - ).toHaveLength(1) + // Parked: NO result may arrive before the respond. + expect(fx.stdoutLines().some((o) => o.type === 'sdk.result')).toBe(false) // A second CONCURRENT raise (distinct request id, like the real - // sidecar's nanoid mint) while parked must NOT re-fire the edge. + // sidecar's nanoid mint) while parked. fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION_2' }) await fx.waitLine((o) => o.type === 'sdk.permission.request' && o.requestId === 'req-perm-2', 'second permission.request') - expect(fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting')).toHaveLength(1) - // Resolving BOTH parked requests returns pending to 0, so the NEXT - // raise crosses 0->>=1 again and the edge re-fires — the proof the - // respond arm decrements the counter (pre-fix: reset only by interrupt). + // Each respond resolves its own parked request and its turn ends. fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-1', decision: { behavior: 'allow' } }) - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'turn completion after allow') + await fx.waitLine((o) => o.type === 'sdk.result', 'turn result after allow') fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-2', decision: { behavior: 'allow' } }) await fx.waitLine( - (o) => o.type === 'sdk.turn.complete' && fx.stdoutLines().filter((r) => r.type === 'sdk.turn.complete').length >= 2, - 'second turn completion', + (o) => o.type === 'sdk.result' && fx.stdoutLines().filter((r) => r.type === 'sdk.result').length >= 2, + 'second turn result', ) fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) await fx.waitLine( (o) => o.type === 'sdk.permission.request' && fx.stdoutLines().filter((r) => r.type === 'sdk.permission.request').length === 3, 'third permission.request', ) + // The server folds these requests and results into its own edges. expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting'), - 'a respond decrements pending, so the next raise re-fires sdk.turn.waiting', - ).toHaveLength(2) + fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting' || o.type === 'sdk.turn.complete'), + 'the fake mints no attention edge', + ).toEqual([]) // Raw-stdin audit: both respond frames landed verbatim in the // fixture's log (exactly the frames this test wrote — zero fabricated). @@ -227,26 +222,20 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - expect(fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting')).toHaveLength(1) // Resolve a requestId that was NEVER raised. (deny matches no // RAISE_PROGRAM rule, so this respond emits nothing else; only the // resolvePending ledger bookkeeping runs.) fx.send({ type: 'permission.respond', sessionId, requestId: 'req-never-raised', decision: { behavior: 'deny' } }) - // The ledger must still hold the real parked request: the next raise - // crosses NO 0->>=1 boundary, so no second waiting edge may fire. - // Pre-fix resolvePending decremented `pending` on ANY respond, so the - // re-raise below re-fired sdk.turn.waiting (pending desync). - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION_2' }) - await fx.waitLine( - (o) => o.type === 'sdk.permission.request' && o.requestId === 'req-perm-2', - 'second permission.request', - ) + // The ledger must still hold the real parked request: an interrupt + // cancels exactly it, and nothing for the foreign id. + fx.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) + await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'the settle') expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting'), - 'the foreign respond must not have decremented pending (no spurious waiting-edge re-fire)', - ).toHaveLength(1) + fx.stdoutLines().filter((o) => o.type === 'sdk.permission.cancelled').map((o) => o.requestId), + 'the foreign respond must not have resolved the real parked request', + ).toEqual(['req-perm-1']) } finally { await fx.stop() } @@ -265,13 +254,16 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', fx.send({ type: 'send', sessionId, text: 'RAISE_QUESTION' }) await fx.waitLine((o) => o.type === 'sdk.question.request', 'question.request') - fx.send({ type: 'interrupt', sessionId }) + fx.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) const cancelledPermission = await fx.waitLine((o) => o.type === 'sdk.permission.cancelled', 'sdk.permission.cancelled') expect(cancelledPermission).toMatchObject({ sessionId, requestId: 'req-perm-1' }) const cancelledQuestion = await fx.waitLine((o) => o.type === 'sdk.question.cancelled', 'sdk.question.cancelled') expect(cancelledQuestion).toMatchObject({ sessionId, requestId: 'req-q-1' }) - // The interrupted turn ends with its own (aborted) result, like the real CLI's (T11). - await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error_during_execution', 'the interrupted turn result') + // The receipt echoes the Stop's id, and the interrupted turn ends with + // its own aborted result, like the real CLI's (T11). + expect(await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'the settle')).toMatchObject({ sessionId, stopId: 'stop-1', ok: true }) + const stopped = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error_during_execution', 'the interrupted turn result') + expect(stopped).toMatchObject({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming' }) // AGENT-03 separation: interrupt ends the turn, never the session. expect(fx.stdoutLines().some((o) => o.type === 'sdk.exit')).toBe(false) @@ -285,13 +277,13 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', // The session is still usable: a plain send completes normally. fx.send({ type: 'send', sessionId, text: 'plain follow-up' }) - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'post-interrupt completion') + await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'post-interrupt completion') } finally { await fx.stop() } }) - it('a deny/errored completion emits sdk.result AND the unified attention edge, and no per-turn idle (needs-attention)', async () => { + it('a deny/errored completion emits sdk.result with its facts, no edge (the server rings it) and no per-turn idle', async () => { const eventsLog = path.join(tmp, 'events.jsonl') const fx = launch({ rules: [ @@ -320,31 +312,15 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', expect(fx.stdoutLines().some((o) => o.type === 'sdk.turn.complete')).toBe(false) fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-1', decision: { behavior: 'deny' } }) - // Race-free: the errored sdk.result is the terminal marker for the deny - // continuation (its edge renders synchronously after it). - await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error', 'errored sdk.result') - - // Unified needs-attention contract: a deny is an ERROR TURN END, so the - // fake (mirroring the real sidecar's gate) rings the SAME attention - // edge a success would — exactly one, minted after the errored result, - // with a finite numeric monotonic `at`. - const denyEdge = await fx.waitLine( - (o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId, - 'the deny turn.complete attention edge', - ) - expect(typeof denyEdge.at).toBe('number') - expect(Number.isFinite(denyEdge.at)).toBe(true) + // The errored sdk.result is the terminal marker for the deny + // continuation: it carries the facts the server's verdict reads. + const errored = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error', 'errored sdk.result') + expect(errored).toMatchObject({ sessionId, subtype: 'error', isError: true }) const out = fx.stdoutLines() - expect( - out.filter((o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId), - 'exactly one attention edge for the denied turn', - ).toHaveLength(1) const resultIdx = out.findIndex( (o) => o.type === 'sdk.result' && o.result === 'error' && o.sessionId === sessionId, ) - const denyEdgeIdx = out.findIndex((o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId) expect(resultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) - expect(denyEdgeIdx, 'the edge follows the errored result in stream order').toBeGreaterThan(resultIdx) // Like the real sidecar, the turn ends with its result alone: no idle // follows it (the real sidecar sends no idle at all). expect( @@ -360,33 +336,24 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', // The outbound wire audit records exactly what went over stdout. const wires = readJsonl(eventsLog).filter((r) => r.kind === 'wire') expect( - wires.filter((r) => r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === sessionId), - 'the wire audit too shows the deny turn.complete edge on stdout', + wires.filter((r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === sessionId), + 'the wire audit shows the errored result on stdout', ).toHaveLength(1) - // A plain turn still completes the positive way (fidelity pin): - // sdk.result{success} AND its own second attention edge, with a - // strictly-greater `at` (the shared monotonic clock). NB: match the - // SECOND edge by its `at` — a count-based predicate would resolve to - // the FIRST edge the moment two exist. + // A plain turn still completes the positive way (fidelity pin). fx.send({ type: 'send', sessionId, text: 'plain follow-up' }) - const successEdge = await fx.waitLine( - (o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId && Number(o.at) > denyEdge.at, - 'the follow-up success turn rings its own edge', - ) - expect(successEdge.at, 'the shared monotonic clock keeps same-ms edges strictly increasing').toBeGreaterThan(denyEdge.at) - const out2 = fx.stdoutLines() - expect(out2.some((o) => o.type === 'sdk.result' && o.result === 'success')).toBe(true) + const success = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'the follow-up success result') + expect(success).toMatchObject({ subtype: 'success', isError: false, terminalReason: 'completed' }) expect( - out2.filter((o) => o.type === 'sdk.turn.complete' && o.sessionId === sessionId), - 'two turns, two edges — one per turn end', - ).toHaveLength(2) + fx.stdoutLines().filter((o) => o.type === 'sdk.turn.complete' || o.type === 'sdk.turn.waiting'), + 'the fake mints no attention edge', + ).toEqual([]) } finally { await fx.stop() } }) - it('an interrupt on an idle session settles ok:true (SDK resolution) and the next turn ring survives (task-004 F-I1)', async () => { + it('an interrupt on an idle session settles ok:true (SDK resolution) with its stopId, and the next turn runs', async () => { const fx = launch({ rules: [] }) try { fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) @@ -398,39 +365,37 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', // idle-session interrupt (sdk.d.ts:2384-2394 — resolution, not // rejection; the ok:false 'no in-flight SDK query' shape fires only // when the SDK surface lacks the interrupt method entirely), so the - // real sidecar settles ok:true and arms NOTHING. The fake must mirror - // that shape — and no stray mark may eat the NEXT unrelated turn ring. - fx.send({ type: 'interrupt', sessionId }) + // real sidecar settles ok:true. The fake must mirror that shape. + fx.send({ type: 'interrupt', sessionId, stopId: 'stop-idle' }) const settle = await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'idle interrupt settle') - expect(settle).toMatchObject({ sessionId, ok: true }) + expect(settle).toMatchObject({ sessionId, stopId: 'stop-idle', ok: true }) fx.send({ type: 'send', sessionId, text: 'next unrelated turn' }) - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'the next turn still rings') + await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'the next turn runs') } finally { await fx.stop() } }) - it('an interrupt against a dead session answers the session-not-found error, never a settle (real-sidecar parity, task-008)', async () => { + it('an interrupt against a dead session settles ok:false, never a fabricated ok:true (real-sidecar parity)', async () => { const fx = launch({ rules: [] }) try { // No create ever ran: the session id is unknown. The REAL sidecar's - // handleInterrupt answers the session-scoped error frame - // (index.mjs: `if (!st) { emit sdk.error sessionNotFound } }`) and - // returns — never a fabricated ok:true settle. The kilroy fake already - // mirrors this; the claude fake must too. - fx.send({ type: 'interrupt', sessionId: 'never-created' }) - const err = await fx.waitLine((o) => o.type === 'sdk.error', 'dead-session interrupt error') - expect(err).toMatchObject({ sessionId: 'never-created', sessionNotFound: true }) + // handleInterrupt refuses it: its receipt is ok:false, echoing the + // Stop's id (index.mjs). The kilroy fake mirrors this; the claude fake + // must too. + fx.send({ type: 'interrupt', sessionId: 'never-created', stopId: 'stop-x' }) + const settle = await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'dead-session interrupt settle') + expect(settle).toMatchObject({ sessionId: 'never-created', stopId: 'stop-x', ok: false }) const out = fx.stdoutLines() - expect(out.filter((o) => o.type === 'sdk.interrupt_settled'), 'no settle for a dead session').toEqual([]) + expect(out.filter((o) => o.type === 'sdk.error'), 'no error frame for a dead session').toEqual([]) expect(out.filter((o) => o.type === 'sdk.status'), 'no fabricated activity for a dead session').toEqual([]) } finally { await fx.stop() } }) - it('the stream-error lane ends the sidecar like the real one: one edge, sdk.stream.ended last, exit code 3', async () => { + it('the stream-error lane ends the sidecar like the real one: sdk.stream.ended last, exit code 3, no edge', async () => { const fx = launch({ rules: [ { on: 'msg:send', match: { text: 'DIE' }, emit: [{ kind: 'stream-error' }] }, @@ -442,18 +407,19 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', const sessionId = created.sessionId as string const exited = new Promise((resolve) => fx.proc.once('exit', (code) => resolve(code))) - // The stream ends mid-turn: the finally-mint edge fires (the send was - // accepted, pendingResults > 0), then the end report, then the exit. + // The stream ends mid-turn: the end report, then the exit. The server + // rings the cut-off turn (C18), not the fake. fx.send({ type: 'send', sessionId, text: 'DIE' }) expect(await exited).toBe(3) const out = fx.stdoutLines() expect(out.at(-1)).toEqual({ type: 'sdk.stream.ended', sessionId, reason: 'stream_error', message: 'Fixture stream error' }) - expect(out.filter((o) => o.type === 'sdk.turn.complete'), 'exactly the result-less turn end rings').toHaveLength(1) + expect(out.filter((o) => o.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) expect(out.filter((o) => o.type === 'sdk.result'), 'the turn ended with no result').toEqual([]) expect(out.filter((o) => o.type === 'sdk.error'), 'the end report carries the cause; no error frame repeats it').toEqual([]) - const edgeIdx = out.findIndex((o) => o.type === 'sdk.turn.complete') + const turnOpened = out.findLastIndex((o) => o.type === 'sdk.status' && o.status === 'running') + expect(turnOpened, 'the send opened its turn').toBeGreaterThanOrEqual(0) expect( - out.slice(edgeIdx).filter((o) => o.type === 'sdk.status'), + out.slice(turnOpened + 1).filter((o) => o.type === 'sdk.status'), 'no stream-end status: the server reports the ended session as exited', ).toEqual([]) } finally { @@ -474,7 +440,7 @@ describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', expect(fs.existsSync(transcript), 'create must ensure the durable transcript file').toBe(true) fx.send({ type: 'send', sessionId: created.sessionId, text: 'hello durable world' }) - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'completion') + await fx.waitLine((o) => o.type === 'sdk.result', 'completion') const lines = readJsonl(transcript) expect(lines.map((l) => l.type)).toEqual(['user', 'assistant']) @@ -501,10 +467,10 @@ describe('fake-claude-sdk-sidecar fork-at-point arm (kata 1wxv e2e fixture)', () 'sdk.session.init ORIG', ) fx.send({ type: 'send', sessionId: created.sessionId, text: 'turn one' }) - await fx.waitLine((o) => o.type === 'sdk.turn.complete', 'turn one completion') + await fx.waitLine((o) => o.type === 'sdk.result', 'turn one completion') fx.send({ type: 'send', sessionId: created.sessionId, text: 'turn two' }) await fx.waitLine( - (o) => o.type === 'sdk.turn.complete' && fx.stdoutLines().filter((r) => r.type === 'sdk.turn.complete').length >= 2, + (o) => o.type === 'sdk.result' && fx.stdoutLines().filter((r) => r.type === 'sdk.result').length >= 2, 'turn two completion', ) return { sessionId: created.sessionId as string, cliSessionId: init.cliSessionId as string } diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts index 4e7b89d98..f2936bc35 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts @@ -84,7 +84,7 @@ describe('harness-06 fake kilroy runtime', () => { expect((ledger[0].msg as { cwd?: string }).cwd).toBe('/tmp/fixture-cwd') }) - it('runs a full send turn: running -> assistant -> result(success) -> turn.complete, with no idle after it', async () => { + it('runs a full send turn: running -> assistant -> result(success, with its facts), with no edge and no idle after it', async () => { const { rt, logPath } = await make() rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } @@ -100,17 +100,13 @@ describe('harness-06 fake kilroy runtime', () => { expect(assistant.content[0].type).toBe('text') expect(assistant.content[0].text).toContain('hello kilroy') - const result = (await rt.nextEvent('sdk.result')) as { result: string } - expect(result.result).toBe('success') + const result = (await rt.nextEvent('sdk.result')) as Record + // The facts the server's verdict reads (it mints the edge, C18). + expect(result).toMatchObject({ result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) - const complete = (await rt.nextEvent('sdk.turn.complete')) as { at: number } - expect(typeof complete.at).toBe('number') - expect(complete.at).toBeGreaterThan(0) - - // A second turn's `at` must exceed the first (monotonic completion clock). rt.send({ type: 'send', sessionId: created.sessionId, text: 'again' }) - const complete2 = (await rt.nextEvent('sdk.turn.complete')) as { at: number } - expect(complete2.at).toBeGreaterThan(complete.at) + await rt.nextEvent('sdk.result') + expect(rt.events().filter((e) => e.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) // The result ends the turn, as with the real sidecar (which sends no idle): // nothing between the first turn's result and the second turn is an idle. const secondRunning = rt.events().findIndex( @@ -123,7 +119,7 @@ describe('harness-06 fake kilroy runtime', () => { expect(ledger.map((row) => (row.msg as { type: string }).type)).toEqual(['create', 'send', 'send']) }) - it('approval knob surfaces sdk.turn.waiting before completing (0->=1 pending edge)', async () => { + it('approval knob surfaces an approval request before completing (the server mints the waiting edge)', async () => { const { rt } = await make({ FAKE_KILROY_APPROVAL: '1', FAKE_KILROY_APPROVAL_DELAY_MS: '150' }) rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } @@ -131,42 +127,34 @@ describe('harness-06 fake kilroy runtime', () => { rt.send({ type: 'send', sessionId: created.sessionId, text: 'needs approval' }) - const waiting = (await rt.nextEvent('sdk.turn.waiting')) as { at: number; sessionId: string } - expect(waiting.sessionId).toBe(created.sessionId) - expect(typeof waiting.at).toBe('number') + const request = (await rt.nextEvent('sdk.permission.request')) as { requestId: string; sessionId: string } + expect(request.sessionId).toBe(created.sessionId) // Consume through the completion, THEN assert ordering in the full stream. await rt.nextEvent('sdk.assistant') - await rt.nextEvent('sdk.turn.complete') // auto-allowed after the delay + await rt.nextEvent('sdk.result') // allowed by the fake after the delay const types = rt.events().map((e) => (e as { type: string }).type) - expect(types.indexOf('sdk.turn.waiting')).toBeLessThan(types.indexOf('sdk.assistant')) - expect(types.indexOf('sdk.assistant')).toBeLessThan(types.indexOf('sdk.turn.complete')) + expect(types.indexOf('sdk.permission.request')).toBeLessThan(types.indexOf('sdk.permission.cancelled')) + expect(types.indexOf('sdk.permission.cancelled')).toBeLessThan(types.indexOf('sdk.assistant')) + expect(types.indexOf('sdk.assistant')).toBeLessThan(types.indexOf('sdk.result')) + expect(types.filter((type) => type === 'sdk.turn.waiting' || type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) }) - it('failure knob yields result(error) AND the unified turn.complete edge (needs-attention)', async () => { + it('failure knob yields a failed result with its error text, and no edge (the server rings it)', async () => { const { rt } = await make({ FAKE_KILROY_FAIL_RESULT: '1' }) rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') rt.send({ type: 'send', sessionId: created.sessionId, text: 'fail me' }) - const result = (await rt.nextEvent('sdk.result')) as { result: string } - expect(result.result).not.toBe('success') - const complete = (await rt.nextEvent('sdk.turn.complete')) as { at: number } - expect(Number.isFinite(complete.at)).toBe(true) - expect((complete as { sessionId?: string }).sessionId).toBe(created.sessionId) - // Stream order mirrors the real sidecar: result -> edge, no idle. An error - // turn end rings the SAME unified attention edge a success would — the - // old success-only guard is gone (the fake now imports the REAL gate). - const types = rt.events().map((e) => (e as { type: string }).type) - expect(types.indexOf('sdk.turn.complete')).toBeGreaterThan(types.indexOf('sdk.result')) - expect( - rt.events().filter((e) => (e as { type: string }).type === 'sdk.turn.complete'), - 'exactly one edge for the errored turn', - ).toHaveLength(1) + const result = (await rt.nextEvent('sdk.result')) as Record + expect(result).toMatchObject({ subtype: 'error_during_execution', isError: true, errorText: 'Fixture turn failed', sessionId: created.sessionId }) + expect(result).not.toHaveProperty('terminalReason') + await readEverythingSoFar(rt, created.sessionId) + expect(rt.events().filter((e) => e.type === 'sdk.turn.complete' || e.type === 'sdk.error'), 'no edge, no banner').toEqual([]) }) - it('interrupt on a HELD in-flight turn settles ok:true; the armed gate consumes the synthesized interrupted result — never an sdk.exit', async () => { + it('interrupt on a HELD in-flight turn settles ok:true with its stopId, then the aborted result — never an edge or an sdk.exit', async () => { const { rt } = await make({ FAKE_KILROY_HOLD_TURN: '1' }) rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } @@ -175,20 +163,17 @@ describe('harness-06 fake kilroy runtime', () => { rt.send({ type: 'send', sessionId: created.sessionId, text: 'hold' }) await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running') - rt.send({ type: 'interrupt', sessionId: created.sessionId }) - const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean } - expect(settle.ok).toBe(true) - const result = (await rt.nextEvent('sdk.result')) as { result: string } - expect(result.result).toBe('error_during_execution') + rt.send({ type: 'interrupt', sessionId: created.sessionId, stopId: 'stop-1' }) + const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean; stopId: string } + expect(settle).toMatchObject({ ok: true, stopId: 'stop-1' }) + const result = (await rt.nextEvent('sdk.result')) as Record + expect(result).toMatchObject({ result: 'error_during_execution', terminalReason: 'aborted_streaming' }) const settled = await readEverythingSoFar(rt, created.sessionId) // The stopped turn's result ends it: no idle follows (the real sidecar sends none). expect(idleBetween(rt, rt.events().indexOf(result), settled), 'no idle after the stopped result').toBe(false) - // TOTAL silence for the user-initiated interrupt: no attention edge on - // the wire — the armed gate consumed the interrupted turn's own result — - // and NEVER an sdk.exit (interrupt ends the TURN, not the session; the - // old fake's aborted-stream sdk.exit + session-drop was the stale - // pre-unification mirror). + // No attention edge on the wire (the server silences the user's own + // Stop) and NEVER an sdk.exit (interrupt ends the TURN, not the session). expect( rt.events().some((e) => (e as { type: string }).type === 'sdk.turn.complete'), 'a user-initiated interrupt rings no attention edge', @@ -199,40 +184,30 @@ describe('harness-06 fake kilroy runtime', () => { ).toBe(false) }) - it('interrupt during a scripted approval defers to the turn: forced non-success result, gate-consumed edge, and the NEXT turn rings', async () => { + it('interrupt during a scripted approval defers to the turn: its aborted result, no edge, and the NEXT turn runs', async () => { const { rt } = await make({ FAKE_KILROY_APPROVAL: '1', FAKE_KILROY_APPROVAL_DELAY_MS: '1000' }) rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') rt.send({ type: 'send', sessionId: created.sessionId, text: 'interrupt me mid-approval' }) - await rt.nextEvent('sdk.turn.waiting') + await rt.nextEvent('sdk.permission.request') // The turn is provably in flight (inside the approval await) — interrupt. rt.send({ type: 'interrupt', sessionId: created.sessionId }) const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean } expect(settle.ok).toBe(true) - // The in-flight scripted completion lands with the forced non-success - // subtype; its ring is consumed by the armed gate. - const result = (await rt.nextEvent('sdk.result')) as { result: string } - expect(result.result).toBe('error_during_execution') - expect( - rt.events().some((e) => (e as { type: string }).type === 'sdk.turn.complete'), - 'the interrupted turn stays silent (the gate consumed its result)', - ).toBe(false) + // The in-flight scripted completion lands with the aborted result. + const result = (await rt.nextEvent('sdk.result')) as Record + expect(result).toMatchObject({ result: 'error_during_execution', terminalReason: 'aborted_streaming' }) - // No stray mark survived the consumed interrupt: the NEXT turn on the - // same live session rings again (the accepted-interrupt mark is - // single-use by construction). + // The NEXT turn on the same live session runs to success. rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) - const complete = (await rt.nextEvent('sdk.turn.complete')) as { at: number } - expect(Number.isFinite(complete.at)).toBe(true) - expect( - rt.events().filter((e) => (e as { type: string }).type === 'sdk.turn.complete'), - 'exactly one edge — the next turn rings; the interrupt never did', - ).toHaveLength(1) + const next = (await rt.nextEvent('sdk.result')) as Record + expect(next).toMatchObject({ subtype: 'success' }) + expect(rt.events().filter((e) => e.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) }) - it('an interrupt with NOTHING in flight settles ok:true and arms nothing — the next turn still rings (task-004 F-I1 parity)', async () => { + it('an interrupt with NOTHING in flight settles ok:true and leaves the session as it was: the next turn runs', async () => { const { rt } = await make() rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) const created = (await rt.nextEvent('created')) as { sessionId: string } @@ -244,8 +219,7 @@ describe('harness-06 fake kilroy runtime', () => { await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) - const complete = (await rt.nextEvent('sdk.turn.complete')) as { at: number } - expect(Number.isFinite(complete.at)).toBe(true) + expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) }) it('crash knob kills the process mid-turn with NO completion edge (the Rust death synthesis rings, not the fake)', async () => { diff --git a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts index 2788c16e5..6ab0808c0 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -703,15 +703,13 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { 'a deny must never produce a success completion', ).toBe(true) - // D1-F2 WIRE TRUTH, unified contract: the fixture mirrors the real - // sidecar's gate — the denied turn's ERRORED result rings the SAME - // sdk.turn.complete edge a success would. A deny is an error turn - // end; only a user-initiated interrupt stays silent. Race-free: poll - // the outbound ledger until the errored result AND the edge that - // follows it exist, THEN assert the edge's exact-once shape — fenced - // BEFORE the follow-up send below (a LATER, legitimately-completing - // turn on the same session). Like the real sidecar, the turn ends with - // its result alone: no per-turn idle follows it. + // D1-F2 WIRE TRUTH: the fixture mirrors the real sidecar — the denied + // turn ends with its ERRORED result alone (no per-turn idle), and the + // fixture mints no attention edge: the server rings the result itself + // (C18), the SAME edge a success would. A deny is an error turn end; + // only a user-initiated interrupt stays silent. Race-free: poll the + // outbound ledger until the errored result exists — fenced BEFORE the + // follow-up send below (a LATER, legitimately-completing turn). const deniedBridgeId = respond.sessionId as string const denyWires = () => readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') const denyResultIndex = (wires: any[]) => wires.findIndex( @@ -720,34 +718,16 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { ) await expect .poll( - () => { - const wires = denyWires() - const resultIdx = denyResultIndex(wires) - if (resultIdx === -1) return false - return wires - .slice(resultIdx) - .some((r) => r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId) - }, - { timeout: 15_000, message: 'the errored sdk.result + the unified edge after it for the denied turn' }, + () => denyResultIndex(denyWires()), + { timeout: 15_000, message: 'the errored sdk.result for the denied turn' }, ) - .toBe(true) + .toBeGreaterThanOrEqual(0) const wires = denyWires() const denyResultIdx = denyResultIndex(wires) - expect(denyResultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) - const denyEdgeIdx = wires.findIndex( - (r, i) => i > denyResultIdx && r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId, - ) - expect(denyEdgeIdx, 'the unified attention edge follows the errored result in stream order').toBeGreaterThan(denyResultIdx) - expect( - Number.isFinite(wires[denyEdgeIdx]?.frame?.at), - 'the edge carries a finite numeric monotonic at', - ).toBe(true) expect( - wires - .slice(denyResultIdx) - .filter((r) => r.frame?.type === 'sdk.turn.complete' && r.frame?.sessionId === deniedBridgeId), - 'exactly one unified edge for the denied turn', - ).toHaveLength(1) + wires.filter((r) => r.frame?.type === 'sdk.turn.complete' || r.frame?.type === 'sdk.turn.waiting'), + 'the fixture mints no attention edge: the server does', + ).toEqual([]) expect( wires .slice(denyResultIdx) @@ -760,7 +740,7 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { 'the denial assistant frame did arrive on the wire', ).toBe(true) - // BROWSER-FACING contract: the deny's edge crossed the Rust rename and + // BROWSER-FACING contract: the server's edge for the denied result // folded in the browser as EXACTLY ONE new turnCompletion event // (seq 1 → 2), recorded under the bare completion namespace (distinct // from the waiting edge's `#waiting` bucket). The user is WATCHING diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index 958c5a992..83e7d6126 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -23,11 +23,11 @@ // still folds the edge and sets the attention flag — the bell rings for // any tab when the window is unfocused (the sound suppression split is // unit-pinned; e2e asserts the fold partition); -// 5. a mid-turn end of the SDK stream (the finally-minted edge, then -// sdk.stream.ended and the sidecar's exit) rings exactly one edge, -// resolves the pane to exited, reports the exit (SIDECAR_EXITED), and the -// sidecar process is gone: a stream end is a session end (G02), and the -// server does not ring a second time for the reported end. +// 5. a mid-turn end of the SDK stream (sdk.stream.ended and the sidecar's +// exit) rings exactly one edge, resolves the pane to exited, reports the +// exit (SIDECAR_EXITED), and the sidecar process is gone: a stream end is +// a session end (G02). The server rings the cut-off turn once; the +// sidecar rings nothing (C18). // // The user-Stop lane runs on the PRODUCTION sidecar // (crates/freshell-claude-sidecar/index.mjs) over the CLI simulator @@ -90,9 +90,9 @@ const FAKE_PROGRAM = { ], }, { - // Mid-turn end-of-stream lane: the finally-minted unified edge, then - // sdk.stream.ended and exit code 3, like the real sidecar. The separate - // `crash` lane (silent process death) belongs to the Rust-synthesis lanes. + // Mid-turn end-of-stream lane: sdk.stream.ended and exit code 3, like + // the real sidecar; the server rings the cut-off turn. The separate + // `crash` lane (silent process death) belongs to the death lanes. on: 'msg:send', match: { text: 'STREAM_ERROR' }, emit: [{ kind: 'stream-error', delayMs: 3_000 }], @@ -440,9 +440,8 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) // ── Step 5: the SDK stream ends mid-turn — the sidecar exits ─────── // The fake's stream-error lane models the real sidecar's end of stream: - // the finally-minted unified edge, then sdk.stream.ended as its last - // line and exit code 3. The server reports the exit and, because the end - // was reported, does not ring again. + // sdk.stream.ended as its last line and exit code 3. The server reports + // the exit and rings the cut-off turn, once (C18). await composer.fill('STREAM_ERROR') await sendButton.click() await page.locator(`[data-context="tab"][data-tab-id="${tabB}"]`).click() @@ -487,18 +486,19 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) } }, { timeout: 30_000, message: 'the sidecar process exited after its stream ended' }) .toBe('gone') - // Each send opens with a running status: count the edges after the last one. + // The sidecar ended the turn with no result and minted no edge: the one + // edge for the lane is the server's. const lastSendOpen = streamWires .map((r) => r.frame?.type === 'sdk.status' && r.frame?.status === 'running') .lastIndexOf(true) expect(lastSendOpen, 'the ended turn opened with a running status').toBeGreaterThanOrEqual(0) expect( - streamWires.slice(lastSendOpen).filter((r) => r.frame?.type === 'sdk.turn.complete'), - 'exactly one unified edge for the ended turn (the session\'s earlier turns each rang their own edge)', - ).toHaveLength(1) + streamWires.slice(lastSendOpen).filter((r) => r.frame?.type === 'sdk.result' || r.frame?.type === 'sdk.turn.complete'), + 'the stream ended with no result, and the sidecar minted no edge', + ).toEqual([]) // One edge total for the lane — stable across a settle window (the - // reported end gets no second, server-minted edge). + // cut-off turn rings exactly once). await page.waitForTimeout(1_500) expect((await turnCompletionState()).seq).toBe(4) } finally { diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 532a068db..b68539ecc 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -24,12 +24,19 @@ * itself. What this spec does catch is a regression to the blocking reveal * path (the overlay recorder) and a broken end-to-end journey. * - * Trigger: the fake Claude-SDK sidecar is driven by the client's own - * `freshAgent.interrupt` frame for the hidden pane's session — the exact frame - * the Stream Deck "stop" sends to a pane in a background tab — so the spec - * needs no composer send and no turn lane. Its program raises one Bash - * approval on the first interrupt; the second interrupt cancels it (the - * sidecar's interrupt-time pending cancellation, mirroring the real one). + * Trigger: the hidden pane's session gets a `freshAgent.send` written straight + * to the socket (the pane's composer is hidden), and the fake Claude-SDK + * sidecar's program answers it with one Bash approval: the turn parks on it. + * Then the client's own `freshAgent.interrupt` frame for the session — the + * exact frame the Stream Deck "stop" sends to a pane in a background tab — + * cancels it (the sidecar's interrupt-time pending cancellation, mirroring + * the real one). The Stop reaches the sidecar because the parked turn is + * running (an idle session's Stop sends nothing, C18). + * + * Stop also ends the turn, whose result legitimately invalidates the + * transcript. Pause delivery immediately after the cancellation, preserving + * WebSocket order, to isolate the approval-only reveal. Then deliver the + * held result and assert that the normal turn end still reaches the client. * * CLOUD-LEGALITY: no timing dependencies — every wait is a poll on observed * state (sidecar stdin/wire ledgers, received WS frames, DOM). This spec must @@ -53,11 +60,15 @@ const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fa const APPROVAL_REQUEST_ID = 'perm-raised-while-hidden' -/** The first interrupt raises one Bash approval; a later one cancels it. */ +/** The message that raises the approval (its turn parks on it). */ +const RAISE_TEXT = 'raise an approval while hidden' + +/** The send raises one Bash approval; the Stop cancels it. */ const FAKE_PROGRAM = { rules: [ { - on: 'msg:interrupt', + on: 'msg:send', + match: { text: RAISE_TEXT }, once: true, emit: [ { @@ -162,7 +173,33 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const stdinLog = path.join(sharedRoot, 'sidecar-stdin.jsonl') const eventsLog = path.join(sharedRoot, 'sidecar-events.jsonl') let server: RustServer | undefined + let pauseAfterCancellation = false + let deliveryPaused = false + let heldResults = 0 + const heldDeliveries: Array<() => void> = [] + const releaseDeliveries = () => { + deliveryPaused = false + for (const deliver of heldDeliveries.splice(0)) deliver() + } try { + await page.routeWebSocket('**/ws', (route) => { + const upstream = route.connectToServer() + upstream.onMessage((message) => { + let frame: any + try { frame = JSON.parse(String(message)) } catch { /* Forward non-JSON messages unchanged. */ } + if (deliveryPaused) { + if (frame?.type === 'freshAgent.event' && frame.event?.type === 'freshAgent.result') heldResults += 1 + heldDeliveries.push(() => route.send(message)) + return + } + route.send(message) + if (pauseAfterCancellation && frame?.type === 'freshAgent.event' + && frame.event?.type === 'freshAgent.permission.cancelled') { + pauseAfterCancellation = false + deliveryPaused = true + } + }) + }) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ @@ -236,6 +273,16 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const tabALocator = page.locator(`[data-context="tab"][data-tab-id="${tabA}"]`) const tabBLocator = page.locator(`[data-context="tab"][data-tab-id="${tabB}"]`) + const sendToHiddenPane = () => page.evaluate(({ sessionId, text }) => { + window.__FRESHELL_TEST_HARNESS__?.sendWsMessage({ + type: 'freshAgent.send', + requestId: 'send-while-hidden', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + text, + }) + }, { sessionId: bridgeSessionId, text: RAISE_TEXT }) const interruptHiddenPane = () => page.evaluate((sessionId) => { // The Stream Deck stop's exact frame for a fresh-agent pane. window.__FRESHELL_TEST_HARNESS__?.sendWsMessage({ @@ -247,7 +294,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without }, bridgeSessionId) // ── 1. An approval RAISED while hidden appears on reveal ───────────── - await interruptHiddenPane() + await sendToHiddenPane() await expect .poll(() => wireFrames(eventsLog, 'sdk.permission.request').length, { timeout: 15_000, @@ -272,13 +319,14 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without // ── 2. The same approval CANCELLED while hidden is gone on reveal ──── await tabBLocator.click() await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) + pauseAfterCancellation = true await interruptHiddenPane() await expect .poll(() => stdinFrames(stdinLog, 'interrupt').length, { timeout: 15_000, - message: 'the server forwards the second interrupt', + message: 'the server forwards the Stop of the parked turn', }) - .toBe(2) + .toBe(1) await expect .poll(() => wireFrames(eventsLog, 'sdk.permission.cancelled').map((frame) => frame.requestId), { timeout: 15_000, @@ -291,6 +339,11 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without message: 'the client receives the cancellation while tab A is hidden', }) .toBe(1) + await expect.poll(() => heldResults, { + timeout: 15_000, + message: 'the Stop result is held after the cancellation, in wire order', + }).toBe(1) + expect(await receivedFreshAgentEvents(harness, 'freshAgent.result')).toHaveLength(0) expect(await harness.getActiveTabId(), 'the cancellation arrived while tab A was hidden').toBe(tabB) await armRevealOverlayRecorder(page) @@ -299,7 +352,13 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without await expect(paneA, 'the reveal has rendered the pane').toBeVisible() await expect(cardInAnyTab, 'the revealed pane no longer shows the cancelled card').toHaveCount(0, { timeout: 15_000 }) expect(await revealOverlaySeen(page), 'the reveal never blocks behind "Refreshing conversation"').toBe(false) + releaseDeliveries() + await expect.poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.result')).length, { + timeout: 15_000, + message: 'the stopped turn result is delivered after the approval-only reveal', + }).toBe(1) } finally { + try { releaseDeliveries() } catch { /* The page can already be closed after a failed test. */ } await server?.stop().catch(() => {}) await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) } diff --git a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts index 03771d1ea..6f04fc705 100644 --- a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts @@ -72,8 +72,8 @@ const FAKE_PROGRAM = { ], }, { - // The approval parks the turn (no completion follows) and the sidecar - // emits sdk.turn.waiting on the 0→1 pending transition. + // The approval parks the turn (no completion follows) and the server + // mints the waiting edge on the 0→1 pending transition. on: 'msg:send', match: { text: BACKGROUND_APPROVAL }, emit: [ @@ -232,11 +232,14 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { endedState.watchedCompletionByTab?.[tabA], 'the turn ended while the user was in tab B (a watched mark means the tab move was late)', ).toBeUndefined() - // The mark came from the server's edge for a real turn, not a stand-in. - expect(wireFrames(eventsLog, 'sdk.turn.complete'), 'the sidecar ended the turn with the attention edge').toHaveLength(1) + // The mark came from the server's edge for a real turn's result, not a + // stand-in: the sidecar ended the turn with its result, and the server + // rang it (C18: the sidecar mints no edge). + expect(wireFrames(eventsLog, 'sdk.result').map((frame) => frame.subtype), 'the sidecar ended the turn with its result').toEqual(['success']) + expect(wireFrames(eventsLog, 'sdk.turn.complete'), 'the sidecar mints no edge').toHaveLength(0) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.complete'), - 'the server forwarded exactly one turn-complete edge to the client', + 'the server sent exactly one turn-complete edge to the client', ).toHaveLength(1) await paneStatus().toBe('idle') @@ -287,13 +290,14 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { wireFrames(eventsLog, 'sdk.permission.request').map((frame) => frame.requestId), 'the sidecar raised the scripted approval', ).toEqual([APPROVAL_REQUEST_ID]) - expect(wireFrames(eventsLog, 'sdk.turn.waiting'), 'the sidecar emitted the waiting edge').toHaveLength(1) + expect(wireFrames(eventsLog, 'sdk.turn.waiting'), 'the sidecar mints no waiting edge').toHaveLength(0) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.waiting'), - 'the server forwarded exactly one waiting edge to the client', + 'the server sent exactly one waiting edge to the client', ).toHaveLength(1) - // The approval parks the turn: no second turn-complete edge. - expect(wireFrames(eventsLog, 'sdk.turn.complete')).toHaveLength(1) + // The approval parks the turn: no second result, no second turn-complete edge. + expect(wireFrames(eventsLog, 'sdk.result')).toHaveLength(1) + expect(await receivedFreshAgentEvents(harness, 'freshAgent.turn.complete')).toHaveLength(1) await expect(otherTabsStatus, 'the waiting tab is counted like a finished one').toBeVisible() await expect(previousTab).toHaveAccessibleDescription('An earlier tab needs attention') diff --git a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts index cb4ef7639..61d3ee3c1 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -551,7 +551,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { const durable = init.event.cliSessionId as string expect(durable).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) - // One turn (fixture completes it with sdk.turn.complete). + // One turn (the fixture ends it with its result; the server rings it). ws.send({ type: 'freshAgent.send', provider: 'claude', diff --git a/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts b/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts index 611d603a3..7584a21e0 100644 --- a/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts @@ -244,8 +244,6 @@ for (const provider of ['kilroy', 'freshclaude'] as const) { fixture.proc.stdin?.write( `${JSON.stringify({ type: 'send', sessionId, text: 'please approve' })}\n`, ) - const waiting = await readSidecarLine(fixture, (o) => o.type === 'sdk.turn.waiting', 'sdk.turn.waiting') - expect(typeof waiting.at).toBe('number') const perm = await readSidecarLine(fixture, (o) => o.type === 'sdk.permission.request', 'sdk.permission.request') expect(perm).toMatchObject({ sessionId, @@ -256,14 +254,13 @@ for (const provider of ['kilroy', 'freshclaude'] as const) { const question = await readSidecarLine(fixture, (o) => o.type === 'sdk.question.request', 'sdk.question.request') expect(question.requestId).toBe('q-1') expect(question.questions[0]).toMatchObject({ question: 'which file should I edit?', multiSelect: false }) - const complete = await readSidecarLine(fixture, (o) => o.type === 'sdk.turn.complete', 'sdk.turn.complete') - // D1-F2: sdk.turn.complete now mirrors the REAL protocol shape - // {sessionId, at} exactly (the fixture `subtype` extension is gone) — - // per-turn success/error truth lives on the always-emitted sdk.result. - expect(complete.subtype).toBeUndefined() - expect(typeof complete.at).toBe('number') + // The turn's result carries the facts the server's verdict reads; like + // the real sidecar (C18) the fixture mints no attention edge: the server + // rings the result and the waiting edge itself. const result = await readSidecarLine(fixture, (o) => o.type === 'sdk.result', 'sdk.result') - expect(result.result).toBe('success') + expect(result).toMatchObject({ result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) + expect(fixture.stdout).not.toContain('"sdk.turn.complete"') + expect(fixture.stdout).not.toContain('"sdk.turn.waiting"') expect((await readSidecarLine(fixture, (o) => o.type === 'sdk.status' && o.status === 'idle', 'idle')).sessionId).toBe(sessionId) // The ledger also carries `kind:'wire'` outbound-frame audit rows @@ -302,7 +299,7 @@ for (const provider of ['kilroy', 'freshclaude'] as const) { // interrupt + shutdown are part of the real protocol surface. const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - fixture.proc.stdin?.write(JSON.stringify({ type: 'interrupt', sessionId: created.sessionId }) + '\n') + fixture.proc.stdin?.write(JSON.stringify({ type: 'interrupt', sessionId: created.sessionId, stopId: 'stop-1' }) + '\n') fixture.proc.stdin?.write(JSON.stringify({ type: 'shutdown' }) + '\n') expect(await fixture.exited()).toBe(0) }) diff --git a/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts b/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts index 6fcdde7d5..3d35893ea 100644 --- a/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts @@ -299,8 +299,9 @@ test.describe('harness-06 misc fixtures smoke', () => { await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running') const assistant = (await rt.nextEvent('sdk.assistant')) as { content: Array<{ text?: string }> } expect(assistant.content[0].text).toContain('kilroy smoke turn') + // The result ends the turn; the server mints its edge (C18), the fake none. await rt.nextEvent('sdk.result', (e) => (e as { result?: string }).result === 'success') - await rt.nextEvent('sdk.turn.complete') + expect(rt.events().some((e) => (e as { type?: string }).type === 'sdk.turn.complete')).toBe(false) // "Records Kilroy invocations": the JSONL ledger carries both requests. const ledger = await readKilroyLedger(logPath) diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index a003ec92d..2eca574a9 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -39,6 +39,18 @@ // FRESHELL_CLAUDE_SIM_COLD_STOP_RECEIPT 'empty' (default) or 'queued': whether a // Stop sent while the CLI boots overtakes the // pending send (both shapes are real, T13) +// FRESHELL_CLAUDE_SIM_COLD_COMMAND 'empty', 'queued' or 'started': the first +// uuid-stamped command after the boot starts cold. +// A Stop that arrives while it is still starting +// misses it (T13; the real CLI's order of receipt +// and lifecycle is racy, this makes one shape +// deterministic): 'empty' overtakes it (receipt +// [] before its queued), 'queued' finds it queued +// (receipt [uuid]), 'started' is answered as it +// starts (receipt [] after its queued and +// started). The command then runs to its result +// unless another interrupt arrives once it runs. +// With no Stop it starts after T.coldStart. // FRESHELL_CLAUDE_SIM_STALL_STDIN '1'/'boot' or 'prompt': the host process // stops reading stdin after boot / at the // first prompt @@ -56,7 +68,9 @@ // command as it starts: uuid, text, content and // the model, effort and permission mode it runs // under), `can_use_tool` (each answer the host's -// canUseTool gave), `control` (each settings call +// canUseTool gave), `interrupt` (each interrupt +// the CLI took: the receipt's stillQueued and +// whether a turn was running), `control` (each settings call // the CLI handles: setModel, setPermissionMode, // applyFlagSettings, with its arguments) and // `emission` (program steps) @@ -205,6 +219,9 @@ function deferred() { return { promise, resolve, reject } } +/** The shapes FRESHELL_CLAUDE_SIM_COLD_COMMAND names (see the knobs above). */ +const COLD_COMMAND_SHAPES = new Set(['empty', 'queued', 'started']) + /** One command or CLI-initiated turn being worked on. */ class Turn { constructor(cmd) { @@ -242,6 +259,14 @@ class SimulatedCli { this.permissionMode = options.permissionMode || 'default' this.partial = options.includePartialMessages === true this.stateEvents = this.env.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS === '1' + this.coldShape = this.env.FRESHELL_CLAUDE_SIM_COLD_COMMAND || null + if (this.coldShape && !COLD_COMMAND_SHAPES.has(this.coldShape)) { + throw new Error(`FRESHELL_CLAUDE_SIM_COLD_COMMAND must be one of ${[...COLD_COMMAND_SHAPES].join(', ')}, not ${JSON.stringify(this.coldShape)}`) + } + // The cold command being started, while a Stop can still miss it, and the + // receipt of a Stop that missed it, when it is answered at its start. + this.cold = null + this.coldReceipt = null this.queue = [] this.cliTurns = [] this.preBoot = [] @@ -543,6 +568,12 @@ class SimulatedCli { this.deferredPushes.push(cmd) return } + if (this.coldShape && cmd.uuid) { + const shape = this.coldShape + this.coldShape = null + this.background(this.acceptCold(cmd, shape)) + return + } if (!this.busy) { this.busy = true this.emitState('running') @@ -556,6 +587,34 @@ class SimulatedCli { if (start && !this.turn) this.startNextOrIdle() } + /** + * Take the cold command (FRESHELL_CLAUDE_SIM_COLD_COMMAND): it is still + * starting at the point `shape` names until a Stop misses it (or the cold + * window ends), and only then goes on. + */ + async acceptCold(cmd, shape) { + const cold = { shape, uuid: cmd.uuid, missed: false, stopped: deferred() } + this.cold = cold + if (shape === 'queued') { + this.accept(cmd, { start: false }) + await this.holdCold(cold) + this.startNextOrIdle() + } else { + // 'empty': the Stop overtook the command; 'started': the CLI answers the + // Stop only once it reported the command's start (runCommand). + await this.holdCold(cold) + this.accept(cmd) + } + } + + /** Wait for a Stop to miss the cold command, or for the cold window to end. */ + async holdCold(cold) { + await Promise.race([cold.stopped.promise, this.sleep(T.coldStart)]) + if (this.cold === cold) this.cold = null + // The receipt reaches the host before anything the command does next. + await this.tick() + } + endWhenIdle() { if (!this.busy && !this.turn && !this.dead) { this.transcript?.close() @@ -591,6 +650,13 @@ class SimulatedCli { this.turn = turn await this.sleep(T.queuedToStarted) if (cmd.uuid) this.emit({ type: 'command_lifecycle', command_uuid: cmd.uuid, state: 'started' }) + if (this.coldReceipt && this.coldReceipt.uuid === cmd.uuid) { + // The receipt of the Stop that missed this command comes after its start. + const { resolve } = this.coldReceipt + this.coldReceipt = null + await this.tick() + resolve() + } this.recordPrompt(cmd) this.transcript.materialize() if (/^prompt$/.test(this.env.FRESHELL_CLAUDE_SIM_STALL_STDIN ?? '')) process.stdin.pause() @@ -974,7 +1040,20 @@ class SimulatedCli { if (this.dead) return Promise.reject(new Error('Query closed')) const receipt = deferred() const run = () => { + const cold = this.cold + if (cold && !cold.missed) { + // The command is still starting: this Stop misses it (T13). + cold.missed = true + const stillQueued = cold.shape === 'queued' ? [cold.uuid] : [] + this.ledger('interrupt', { stillQueued, running: false, missed: cold.uuid }) + const answer = () => receipt.resolve({ still_queued: stillQueued }) + if (cold.shape === 'started') this.coldReceipt = { uuid: cold.uuid, resolve: answer } + else answer() + cold.stopped.resolve() + return + } const stillQueued = [...this.queue, ...(this.turn?.absorbed ?? []).filter((cmd) => !cmd.started)].filter((cmd) => cmd.uuid).map((cmd) => cmd.uuid) + this.ledger('interrupt', { stillQueued, running: Boolean(this.turn) }) receipt.resolve({ still_queued: stillQueued }) this.turn?.abort() } diff --git a/test/fixtures/claude-cli-sim/sdk-shapes.mjs b/test/fixtures/claude-cli-sim/sdk-shapes.mjs index 3067a39d5..06f58318d 100644 --- a/test/fixtures/claude-cli-sim/sdk-shapes.mjs +++ b/test/fixtures/claude-cli-sim/sdk-shapes.mjs @@ -29,6 +29,9 @@ export const T = { heartbeat: 30_000, subagent: 2000, agentLaunch: 50, + // How long a cold command (FRESHELL_CLAUDE_SIM_COLD_COMMAND) waits for a + // Stop to miss it before it goes on. + coldStart: 1500, } export const TOOL_REJECTED = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed." diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 7e46af9f2..3740a1328 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -8,7 +8,7 @@ // claude-cli-conformance.ts. import { spawn, type ChildProcess } from 'node:child_process' -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' @@ -253,6 +253,67 @@ describe('Claude CLI simulator programs', () => { await s.until(() => s.messages.some((m) => m.type === 'command_lifecycle' && m.state === 'cancelled')) }) + // T13 after the boot: a command the CLI is still starting is missed by a + // Stop, in each receipt shape the real CLI shows (the order of the receipt + // and the command's lifecycle is racy there; the knob makes each shape + // deterministic). The command then runs to success, unless an interrupt + // sent once it runs aborts it. + it.each([ + ['empty', [], [], []], + ['queued', ['55555555-5555-4555-8555-555555555555'], ['queued'], ['queued']], + ['started', [], [], ['queued', 'started']], + ] as const)('FRESHELL_CLAUDE_SIM_COLD_COMMAND=%s: a Stop while the first command starts misses it with that receipt', async (shape, stillQueued, beforeStop, beforeReceipt) => { + const uuid = '55555555-5555-4555-8555-555555555555' + const s = await open({}, { FRESHELL_CLAUDE_SIM_COLD_COMMAND: shape, FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) + await s.q.initializationResult() + const lifecycle = () => s.messages.filter((m) => m.type === 'command_lifecycle' && m.command_uuid === uuid).map((m) => m.state) + s.prompt.push('SLOW cold', uuid) + // The command is still starting, at the point each shape names. + if (beforeStop.length) await s.until(() => lifecycle().length === beforeStop.length) + else await new Promise((resolve) => setTimeout(resolve, 20)) + expect(lifecycle(), 'the command lifecycle the CLI reported before the Stop').toEqual(beforeStop) + const receipt = await s.q.interrupt() + expect(lifecycle(), 'the command lifecycle the CLI reported before its receipt').toEqual(beforeReceipt) + expect(receipt).toEqual({ still_queued: stillQueued }) + await s.until(() => results(s.messages).length === 1) + expect(results(s.messages)[0]).toMatchObject({ subtype: 'success', terminal_reason: 'completed' }) + await s.until(() => lifecycle().at(-1) === 'completed') + expect(lifecycle()).toEqual(['queued', 'started', 'completed']) + + // Only the first command starts cold: a Stop once the next one runs aborts it. + s.prompt.push('SLOW warm', '66666666-6666-4666-8666-666666666666') + await s.until(() => s.messages.some((m) => m.type === 'stream_event' || (m.type === 'user' && m.isReplay && m.uuid === '66666666-6666-4666-8666-666666666666'))) + await s.q.interrupt() + await s.until(() => results(s.messages).length === 2) + expect(results(s.messages)[1]).toMatchObject({ subtype: 'error_during_execution', terminal_reason: 'aborted_streaming' }) + }) + + it('FRESHELL_CLAUDE_SIM_COLD_COMMAND: an interrupt sent once the cold command runs aborts it', async () => { + const uuid = '77777777-7777-4777-8777-777777777777' + const s = await open({}, { FRESHELL_CLAUDE_SIM_COLD_COMMAND: 'empty', FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) + await s.q.initializationResult() + s.prompt.push('SLOW cold', uuid) + await new Promise((resolve) => setTimeout(resolve, 20)) + await expect(s.q.interrupt()).resolves.toEqual({ still_queued: [] }) + await s.until(() => s.messages.some((m) => m.type === 'command_lifecycle' && m.command_uuid === uuid && m.state === 'started')) + await s.q.interrupt() + await s.until(() => results(s.messages).length === 1) + expect(results(s.messages)[0]).toMatchObject({ subtype: 'error_during_execution', terminal_reason: 'aborted_streaming' }) + }) + + it('records every interrupt the CLI receives in the ledger, with the receipt it answered', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-sim-ledger-')) + cleanups.push(() => fs.rm(dir, { recursive: true, force: true })) + const ledger = path.join(dir, 'ledger.jsonl') + const s = await open({}, { FRESHELL_CLAUDE_SIM_LEDGER: ledger, FRESHELL_CLAUDE_SIM_TIME_SCALE: '0.1' }) + await s.q.initializationResult() + await s.q.interrupt() + const rows = (await fs.readFile(ledger, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(rows.filter((row) => row.kind === 'interrupt')).toEqual([ + expect.objectContaining({ kind: 'interrupt', stillQueued: [], running: false }), + ]) + }) + it('a /compact whose summary request hangs (HANG in its instructions) stays compacting with no result', async () => { // The marker reaches the compaction's summary request the way APIERR does // on the real CLI (T15 scenario `compact`), so the local provider's HANG @@ -671,8 +732,8 @@ describe('the real sidecar over the simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE) send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'r1', cwd: home.cwd, permissionMode: 'bypassPermissions' }) expect(await until(() => frames.some((f) => f.type === 'created'))).toBe(true) const sessionId = frames.find((f) => f.type === 'created').sessionId - send({ type: 'send', sessionId, text: 'hello' }) - expect(await until(() => frames.some((f) => f.type === 'sdk.turn.complete'))).toBe(true) + send({ type: 'send', sessionId, uuid: randomUUID(), text: 'hello' }) + expect(await until(() => frames.some((f) => f.type === 'sdk.result' && f.subtype === 'success'))).toBe(true) expect(frames.find((f) => f.type === 'sdk.session.init')).toMatchObject({ sessionId, cliSessionId: expect.stringMatching(/^[0-9a-f-]{36}$/) }) expect(frames.find((f) => f.type === 'sdk.result')).toMatchObject({ sessionId, result: 'success' }) expect(frames.find((f) => f.type === 'sdk.assistant')?.content).toEqual([{ type: 'text', text: 'ok' }]) diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index 5eec02dac..dd0d425c2 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -305,7 +305,9 @@ export async function startLocalProvider({ replyText = 'ok', respond } = {}) { } const messages = Array.isArray(body.messages) ? body.messages : [] - const last = messages[messages.length - 1] + // The last USER message: the CLI can append a system-role message after it + // (its agent-type listing), which carries no prompt. + const last = [...messages].reverse().find((m) => m?.role === 'user') const firstUserText = textOfContent(messages.find((m) => m?.role === 'user')?.content) const { text: promptText, afterTool } = promptTextAfterTools(last) const tools = Array.isArray(body.tools) ? body.tools.map((tool) => tool?.name).filter(Boolean) : [] diff --git a/test/integration/providers/claude-sidecar-shutdown.mjs b/test/integration/providers/claude-sidecar-shutdown.mjs index 12e0fd0de..58f9e0f89 100644 --- a/test/integration/providers/claude-sidecar-shutdown.mjs +++ b/test/integration/providers/claude-sidecar-shutdown.mjs @@ -132,7 +132,7 @@ try { if (created.type !== 'created') throw new Error('the sidecar refused the create') failureStage = 'mid-turn' - write({ type: 'send', sessionId: created.sessionId, text: 'hello' }) + write({ type: 'send', sessionId: created.sessionId, uuid: randomUUID(), text: 'hello' }) await withTimeout(streaming, 45_000, 'the provider to receive the turn') await waitFor(frame => frame.type === 'sdk.stream' && frame.event?.type === 'content_block_delta', 30_000, 'the streamed delta') cliPid = Number(execFileSync('pgrep', ['-P', String(sidecar.pid)]).toString().trim().split('\n')[0]) diff --git a/test/unit/architecture/fresh-agent-only-runtime.test.ts b/test/unit/architecture/fresh-agent-only-runtime.test.ts index f6e2fd6b4..efe0a1e35 100644 --- a/test/unit/architecture/fresh-agent-only-runtime.test.ts +++ b/test/unit/architecture/fresh-agent-only-runtime.test.ts @@ -262,23 +262,6 @@ function findLegacyRuntimeReferences(file: string, contents: string): Finding[] return [...directFindings, ...multilineFindings] } -function findSdkPublicReferences(file: string, contents: string): Finding[] { - return contents - .split(/\r?\n/) - .flatMap((line, index): Finding[] => { - if (!line.includes('sdk.')) { - return [] - } - - return [{ - file, - line: index + 1, - snippet: line.trim(), - reason: 'sdk.* protocol references belong only in server-internal SDK bridge/adapters', - }] - }) -} - function formatFindings(findings: Finding[]): string { return findings .map((finding) => { @@ -449,13 +432,4 @@ describe('fresh-agent-only runtime architecture', () => { expect(clientSources).not.toContain(joinProtocolName('codingcli', 'create')) }) - it('keeps SDK bridge protocol details out of public/browser-facing runtime source', async () => { - const findings = (await Promise.all((await collectRuntimeSourceFiles()).map(async (filePath) => { - const file = toRepoRelativePath(filePath) - const contents = await readFile(filePath, 'utf8') - return findSdkPublicReferences(file, contents) - }))).flat() - - expect(findings, formatFindings(findings)).toEqual([]) - }) }) diff --git a/test/unit/claude-sidecar/harness.ts b/test/unit/claude-sidecar/harness.ts index 935d206fd..e1413578d 100644 --- a/test/unit/claude-sidecar/harness.ts +++ b/test/unit/claude-sidecar/harness.ts @@ -14,6 +14,7 @@ // and its directory removed when the calling test finishes. import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -70,6 +71,17 @@ export interface StartRealSidecarOptions { type Predicate = (frame: SidecarFrame) => boolean +/** + * The server mints a uuid for every `send` it writes (protocol v2: the uuid + * the CLI keeps for the command and its user line). The harness plays the + * server, so a `send` written without a `uuid` key gets a fresh one; a test + * that means to leave it out passes the key itself (for example `uuid: null`). + */ +function withSendUuid(message: object): object { + const request = message as Record + return request.type === 'send' && !Object.hasOwn(request, 'uuid') ? { ...request, uuid: randomUUID() } : message +} + export interface RealSidecar { child: ChildProcess /** The isolated workspace: the default `cwd` of `create`. */ @@ -79,7 +91,7 @@ export interface RealSidecar { /** Every stdout frame so far, in arrival order. */ frames: SidecarFrame[] stderr(): string - /** Write one request line. */ + /** Write one request line (a `send` without a `uuid` gets one, as the server mints it). */ send(message: object): void /** * Write several request lines in ONE write. They reach the sidecar in one @@ -164,7 +176,7 @@ export async function startRealSidecar(options: StartRealSidecarOptions = {}): P let requests = 0 const stderr = () => stderrText const write = (messages: object[]) => { - child.stdin!.write(messages.map((message) => `${JSON.stringify(message)}\n`).join('')) + child.stdin!.write(messages.map((message) => `${JSON.stringify(withSendUuid(message))}\n`).join('')) } const waitFor = (match: string | Predicate, label?: string, timeoutMs = DEFAULT_TIMEOUT_MS) => { diff --git a/test/unit/claude-sidecar/interrupt-idle-gate.test.ts b/test/unit/claude-sidecar/interrupt-idle-gate.test.ts index ae2394eb6..3dc75c860 100644 --- a/test/unit/claude-sidecar/interrupt-idle-gate.test.ts +++ b/test/unit/claude-sidecar/interrupt-idle-gate.test.ts @@ -1,18 +1,30 @@ // @vitest-environment node -// Real-sidecar behavioral pin for the unified attention gate's ARM condition -// (task-004 review F-I1), driven through the production index.mjs process -// over the CLI simulator (harness.ts): the interrupt mark is armed ONLY while -// a turn is plausibly awaiting a terminal frame (pendingResults > 0). The SDK -// contract documents RESOLUTION — not rejection — for interrupting with -// nothing in flight (sdk.d.ts:2384-2394), so an idle-session interrupt (a -// late Stop click racing the busy-clear, or a double-click second Stop) must -// arm NOTHING: the settle lands ok:true with no result to consume, and the -// NEXT unrelated turn ring survives. An interrupt while a turn IS pending -// still suppresses exactly the interrupted turn own result. +// The sidecar mints no attention edge (core-runtime design §5.1, §5.5; C18): +// the server owns turn accounting and every `freshAgent.turn.complete` / +// `freshAgent.turn.waiting`, from the facts this sidecar forwards. Driven +// through the production index.mjs process over the CLI simulator +// (harness.ts): whatever a turn does - finish, fail, stop, wait on an +// approval or a question, end with the CLI's death - the sidecar writes no +// `sdk.turn.complete`, no `sdk.turn.waiting`, no `sdk.exit` and no failure +// banner (`sdk.error{turnFailure}`). What it does write is the facts: the +// enriched result, and the interrupt's receipt echoing the Stop's id. +// +// The interrupt gate this file used to pin (arm only while a result is owed, +// silence only the stopped turn's aborted result, a stale mark never +// swallows a bell) is the server's Stop latch now: +// crates/freshell-freshagent/src/claude_turns_stop_tests.rs and +// crates/freshell-ws/tests/freshagent_claude_turns.rs. import { describe, expect, it } from 'vitest' -import { startRealSidecar, type RealSidecar } from './harness' +import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness' + +/** Frames only the server may originate now. */ +const EDGE_TYPES = new Set(['sdk.turn.complete', 'sdk.turn.waiting', 'sdk.exit']) + +function edgesAndBanners(bridge: RealSidecar): SidecarFrame[] { + return bridge.frames.filter((frame) => EDGE_TYPES.has(frame.type) || (frame.type === 'sdk.error' && frame.turnFailure !== undefined)) +} /** A booted session with nothing in flight (the CLI reports no init until a prompt, T1). */ async function createSession(bridge: RealSidecar) { @@ -21,132 +33,100 @@ async function createSession(bridge: RealSidecar) { return sessionId } -describe('Claude sidecar interrupt gate arming (unified attention edge)', () => { - it('an interrupt on an idle session settles ok:true and the next unrelated turn ring survives', async () => { - const bridge = await startRealSidecar() - const sessionId = await createSession(bridge) - - // Idle-session interrupt: nothing awaits a terminal frame. The CLI - // RESOLVES interrupt() (the documented nothing-in-flight contract), so - // the settle lands ok:true — and NO mark may be armed. Pre-fix, the - // unconditional arm here left a stray mark that consumed exactly the - // next result ring, silently suppressing an attention edge the user - // never witnessed. - bridge.send({ type: 'interrupt', sessionId }) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - - // The NEXT unrelated turn result must still ring. - bridge.send({ type: 'send', sessionId, text: 'next unrelated turn' }) - expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, result: 'success' }) - expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) - }) +const isToolUse = (frame: SidecarFrame) => frame.type === 'sdk.assistant' && frame.content?.some((block: { type: string }) => block.type === 'tool_use') - it('an interrupt while a turn is pending suppresses exactly that turn result; the next turn rings again', async () => { - const bridge = await startRealSidecar() +describe('the Claude sidecar mints no attention edge', () => { + it('a finished turn, a failed one and an API error each end with their result and nothing else', async () => { + const bridge = await startRealSidecar({ + program: { rules: [{ on: 'msg:prompt', match: { text: 'max turns' }, emit: [{ kind: 'completion', data: { subtype: 'error_max_turns' } }] }] }, + timeScale: 0.1, + }) const sessionId = await createSession(bridge) - - // A turn whose model request never completes (HANG) until the interrupt - // aborts it: the interrupted turn's own terminal frame lands AFTER the - // settle receipt (the SDK ordering the gate depends on, sdk.d.ts:3765; - // T11). The interrupt goes out once the turn has started (its init, T1), - // so it is a warm Stop, not a cold one racing the CLI's boot (T13). - bridge.send({ type: 'send', sessionId, text: 'HANG' }) - await bridge.waitFor('sdk.session.init') - bridge.send({ type: 'interrupt', sessionId }) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, result: 'error_during_execution' }) - - // The next turn rings again — and by then exactly ONE edge has landed: - // the interrupted turn own ring was consumed by the armed mark. - bridge.send({ type: 'send', sessionId, text: 'after interrupt' }) - expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) - expect(bridge.frames.filter((frame) => frame.type === 'sdk.turn.complete')).toHaveLength(1) + for (const [n, text] of ['hello', 'max turns', 'APIERR:500'].entries()) { + bridge.send({ type: 'send', sessionId, text }) + await bridge.waitFor(() => bridge.frames.filter((frame) => frame.type === 'sdk.result').length === n + 1, `result ${n + 1}`) + } + await bridge.waitFor((frame) => frame.type === 'sdk.session.state' && frame.state === 'idle', 'the CLI goes idle') + const results = bridge.frames.filter((frame) => frame.type === 'sdk.result') + expect(results.map((result) => [result.subtype, result.isError, result.terminalReason])).toEqual([ + ['success', false, 'completed'], + ['error_max_turns', true, undefined], + ['success', true, 'api_error'], + ]) + expect(edgesAndBanners(bridge)).toEqual([]) }) - // G03: the CLI ends a stopped turn with error_during_execution and an - // `[ede_diagnostic]` error (T11). That is the user's own Stop, not a - // failure, so no error frame (the client's "Agent error" banner) follows. it.each([ - { stage: 'streamed text', prompt: 'HANG', started: (frame: Record) => frame.type === 'sdk.session.init' }, - { - stage: 'a tool call', - prompt: 'TOOL:sleep=30', - started: (frame: Record) => frame.type === 'sdk.assistant' && frame.content?.some((block: { type: string }) => block.type === 'tool_use'), - }, - ])('a Stop during $stage ends the turn silently and with no error', async ({ prompt, started }) => { + { stage: 'streamed text', prompt: 'HANG', started: (frame: SidecarFrame) => frame.type === 'sdk.session.init' }, + { stage: 'a tool call', prompt: 'TOOL:sleep=30', started: isToolUse }, + ])('a Stop during $stage: the receipt echoes its stopId, the aborted result carries its facts, and no edge or banner follows', async ({ prompt, started }) => { const bridge = await startRealSidecar() const sessionId = await createSession(bridge) bridge.send({ type: 'send', sessionId, text: prompt }) await bridge.waitFor(started, `${prompt} under way`) - bridge.send({ type: 'interrupt', sessionId }) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, result: 'error_during_execution' }) + bridge.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) + expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-1', ok: true, stillQueued: [] }) + const result = await bridge.waitFor('sdk.result') + expect(result).toMatchObject({ sessionId, subtype: 'error_during_execution', isError: true }) + expect(result.terminalReason).toMatch(/^aborted_/) + expect(result.errorText, 'diagnostics never reach the server').toBeUndefined() - // Everything the stopped turn produces has arrived once the next turn has - // rung: it is still exactly one edge, and no error frame at all. bridge.send({ type: 'send', sessionId, text: 'after the stop' }) - await bridge.waitFor((frame) => frame.type === 'sdk.result' && frame.result === 'success', 'the next turn result') - await bridge.waitFor('sdk.turn.complete') - expect(bridge.frames.filter((frame) => frame.type === 'sdk.turn.complete')).toHaveLength(1) - expect(bridge.frames.filter((frame) => frame.type === 'sdk.error')).toEqual([]) + await bridge.waitFor((frame) => frame.type === 'sdk.result' && frame.subtype === 'success', 'the next turn result') + expect(edgesAndBanners(bridge)).toEqual([]) }) - // G06: a Stop can miss the turn it was meant to stop. When the Stop reaches - // the CLI before the send it follows (both arrive in one stdin chunk, and - // the CLI takes the Stop first), the receipt comes back ok with nothing - // stopped and the turn then runs to success/completed. Only an aborted - // result is the stopped turn's own, so this one rings: the user believes - // the turn stopped, and it did not. (Since the sidecar answers `created` - // only once the CLI booted, a Stop can no longer race the boot itself, T13; - // and a boot the CLI refuses never becomes a session: boot-handshake.test.ts.) - it('a Stop that overtakes the send it meant to stop: the turn completes and rings', async () => { - const bridge = await startRealSidecar() - const sessionId = await bridge.create({ requestId: 'create', model: 'opus' }) - expect(bridge.ledger().filter((row) => row.kind === 'boot')).toHaveLength(1) - bridge.sendLines([{ type: 'send', sessionId, text: 'hello' }, { type: 'interrupt', sessionId }]) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, result: 'success' }) - expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) - }) - - it('a stale Stop mark never swallows the next turn\'s bell', async () => { + it('an interrupt with nothing running settles ok:true with its stopId (the CLI resolves it) and nothing else', async () => { const bridge = await startRealSidecar() const sessionId = await createSession(bridge) + bridge.send({ type: 'interrupt', sessionId, stopId: 'stop-idle' }) + expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-idle', ok: true }) + bridge.send({ type: 'send', sessionId, text: 'next turn' }) + expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, subtype: 'success' }) + expect(edgesAndBanners(bridge)).toEqual([]) + }) - // A send pushed during a tool call is absorbed into that turn (T10): two - // sends, one result. The sidecar still counts the absorbed send as owed. - bridge.send({ type: 'send', sessionId, text: 'TOOL:sleep=1' }) - await bridge.waitFor((frame) => frame.type === 'sdk.assistant' && frame.content?.some((block: { type: string }) => block.type === 'tool_use'), 'the tool call') - bridge.send({ type: 'send', sessionId, text: 'absorbed into the tool turn' }) - await bridge.waitForPrompt(2) - expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, result: 'success' }) - await bridge.waitFor('sdk.turn.complete') - - // A Stop now finds the CLI idle, but the owed absorbed send arms a mark, - // and nothing the Stop could abort follows it. - bridge.send({ type: 'interrupt', sessionId }) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - - // The next turn completes normally: it rings. - bridge.send({ type: 'send', sessionId, text: 'the next turn' }) - await bridge.waitFor(() => bridge.frames.filter((frame) => frame.type === 'sdk.result').length === 2, 'the next turn result') - await bridge.waitFor(() => bridge.frames.filter((frame) => frame.type === 'sdk.turn.complete').length === 2, 'the next turn rings') - expect(bridge.frames.filter((frame) => frame.type === 'sdk.result').map((frame) => frame.result)).toEqual(['success', 'success']) + it('an approval and a question are forwarded as requests with no waiting edge', async () => { + const bridge = await startRealSidecar({ + program: { + rules: [ + { on: 'msg:prompt', match: { text: 'ask approval' }, emit: [{ kind: 'approval', data: { tool: 'Bash', input: { command: 'ls' } } }] }, + { on: 'msg:prompt', match: { text: 'ask question' }, emit: [{ kind: 'question', data: { questions: [{ question: 'Pick', header: 'Q', options: [{ label: 'A', description: 'a' }], multiSelect: false }] } }] }, + ], + }, + }) + const sessionId = await bridge.create({ requestId: 'create', model: 'opus', permissionMode: 'default' }) + await bridge.waitForBoot() + bridge.send({ type: 'send', sessionId, text: 'ask approval' }) + const approval = await bridge.waitFor('sdk.permission.request') + bridge.send({ type: 'permission.respond', sessionId, requestId: approval.requestId, decision: { behavior: 'allow', updatedInput: { command: 'ls' } } }) + await bridge.waitFor('sdk.result') + bridge.send({ type: 'send', sessionId, text: 'ask question' }) + const question = await bridge.waitFor('sdk.question.request') + bridge.send({ type: 'question.respond', sessionId, requestId: question.requestId, answers: { Pick: 'A' } }) + await bridge.waitFor(() => bridge.frames.filter((frame) => frame.type === 'sdk.result').length === 2, 'the second result') + expect(edgesAndBanners(bridge)).toEqual([]) }) - it('a Stop that missed the turn does not silence a turn that then ends with no result', async () => { + it('a turn the CLI dies under ends with the stream end alone', async () => { const bridge = await startRealSidecar({ program: { rules: [{ on: 'msg:prompt', match: { text: 'CRASH_SOON' }, emit: [{ kind: 'crash', delayMs: 300 }] }] }, }) - const sessionId = await bridge.create({ requestId: 'create', model: 'opus' }) - // The Stop overtakes the send, so the turn runs; then the CLI dies - // mid-turn and the stream ends with no result (T14). - bridge.sendLines([{ type: 'send', sessionId, text: 'CRASH_SOON' }, { type: 'interrupt', sessionId }]) - expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId, ok: true }) - await bridge.waitForPrompt(1) + const sessionId = await createSession(bridge) + bridge.send({ type: 'send', sessionId, text: 'CRASH_SOON' }) expect(await bridge.waitFor('sdk.stream.ended')).toMatchObject({ sessionId, reason: 'cli_exited' }) - expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) expect(bridge.frames.filter((frame) => frame.type === 'sdk.result')).toEqual([]) + expect(edgesAndBanners(bridge)).toEqual([]) + }) + + it('a request for a session this sidecar does not host is refused: a send is rejected, an interrupt settles ok:false', async () => { + const bridge = await startRealSidecar() + await createSession(bridge) + bridge.send({ type: 'send', sessionId: 'not-hosted', uuid: '88888888-8888-4888-8888-888888888888', text: 'hello' }) + expect(await bridge.waitFor('sdk.send.rejected')).toMatchObject({ sessionId: 'not-hosted', uuid: '88888888-8888-4888-8888-888888888888', code: 'SESSION_NOT_FOUND' }) + bridge.send({ type: 'interrupt', sessionId: 'not-hosted', stopId: 'stop-x' }) + expect(await bridge.waitFor('sdk.interrupt_settled')).toMatchObject({ sessionId: 'not-hosted', stopId: 'stop-x', ok: false }) + expect(bridge.frames.filter((frame) => frame.type === 'sdk.error')).toEqual([]) }) }) diff --git a/test/unit/claude-sidecar/lifecycle-facts.test.ts b/test/unit/claude-sidecar/lifecycle-facts.test.ts new file mode 100644 index 000000000..f4ccf3dc7 --- /dev/null +++ b/test/unit/claude-sidecar/lifecycle-facts.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment node +// +// uuid-stamped sends and the lifecycle facts the sidecar forwards (core-runtime +// design §5.1, C16), through the real index.mjs over the CLI simulator +// (harness.ts). Every send carries the uuid the server minted; the CLI keeps it +// for the command and the user line, and reports the command's lifecycle under +// it (T8). The sidecar forwards those facts, the session state (T9), status +// ends (T15), heartbeats (T17), interrupt receipts (T12) and each result's +// verdict facts, so the server owns turn accounting: from C18 the server mints +// every attention edge, and the sidecar none. + +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness' + +/** A booted session with nothing in flight. */ +async function session(options: Parameters[0] = {}) { + const bridge = await startRealSidecar(options) + const sessionId = await bridge.create({ requestId: 'create', model: 'opus' }) + await bridge.waitForBoot() + return { bridge, sessionId } +} + +function commandStates(bridge: RealSidecar, uuid: string): string[] { + return bridge.frames.filter((frame) => frame.type === 'sdk.command' && frame.uuid === uuid).map((frame) => frame.state) +} + +const isResult = (frame: SidecarFrame) => frame.type === 'sdk.result' + +describe('uuid-stamped sends', () => { + it('the CLI runs each send under the uuid the server gave it', async () => { + const { bridge, sessionId } = await session() + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text: 'hello' }) + expect(await bridge.waitForPrompt(1)).toMatchObject({ uuid, text: 'hello' }) + }) + + it('forwards the sent command\'s lifecycle in the CLI\'s order, with its first replay', async () => { + const { bridge, sessionId } = await session() + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text: 'hello' }) + await bridge.waitFor((frame) => frame.type === 'sdk.command' && frame.uuid === uuid && frame.state === 'completed', 'the completed lifecycle') + expect(commandStates(bridge, uuid)).toEqual(['queued', 'started', 'replayed', 'completed']) + expect(bridge.frames.find((frame) => frame.type === 'sdk.command')).toMatchObject({ sessionId }) + // The result comes before the command's own end (T8). + const result = bridge.frames.findIndex(isResult) + const completed = bridge.frames.findIndex((frame) => frame.type === 'sdk.command' && frame.state === 'completed') + expect(result).toBeGreaterThan(-1) + expect(result).toBeLessThan(completed) + // The server rings the turn from these facts (C18): the sidecar sends no edge. + expect(bridge.frames.filter((frame) => frame.type === 'sdk.turn.complete')).toEqual([]) + }) + + // C17: the server's turn ledger reads msg_lifecycle_v1 from the per-turn + // init, so a result that started none of the server's commands (a turn the + // CLI runs by itself) is never taken for one of them. + it('forwards the CLI\'s capabilities on the per-turn init', async () => { + const { bridge, sessionId } = await session() + bridge.send({ type: 'send', sessionId, uuid: randomUUID(), text: 'hello' }) + const init = await bridge.waitFor('sdk.session.init') + expect(init.capabilities).toEqual(expect.arrayContaining(['msg_lifecycle_v1'])) + expect(init.capabilities.every((cap: unknown) => typeof cap === 'string')).toBe(true) + }) + + it('forwards no lifecycle for commands the CLI makes itself', async () => { + const { bridge, sessionId } = await session() + // setModel runs /model inside the CLI and replays it under a uuid of its own (T8). + bridge.send({ type: 'configure', sessionId, requestId: 'model', settings: { model: 'sonnet' } }) + expect(await bridge.waitFor('sdk.configured')).toMatchObject({ ok: true }) + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text: 'hello' }) + await bridge.waitFor((frame) => frame.type === 'sdk.command' && frame.state === 'completed', 'the completed lifecycle') + expect(new Set(bridge.frames.filter((frame) => frame.type === 'sdk.command').map((frame) => frame.uuid))).toEqual(new Set([uuid])) + }) + + it('a checkpointed send binds its turn identity under the send\'s own uuid', async () => { + const { bridge, sessionId } = await session() + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text: 'hello', checkpointAttempt: { logicalAttemptId: 'attempt-1', requestId: 'send-1' } }) + expect(await bridge.waitFor('sdk.checkpointTurnIdentity')).toMatchObject({ + sessionId, logicalAttemptId: 'attempt-1', requestId: 'send-1', nativeTurnId: uuid, displayTurnId: uuid, + }) + }) +}) + +describe('session facts', () => { + it('reports the session running and then idle around a turn (the sidecar turns the CLI\'s state events on)', async () => { + const { bridge, sessionId } = await session() + const query = bridge.ledger().find((row) => row.kind === 'query') + expect(query?.options.envKeys).toContain('CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS') + bridge.send({ type: 'send', sessionId, text: 'hello' }) + await bridge.waitFor((frame) => frame.type === 'sdk.session.state' && frame.state === 'idle', 'the idle state') + const states = bridge.frames.filter((frame) => frame.type === 'sdk.session.state') + expect(states.map((frame) => frame.state)).toEqual(['running', 'idle']) + expect(states[0]).toMatchObject({ sessionId }) + // The idle arrives once the turn's result is in. + expect(bridge.frames.findIndex(isResult)).toBeLessThan(bridge.frames.indexOf(states[1])) + }) + + it('reports each model request, and the end of a compaction with its outcome', async () => { + const { bridge, sessionId } = await session() + bridge.send({ type: 'send', sessionId, text: 'hello' }) + expect(await bridge.waitFor((frame) => frame.type === 'sdk.status' && frame.status === 'requesting', 'a request')).toEqual({ type: 'sdk.status', sessionId, status: 'requesting' }) + await bridge.waitFor(isResult, 'the first result') + bridge.send({ type: 'send', sessionId, text: '/compact' }) + await bridge.waitFor((frame) => frame.type === 'sdk.status' && frame.status === 'compacting', 'compacting') + expect(await bridge.waitFor((frame) => frame.type === 'sdk.status' && frame.status === null, 'the compaction\'s end')) + .toEqual({ type: 'sdk.status', sessionId, status: null, compactResult: 'success' }) + }) + + it('turns tool and task progress into heartbeats, at most one of each kind per five seconds', async () => { + // A 100 s Bash call at 1/1000 speed: the CLI promotes it to a background + // task after 3 s and heartbeats at 30, 60 and 90 s, all within 0.1 s of + // real time. + const { bridge, sessionId } = await session({ timeScale: 0.001 }) + bridge.send({ type: 'send', sessionId, text: 'TOOL:sleep=100' }) + await bridge.waitFor(isResult, 'the result') + const toolUse = bridge.frames + .filter((frame) => frame.type === 'sdk.assistant') + .flatMap((frame) => frame.content) + .find((block: { type: string }) => block.type === 'tool_use') + const beats = bridge.frames.filter((frame) => frame.type === 'sdk.heartbeat') + expect(beats.filter((beat) => beat.kind === 'tool')).toEqual([{ type: 'sdk.heartbeat', sessionId, kind: 'tool', toolUseId: toolUse.id, elapsedMs: 30_000 }]) + expect(beats.filter((beat) => beat.kind === 'task')).toEqual([{ type: 'sdk.heartbeat', sessionId, kind: 'task', toolUseId: toolUse.id }]) + }) + + it('a Stop\'s receipt lists the sent command that will still run (T12)', async () => { + const { bridge, sessionId } = await session() + const running = randomUUID() + const queued = randomUUID() + bridge.send({ type: 'send', sessionId, uuid: running, text: 'HANG' }) + await bridge.waitFor('sdk.session.init') + // A send during streamed text queues as its own turn (T10). + bridge.send({ type: 'send', sessionId, uuid: queued, text: 'next' }) + await bridge.waitFor((frame) => frame.type === 'sdk.command' && frame.uuid === queued && frame.state === 'queued', 'the queued send') + bridge.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) + expect(await bridge.waitFor('sdk.interrupt_settled')).toEqual({ type: 'sdk.interrupt_settled', sessionId, stopId: 'stop-1', ok: true, stillQueued: [queued] }) + // The stopped command ends cancelled; the survivor runs as its own turn. + await bridge.waitFor((frame) => frame.type === 'sdk.command' && frame.uuid === queued && frame.state === 'completed', 'the survivor completed') + expect(commandStates(bridge, running)).toEqual(['queued', 'started', 'replayed', 'cancelled']) + }) + + it('each result carries its verdict facts', async () => { + const { bridge, sessionId } = await session() + bridge.send({ type: 'send', sessionId, text: 'hello' }) + expect(await bridge.waitFor(isResult, 'the result')).toMatchObject({ + sessionId, result: 'success', subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1, + }) + bridge.send({ type: 'send', sessionId, text: 'APIERR:500' }) + expect(await bridge.waitFor((frame) => isResult(frame) && frame.isError === true, 'the API error result')) + .toMatchObject({ subtype: 'success', isError: true, terminalReason: 'api_error' }) + }) +}) + +describe('rejected sends', () => { + it('a bad image is rejected and the sidecar keeps serving the session', async () => { + const { bridge, sessionId } = await session() + const rejected = randomUUID() + bridge.send({ type: 'send', sessionId, uuid: rejected, text: 'look', images: [{ mediaType: 'image/svg+xml', data: 'PHN2Zy8+' }] }) + const rejection = await bridge.waitFor('sdk.send.rejected') + expect(rejection).toMatchObject({ sessionId, uuid: rejected, code: 'INVALID_IMAGE' }) + expect(rejection.message).toMatch(/image\/svg\+xml/) + expect(bridge.stderr()).toContain('"event":"send.rejected"') + + const next = randomUUID() + bridge.send({ type: 'send', sessionId, uuid: next, text: 'hello' }) + expect(await bridge.waitFor(isResult, 'the next result')).toMatchObject({ sessionId, subtype: 'success' }) + // The rejected send never reached the CLI. + expect(bridge.ledger().filter((row) => row.kind === 'prompt').map((row) => row.uuid)).toEqual([next]) + expect(commandStates(bridge, rejected)).toEqual([]) + expect(bridge.child.exitCode).toBeNull() + }) + + it('a send without a valid uuid is rejected', async () => { + const { bridge, sessionId } = await session() + bridge.send({ type: 'send', sessionId, uuid: null, text: 'hello' }) + expect(await bridge.waitFor('sdk.send.rejected')).toMatchObject({ sessionId, uuid: null, code: 'INVALID_SEND' }) + bridge.send({ type: 'send', sessionId, uuid: 'not-a-uuid', text: 'hello' }) + await bridge.waitFor(() => bridge.frames.filter((frame) => frame.type === 'sdk.send.rejected').length === 2, 'the second rejection') + expect(bridge.ledger().filter((row) => row.kind === 'prompt')).toEqual([]) + }) +}) diff --git a/test/unit/claude-sidecar/lifecycle-frames.test.ts b/test/unit/claude-sidecar/lifecycle-frames.test.ts new file mode 100644 index 000000000..2725e2f56 --- /dev/null +++ b/test/unit/claude-sidecar/lifecycle-frames.test.ts @@ -0,0 +1,249 @@ +// @vitest-environment node + +// The pure mapping from the CLI's lifecycle messages to the sidecar's fact +// frames (crates/freshell-claude-sidecar/lifecycle-frames.mjs; core-runtime +// design §5.1, C16). The message shapes are the pinned CLI's: T8 (per-command +// lifecycle and replays), T9 (session state), T13 (interrupt receipts), T15 +// (status null with a compaction's outcome), T17 (tool heartbeats). The +// sidecar forwards facts only for the commands it sent: the CLI also replays +// and queues uuids it minted itself, which the server never knew. + +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + createHeartbeatLimiter, + createLifecycleState, + HEARTBEAT_INTERVAL_MS, + lifecycleFrames, + rememberSend, + stillQueuedOf, +} from '../../../crates/freshell-claude-sidecar/lifecycle-frames.mjs' +import { startRealSidecar } from './harness' + +const SENT = '3f0b9d2e-1111-4222-8333-444455556666' +const OTHER = '9a8b7c6d-5555-4666-8777-888899990000' + +function stateWithSent(...uuids: string[]) { + const state = createLifecycleState() + for (const uuid of uuids) rememberSend(state, uuid) + return state +} + +describe('command lifecycle facts', () => { + it('forwards every lifecycle state of a command the sidecar sent', () => { + for (const lifecycle of ['queued', 'started', 'completed', 'cancelled', 'discarded']) { + const state = stateWithSent(SENT) + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: SENT, state: lifecycle }, state, 0)) + .toEqual([{ type: 'sdk.command', sessionId: 's1', uuid: SENT, state: lifecycle }]) + } + }) + + it('forwards nothing for a command the CLI queued itself, or a state it does not know', () => { + const state = stateWithSent(SENT) + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: OTHER, state: 'started' }, state, 0)).toEqual([]) + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: SENT, state: 'paused' }, state, 0)).toEqual([]) + }) + + it('reports the first replay of a sent command, once', () => { + const state = stateWithSent(SENT) + const replay = { type: 'user', isReplay: true, uuid: SENT, message: { role: 'user', content: 'hello' }, parent_tool_use_id: null } + expect(lifecycleFrames('s1', replay, state, 0)).toEqual([{ type: 'sdk.command', sessionId: 's1', uuid: SENT, state: 'replayed' }]) + expect(lifecycleFrames('s1', replay, state, 0)).toEqual([]) + }) + + it('ignores replays the CLI minted itself and user rows that are not replays', () => { + const state = stateWithSent(SENT) + // setModel's /model output is replayed under a uuid the CLI minted (T8). + expect(lifecycleFrames('s1', { type: 'user', isReplay: true, uuid: OTHER, message: { role: 'user', content: '' } }, state, 0)).toEqual([]) + // A tool result is an ordinary user row. + expect(lifecycleFrames('s1', { type: 'user', uuid: SENT, message: { role: 'user', content: [] } }, state, 0)).toEqual([]) + }) + + it('keeps every outstanding command through terminal lifecycle and interrupt receipts', () => { + const state = createLifecycleState() + const uuid = (i: number) => `${String(i).padStart(8, '0')}-0000-4000-8000-000000000000` + const uuids = Array.from({ length: 300 }, (_, i) => uuid(i)) + + const queued = uuids.flatMap((commandId) => { + rememberSend(state, commandId) + return lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: commandId, state: 'queued' }, state, 0) + }) + expect(queued).toEqual(uuids.map((commandId) => ({ + type: 'sdk.command', sessionId: 's1', uuid: commandId, state: 'queued', + }))) + expect(stillQueuedOf({ still_queued: uuids }, state)).toEqual(uuids) + + const retired = [ + ['completed', uuids[0]], + ['cancelled', uuids[1]], + ['discarded', uuids[2]], + ] as const + for (const [terminalState, commandId] of retired) { + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: commandId, state: terminalState }, state, 0)) + .toEqual([{ type: 'sdk.command', sessionId: 's1', uuid: commandId, state: terminalState }]) + } + + expect(stillQueuedOf({ still_queued: uuids }, state)).toEqual(uuids.slice(retired.length)) + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: uuids[0], state: 'started' }, state, 0)).toEqual([]) + expect(lifecycleFrames('s1', { type: 'command_lifecycle', command_uuid: uuids.at(-1), state: 'started' }, state, 0)) + .toEqual([{ type: 'sdk.command', sessionId: 's1', uuid: uuids.at(-1), state: 'started' }]) + }) +}) + +describe('real sidecar lifecycle queue', () => { + it('forwards and drains more than 256 queued commands through an interrupt receipt', async () => { + const bridge = await startRealSidecar({ timeScale: 0 }) + const sessionId = await bridge.create({ requestId: 'create-lifecycle-capacity' }) + await bridge.waitForBoot() + + const runningId = randomUUID() + const queuedIds = Array.from({ length: 300 }, () => randomUUID()) + const queuedIdSet = new Set(queuedIds) + const queuedSeen = new Set() + bridge.send({ type: 'send', sessionId, uuid: runningId, text: 'HANG' }) + await bridge.waitFor( + (frame) => frame.type === 'sdk.command' && frame.uuid === runningId && frame.state === 'started', + 'the first command to start', + ) + + bridge.sendLines(queuedIds.map((uuid) => ({ type: 'send', sessionId, uuid, text: 'queued command' }))) + await bridge.waitFor( + (frame) => { + if (frame.type === 'sdk.command' && frame.state === 'queued' && queuedIdSet.has(frame.uuid)) queuedSeen.add(frame.uuid) + return queuedSeen.size === queuedIdSet.size + }, + 'all queued command lifecycle frames', + 60_000, + ) + + bridge.send({ type: 'interrupt', sessionId, stopId: 'capacity-stop' }) + const receipt = await bridge.waitFor( + (frame) => frame.type === 'sdk.interrupt_settled' && frame.stopId === 'capacity-stop', + 'the interrupt receipt', + 60_000, + ) + expect(receipt).toMatchObject({ ok: true, stillQueued: queuedIds }) + + const terminalStates = new Set(['completed', 'cancelled', 'discarded']) + const terminalSeen = new Set() + await bridge.waitFor( + (frame) => { + if (frame.type === 'sdk.command' && queuedIdSet.has(frame.uuid) && terminalStates.has(frame.state)) terminalSeen.add(frame.uuid) + return terminalSeen.size === queuedIdSet.size + }, + 'all queued commands to reach terminal lifecycle', + 60_000, + ) + const lastTerminalIndex = bridge.frames.reduce((last, frame, index) => + frame.type === 'sdk.command' && queuedIdSet.has(frame.uuid) && terminalStates.has(frame.state) ? index : last, + -1) + await bridge.waitFor( + (frame) => frame.type === 'sdk.session.state' && frame.state === 'idle' + && bridge.frames.indexOf(frame) > lastTerminalIndex, + 'the session to return to idle after draining the queue', + 60_000, + ) + + const forwardedTerminalIds = new Set( + bridge.frames + .filter((frame) => frame.type === 'sdk.command' && queuedIdSet.has(frame.uuid) && terminalStates.has(frame.state)) + .map((frame) => frame.uuid), + ) + expect(forwardedTerminalIds).toEqual(new Set(queuedIds)) + }, 90_000) +}) + +describe('session state and status facts', () => { + it('forwards each session state the CLI reports (T9)', () => { + const state = createLifecycleState() + for (const sessionState of ['running', 'requires_action', 'idle']) { + expect(lifecycleFrames('s1', { type: 'system', subtype: 'session_state_changed', state: sessionState }, state, 0)) + .toEqual([{ type: 'sdk.session.state', sessionId: 's1', state: sessionState }]) + } + expect(lifecycleFrames('s1', { type: 'system', subtype: 'session_state_changed', state: 'sleeping' }, state, 0)).toEqual([]) + }) + + it('forwards the end of a status with what it carries: a compaction outcome or a permission mode (T15)', () => { + const state = createLifecycleState() + expect(lifecycleFrames('s1', { type: 'system', subtype: 'status', status: null, compact_result: 'success' }, state, 0)) + .toEqual([{ type: 'sdk.status', sessionId: 's1', status: null, compactResult: 'success' }]) + expect(lifecycleFrames('s1', { type: 'system', subtype: 'status', status: null, compact_result: 'failed', compact_error: 'Not enough messages to compact.' }, state, 0)) + .toEqual([{ type: 'sdk.status', sessionId: 's1', status: null, compactResult: 'failed', compactError: 'Not enough messages to compact.' }]) + expect(lifecycleFrames('s1', { type: 'system', subtype: 'status', status: null, permissionMode: 'plan' }, state, 0)) + .toEqual([{ type: 'sdk.status', sessionId: 's1', status: null, permissionMode: 'plan' }]) + }) + + it('forwards a model request as turn evidence, and leaves compacting to the content mapping', () => { + const state = createLifecycleState() + expect(lifecycleFrames('s1', { type: 'system', subtype: 'status', status: 'requesting' }, state, 0)) + .toEqual([{ type: 'sdk.status', sessionId: 's1', status: 'requesting' }]) + expect(lifecycleFrames('s1', { type: 'system', subtype: 'status', status: 'compacting' }, state, 0)).toEqual([]) + }) +}) + +describe('heartbeats', () => { + it('turns a tool heartbeat into a liveness fact for the tool it belongs to (T17)', () => { + const state = createLifecycleState() + // The CLI stamps the heartbeat with its own id and names the tool as its parent. + const beat = { type: 'tool_progress', tool_use_id: 'toolu_1-heartbeat-0', tool_name: 'Bash', parent_tool_use_id: 'toolu_1', elapsed_time_seconds: 30, heartbeat: true } + expect(lifecycleFrames('s1', beat, state, 1_000)) + .toEqual([{ type: 'sdk.heartbeat', sessionId: 's1', kind: 'tool', toolUseId: 'toolu_1', elapsedMs: 30_000 }]) + }) + + it('treats task and API-retry progress as heartbeats of their own kinds', () => { + const state = createLifecycleState() + expect(lifecycleFrames('s1', { type: 'system', subtype: 'task_started', task_id: 't1', tool_use_id: 'toolu_2', description: 'sleep' }, state, 0)) + .toEqual([{ type: 'sdk.heartbeat', sessionId: 's1', kind: 'task', toolUseId: 'toolu_2' }]) + expect(lifecycleFrames('s1', { type: 'system', subtype: 'api_retry', attempt: 1, max_retries: 10, retry_delay_ms: 500, error_status: 529 }, state, 0)) + .toEqual([{ type: 'sdk.heartbeat', sessionId: 's1', kind: 'retry' }]) + }) + + it('sends at most one heartbeat per kind every five seconds', () => { + const state = createLifecycleState() + const tool = (elapsed: number) => ({ type: 'tool_progress', tool_use_id: `toolu_1-heartbeat-${elapsed}`, tool_name: 'Bash', parent_tool_use_id: 'toolu_1', elapsed_time_seconds: elapsed, heartbeat: true }) + const task = { type: 'system', subtype: 'task_progress', task_id: 't1', description: 'd', usage: { total_tokens: 1, tool_uses: 0, duration_ms: 1 } } + expect(lifecycleFrames('s1', tool(30), state, 10_000)).toHaveLength(1) + expect(lifecycleFrames('s1', tool(60), state, 10_000 + HEARTBEAT_INTERVAL_MS - 1)).toEqual([]) + // Another kind has its own allowance. + expect(lifecycleFrames('s1', task, state, 10_001)).toHaveLength(1) + expect(lifecycleFrames('s1', tool(90), state, 10_000 + HEARTBEAT_INTERVAL_MS)).toHaveLength(1) + }) + + it('the limiter admits the first beat of a kind and the next one a full interval later', () => { + const limiter = createHeartbeatLimiter(100) + expect(limiter.admit('tool', 5)).toBe(true) + expect(limiter.admit('tool', 104)).toBe(false) + expect(limiter.admit('retry', 104)).toBe(true) + expect(limiter.admit('tool', 105)).toBe(true) + }) +}) + +describe('interrupt receipts', () => { + it('lists only the sent commands the receipt says will still run (T12, T13)', () => { + const state = stateWithSent(SENT) + // The CLI may list uuids it enqueued itself; the server never knew them. + expect(stillQueuedOf({ still_queued: [OTHER, SENT] }, state)).toEqual([SENT]) + expect(stillQueuedOf({ still_queued: [] }, state)).toEqual([]) + }) + + it('has no list when the CLI sent no receipt (older CLIs resolve undefined)', () => { + const state = stateWithSent(SENT) + expect(stillQueuedOf(undefined, state)).toBeUndefined() + expect(stillQueuedOf({}, state)).toBeUndefined() + }) +}) + +describe('other messages', () => { + it('produce no lifecycle facts', () => { + const state = stateWithSent(SENT) + for (const msg of [ + { type: 'assistant', uuid: SENT, message: { content: [] } }, + { type: 'result', subtype: 'success' }, + { type: 'stream_event', event: { type: 'message_start' } }, + { type: 'system', subtype: 'init', session_id: 'x' }, + { type: 'system', subtype: 'compact_boundary' }, + ]) { + expect(lifecycleFrames('s1', msg, state, 0)).toEqual([]) + } + }) +}) diff --git a/test/unit/claude-sidecar/live-frames.test.ts b/test/unit/claude-sidecar/live-frames.test.ts index 22c53736f..3bc258e77 100644 --- a/test/unit/claude-sidecar/live-frames.test.ts +++ b/test/unit/claude-sidecar/live-frames.test.ts @@ -43,7 +43,7 @@ describe('a live turn\'s frames', () => { const sidecar = await startRealSidecar() const sessionId = await sidecar.create() const checkpointAttempt = { uuid: '0b8e1f4c-6a55-4f4e-9d0a-3c2b1a090807', requestId: 'send-1', logicalAttemptId: 'attempt-1' } - sidecar.send({ type: 'send', sessionId, text: 'TOOL:sleep=0', checkpointAttempt }) + sidecar.send({ type: 'send', sessionId, uuid: checkpointAttempt.uuid, text: 'TOOL:sleep=0', checkpointAttempt }) await sidecar.waitFor('sdk.result', 'the turn result') const { cliSessionId } = await sidecar.waitFor('sdk.session.init') @@ -103,7 +103,7 @@ describe('a live turn\'s frames', () => { describe('what a compaction and a permission mode change send', () => { const resultsOf = (sidecar: RealSidecar) => sidecar.frames.filter((frame) => frame.type === 'sdk.result') - it('a failed /compact: a COMPACTION_FAILED notice, then running until its result, and no error frame', async () => { + it('a failed /compact reports its status end and COMPACTION_FAILED reason before its result', async () => { const sidecar = await startRealSidecar({ timeScale: 0.1 }) const sessionId = await sidecar.create() sidecar.send({ type: 'send', sessionId, text: '/compact APIERR:400' }) @@ -111,11 +111,13 @@ describe('what a compaction and a permission mode change send', () => { const types = sidecar.frames.map((frame) => frame.type) const compacting = sidecar.frames.findIndex((frame) => frame.type === 'sdk.status' && frame.status === 'compacting') const notice = sidecar.frames.findIndex((frame) => frame.type === 'sdk.notice') - const running = sidecar.frames.findIndex((frame) => frame.type === 'sdk.status' && frame.status === 'running') + const ended = sidecar.frames.findIndex((frame) => frame.type === 'sdk.status' && frame.status === null) expect(compacting).toBeGreaterThanOrEqual(0) expect(notice).toBeGreaterThan(compacting) - expect(running).toBeGreaterThan(notice) - expect(types.indexOf('sdk.result')).toBeGreaterThan(running) + expect(ended).toBeGreaterThan(compacting) + expect(notice).toBeGreaterThan(ended) + expect(types.indexOf('sdk.result')).toBeGreaterThan(notice) + expect(sidecar.frames.some((frame) => frame.type === 'sdk.status' && frame.status === 'running')).toBe(false) expect(sidecar.frames[notice]).toEqual({ type: 'sdk.notice', sessionId, @@ -129,15 +131,15 @@ describe('what a compaction and a permission mode change send', () => { .toMatchObject({ severity: 'error', event: 'notice', sessionId, error: 'Error during compaction: API Error: 400 local provider error' }) }) - it('a successful /compact: running again once compacting ends, then its result, and no notice', async () => { + it('a successful /compact reports its status end before its result, with no notice', async () => { const sidecar = await startRealSidecar({ timeScale: 0.1 }) const sessionId = await sidecar.create() sidecar.send({ type: 'send', sessionId, text: 'something to compact' }) await sidecar.waitFor('sdk.result') sidecar.send({ type: 'send', sessionId, text: '/compact' }) await sidecar.waitFor(() => resultsOf(sidecar).length === 2, 'the compaction result') - const statuses = sidecar.frames.filter((frame) => frame.type === 'sdk.status' || frame.type === 'sdk.result').map((frame) => frame.status ?? frame.type) - expect(statuses).toEqual(['sdk.result', 'compacting', 'running', 'sdk.result']) + const statuses = sidecar.frames.filter((frame) => frame.type === 'sdk.status' || frame.type === 'sdk.result').map((frame) => frame.type === 'sdk.status' ? frame.status : frame.type) + expect(statuses).toEqual(['requesting', 'sdk.result', 'compacting', null, 'sdk.result']) expect(sidecar.frames.some((frame) => frame.type === 'sdk.notice')).toBe(false) }) diff --git a/test/unit/claude-sidecar/permission-channel.test.ts b/test/unit/claude-sidecar/permission-channel.test.ts index faf7571c1..2b11561eb 100644 --- a/test/unit/claude-sidecar/permission-channel.test.ts +++ b/test/unit/claude-sidecar/permission-channel.test.ts @@ -33,7 +33,7 @@ const __dirname = path.dirname(__filename) const CRASH_PROBE = path.join(__dirname, 'fixtures', 'sidecar-crash-probe.mjs') // ── dependency stubs (the channel is dependency-injected; index.mjs passes its -// own emit/nanoid/nextMonotonic, the tests pass equivalents) ───────────────── +// own emit/nanoid, the tests pass equivalents) ─────────────────────────────── type Frame = Record function makeDeps(session: Record = {}) { @@ -44,8 +44,7 @@ function makeDeps(session: Record = {}) { let n = 0 // nanoid stub: exactly 21 url-safe chars, unique per call (real nanoid shape). const nanoid = () => `req-${String(++n).padStart(17, '0')}` - const nextMonotonic = (last: number | undefined, now: number) => (last != null && now <= last ? last + 1 : now) - return { frames, emit, nanoid, nextMonotonic, session } + return { frames, emit, nanoid, session } } async function settle(p: Promise, ms = 60): Promise<{ settled: boolean; value: T | undefined }> { @@ -117,9 +116,9 @@ function resolvedAnswer(harness: RealSidecar, toolName: string, n: number, label } // ── case 1 ─────────────────────────────────────────────────────────────────── -describe('case 1: raisePermissionRequest parks, emits request + 0->>=1 waiting edge', () => { - it('emits sdk.permission.request (minted 21-char id + tool payload) then sdk.turn.waiting; a second park does NOT re-emit waiting', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() +describe('case 1: raisePermissionRequest parks and emits the request (the waiting edge is the server\'s)', () => { + it('emits sdk.permission.request (minted 21-char id + tool payload) and no waiting edge; a second park emits its own request', async () => { + const { frames, emit, nanoid, session } = makeDeps() const options = { toolUseID: 'toolu_1', suggestions: [{ type: 'addRules', rules: [{ toolName: 'Bash' }], behavior: 'allow', destination: 'session' }], @@ -127,11 +126,12 @@ describe('case 1: raisePermissionRequest parks, emits request + 0->>=1 waiting e decisionReason: 'needs approval', } const p1 = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options, }) - expect(frames.map((f) => f.type)).toEqual(['sdk.permission.request', 'sdk.turn.waiting']) + // C18: the server folds this request and mints the 0 -> >=1 waiting edge. + expect(frames.map((f) => f.type)).toEqual(['sdk.permission.request']) const req = frames[0] expect(req.sessionId).toBe('s1') expect(req.requestId).toBe('req-00000000000000001') @@ -142,24 +142,20 @@ describe('case 1: raisePermissionRequest parks, emits request + 0->>=1 waiting e expect(req.suggestions).toEqual(options.suggestions) expect(req.blockedPath).toBe('/tmp/blocked') expect(req.decisionReason).toBe('needs approval') - expect(typeof frames[1].at).toBe('number') - expect(frames[1].sessionId).toBe('s1') expect(session.pendingPermissions.size).toBe(1) // The returned promise is parked until respond/cancel. expect((await settle(p1)).settled).toBe(false) - // A second parked request emits its own request frame but NOT a second - // waiting edge (0 -> >=1 transition only). + // A second parked request emits its own request frame. const p2 = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Read', input: { file_path: '/x' }, options, }) expect(frames.map((f) => f.type)).toEqual([ 'sdk.permission.request', - 'sdk.turn.waiting', 'sdk.permission.request', ]) - expect(frames[2].requestId).toBe('req-00000000000000002') + expect(frames[1].requestId).toBe('req-00000000000000002') expect(session.pendingPermissions.size).toBe(2) expect((await settle(p2)).settled).toBe(false) @@ -173,9 +169,9 @@ describe('case 1: raisePermissionRequest parks, emits request + 0->>=1 waiting e // ── case 2 ─────────────────────────────────────────────────────────────────── describe('case 2: respondPermission resolves the parked promise VERBATIM', () => { it('resolves with the exact decision object (allow then deny), deletes entries, returns true; unknown id -> false, nothing emitted', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const p1 = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 'toolu_1' }, }) const d1 = { behavior: 'allow' } @@ -186,7 +182,7 @@ describe('case 2: respondPermission resolves the parked promise VERBATIM', () => expect(frames.length).toBe(framesBeforeRespond) // respond emits no frames const p2 = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'rm -rf /' }, options: { toolUseID: 'toolu_2' }, }) const d2 = { behavior: 'deny', message: 'Denied by user', interrupt: false } @@ -202,8 +198,8 @@ describe('case 2: respondPermission resolves the parked promise VERBATIM', () => // ── case 3 ─────────────────────────────────────────────────────────────────── describe('case 3: raiseQuestionRequest (AskUserQuestion) sanitizes; short-circuits unusable input', () => { - it('sanitizes questions ({question,header,options:[{label,description}],multiSelect} + spread extras), emits request + waiting edge, parks', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + it('sanitizes questions ({question,header,options:[{label,description}],multiSelect} + spread extras), emits the request and no waiting edge, parks', async () => { + const { frames, emit, nanoid, session } = makeDeps() const input = { questions: [ { @@ -217,9 +213,9 @@ describe('case 3: raiseQuestionRequest (AskUserQuestion) sanitizes; short-circui ], marker: 'keep-me', } - const pending = raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId: 's1', input }) + const pending = raiseQuestionRequest({ session, emit, nanoid, sessionId: 's1', input }) - expect(frames.map((f) => f.type)).toEqual(['sdk.question.request', 'sdk.turn.waiting']) + expect(frames.map((f) => f.type)).toEqual(['sdk.question.request']) const req = frames[0] expect(req.sessionId).toBe('s1') expect(req.requestId).toBe('req-00000000000000001') @@ -248,8 +244,8 @@ describe('case 3: raiseQuestionRequest (AskUserQuestion) sanitizes; short-circui { questions: [null, 7] }, // filters to empty ] for (const input of badInputs) { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() - const result = await raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId: 's1', input }) + const { frames, emit, nanoid, session } = makeDeps() + const result = await raiseQuestionRequest({ session, emit, nanoid, sessionId: 's1', input }) expect(result).toEqual({ behavior: 'allow', updatedInput: input }) expect(result.updatedInput).toBe(input) // untouched input object expect(frames).toEqual([]) @@ -262,14 +258,14 @@ describe('case 3: raiseQuestionRequest (AskUserQuestion) sanitizes; short-circui // ── case 4 ─────────────────────────────────────────────────────────────────── describe('case 4: respondQuestion wraps answers into an allow decision', () => { it('resolves {behavior:allow, updatedInput:{...originalInput, questions, answers}}; unknown id -> false', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const input = { questions: [ { question: 'Pick one', header: 'H', options: [{ label: 'A', description: 'a' }], multiSelect: false }, ], marker: 'keep-me', } - const pending = raiseQuestionRequest({ session, emit, nanoid, nextMonotonic, sessionId: 's1', input }) + const pending = raiseQuestionRequest({ session, emit, nanoid, sessionId: 's1', input }) const sanitized = frames[0].questions const answers = { 'Pick one': 'A' } const framesBeforeRespond = frames.length @@ -294,13 +290,13 @@ describe('case 4: respondQuestion wraps answers into an allow decision', () => { // ── case 5 (LB-04 cancel split) ────────────────────────────────────────────── describe('case 5: cancelPending splits by live state (LB-04: never a late resolve after close)', () => { it('resolveDeny:true (post-interrupt, transport open) emits cancel frames per entry and resolves each parked promise deny/Interrupted', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const pr = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 't1' }, }) const qr = raiseQuestionRequest({ - session, emit, nanoid, nextMonotonic, sessionId: 's1', + session, emit, nanoid, sessionId: 's1', input: { questions: [{ question: 'Q?', header: '', options: [], multiSelect: true }] }, }) frames.length = 0 @@ -319,13 +315,13 @@ describe('case 5: cancelPending splits by live state (LB-04: never a late resolv }) it('resolveDeny:false (post-close/shutdown) emits cancel frames and NEVER resolves the parked promises', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const pr = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 't1' }, }) const qr = raiseQuestionRequest({ - session, emit, nanoid, nextMonotonic, sessionId: 's1', + session, emit, nanoid, sessionId: 's1', input: { questions: [{ question: 'Q?', header: '', options: [], multiSelect: true }] }, }) frames.length = 0 @@ -346,10 +342,10 @@ describe('case 5: cancelPending splits by live state (LB-04: never a late resolv // ── cases 6 & 7: canUseTool adapter routing ────────────────────────────────── describe('cases 6/7: canUseTool adapter preserves legacy ordering (sdk-bridge.ts:203-214)', () => { it('case 6: bypassPermissions -> immediate allow, nothing emitted, nothing parked', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps({ permissionMode: 'bypassPermissions' }) + const { frames, emit, nanoid, session } = makeDeps({ permissionMode: 'bypassPermissions' }) const input = { command: 'ls' } const result = await canUseTool({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input, options: { toolUseID: 't1' }, }) expect(result).toEqual({ behavior: 'allow', updatedInput: input }) @@ -360,15 +356,15 @@ describe('cases 6/7: canUseTool adapter preserves legacy ordering (sdk-bridge.ts }) it('case 7: AskUserQuestion routes to the question path even under bypassPermissions', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps({ permissionMode: 'bypassPermissions' }) + const { frames, emit, nanoid, session } = makeDeps({ permissionMode: 'bypassPermissions' }) const input = { questions: [{ question: 'Q?', header: 'h', options: [{ label: 'A', description: 'a' }], multiSelect: false }], } const pending = canUseTool({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'AskUserQuestion', input, options: { toolUseID: 't2' }, }) - expect(frames.map((f) => f.type)).toEqual(['sdk.question.request', 'sdk.turn.waiting']) + expect(frames.map((f) => f.type)).toEqual(['sdk.question.request']) expect(session.pendingQuestions.size).toBe(1) expect(session.pendingPermissions?.size ?? 0).toBe(0) cancelPending(session, emit, 's1', { resolveDeny: false }) @@ -401,10 +397,10 @@ describe('case 8: no-guard invariant probe (reviewed guard design: crash loudly, // ── case 9: provider-originated cancellation (options.signal, round-2 F1) ──── describe('case 9: options.signal subscription (provider cancellation removes the card without inventing a user decision)', () => { it('abort deletes the pending entry, emits sdk.permission.cancelled, resolves deny/Aborted by provider', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const ctrl = new AbortController() const pr = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 't1', signal: ctrl.signal }, }) frames.length = 0 @@ -417,11 +413,11 @@ describe('case 9: options.signal subscription (provider cancellation removes the }) it('abort after a respond is inert: no cancel frame, no second resolution', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const ctrl = new AbortController() const decision = { behavior: 'allow' } const pr = raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 't1', signal: ctrl.signal }, }) expect(respondPermission(session, 'req-00000000000000001', decision)).toBe(true) @@ -434,10 +430,10 @@ describe('case 9: options.signal subscription (provider cancellation removes the }) it('a parked question aborts the same way: sdk.question.cancelled + deny/Aborted by provider', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const ctrl = new AbortController() const qr = raiseQuestionRequest({ - session, emit, nanoid, nextMonotonic, sessionId: 's1', + session, emit, nanoid, sessionId: 's1', input: { questions: [{ question: 'Q?', header: '', options: [], multiSelect: false }] }, signal: ctrl.signal, }) @@ -458,11 +454,11 @@ describe('case 9: options.signal subscription (provider cancellation removes the // payload at once — never a card for a dead request. describe('pre-aborted signal short-circuits: never park, never emit, resolve deny at once', () => { it('raisePermissionRequest with an already-aborted options.signal: no frames, nothing parked, resolves deny/Aborted by provider', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const ctrl = new AbortController() ctrl.abort() const result = await raisePermissionRequest({ - session, emit, nanoid, nextMonotonic, + session, emit, nanoid, sessionId: 's1', toolName: 'Bash', input: { command: 'ls' }, options: { toolUseID: 't1', signal: ctrl.signal }, }) @@ -473,13 +469,13 @@ describe('pre-aborted signal short-circuits: never park, never emit, resolve den }) it('raiseQuestionRequest with an already-aborted signal: no frames, nothing parked, resolves deny/Aborted by provider', async () => { - const { frames, emit, nanoid, nextMonotonic, session } = makeDeps() + const { frames, emit, nanoid, session } = makeDeps() const ctrl = new AbortController() ctrl.abort() // Usable questions, so the raise reaches the pre-aborted branch (:107) rather // than the empty/invalid allow short-circuits that precede it (:81-105). const result = await raiseQuestionRequest({ - session, emit, nanoid, nextMonotonic, sessionId: 's1', + session, emit, nanoid, sessionId: 's1', input: { questions: [{ question: 'Q?', header: '', options: [], multiSelect: false }] }, signal: ctrl.signal, }) @@ -500,7 +496,7 @@ describe('case 10: production wiring through the real index.mjs (FRESHELL_CLAUDE expect(sessionId).toMatch(/^[A-Za-z0-9_-]{16,32}$/) // magic send -> the simulated CLI invokes options.canUseTool -> - // sdk.permission.request + waiting edge on stdout (order: request first). + // sdk.permission.request on stdout (the server mints the waiting edge). harness.send({ type: 'send', sessionId, text: '__raise_permission__' }) const permReq = await harness.waitFor((f) => f.type === 'sdk.permission.request', 'sdk.permission.request') expect(permReq.sessionId).toBe(sessionId) @@ -513,8 +509,6 @@ describe('case 10: production wiring through the real index.mjs (FRESHELL_CLAUDE ]) expect(permReq.blockedPath).toBe('/tmp/blocked') expect(permReq.decisionReason).toBe('needs approval') - const waiting1 = await harness.waitFor((f) => f.type === 'sdk.turn.waiting', 'sdk.turn.waiting') - expect(harness.frames.indexOf(permReq)).toBeLessThan(harness.frames.indexOf(waiting1)) // permission.respond — the CLI's parked canUseTool call settles with the EXACT decision const decision = { behavior: 'allow', updatedInput: { command: 'ls -la' } } @@ -560,13 +554,16 @@ describe('case 10: production wiring through the real index.mjs (FRESHELL_CLAUDE (f) => f.type === 'sdk.permission.request' && f.requestId !== permReq.requestId, 'second sdk.permission.request', ) - harness.send({ type: 'interrupt', sessionId }) + harness.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) const cancelFrame = await harness.waitFor((f) => f.type === 'sdk.permission.cancelled', 'sdk.permission.cancelled') expect(cancelFrame.sessionId).toBe(sessionId) expect(cancelFrame.requestId).toBe(permReq2.requestId) const resolved2 = await resolvedAnswer(harness, 'Bash', 2, 'canUseTool answer: permission #2') expect(resolved2.decision).toEqual({ behavior: 'deny', message: 'Interrupted' }) + // C18: no attention edge of any kind ever left the sidecar. + expect(harness.frames.filter((f) => f.type === 'sdk.turn.waiting' || f.type === 'sdk.turn.complete')).toEqual([]) + // clean teardown const exitP = harness.waitExit() harness.send({ type: 'shutdown' }) diff --git a/test/unit/claude-sidecar/rollback-quiesce.test.ts b/test/unit/claude-sidecar/rollback-quiesce.test.ts deleted file mode 100644 index 862500258..000000000 --- a/test/unit/claude-sidecar/rollback-quiesce.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// test/unit/claude-sidecar/rollback-quiesce.test.ts -// -// Process-level contract for the ep4-r3 rollback quiesce protocol in -// crates/freshell-claude-sidecar/index.mjs — the SDK's queued-input surface -// cannot cancel never-started items (UUID-less compact messages), so -// rollback's cancellation authority lives in the sidecar's OWN input queue: -// -// 1. `/compact` sends that were never handed to an awaiting SDK consumer are -// DROPPED at `rollback.quiesce` and counted as `cancelledQueue`; -// 2. a compact that crossed the same-tick handoff (pushed while the SDK -// consumer was awaiting) is un-cancellable — the answer's -// `handedCompactLikely` forces rollback to refuse; -// 3. the flags clear on the compact run's OWN terminal SDK frames (evidence -// stream order), so a later quiesce is all-clear again; -// 4. an open turn flags `inFlightTurn` (a handle_mirrored busy fold alone -// cannot see an open-but-unproduced turn at probe time); -// 5. the answer echoes `probeId` verbatim (request/receipt correlation — a -// stale receipt can never close a later live probe). -// -// Runs the real index.mjs over the CLI simulator (harness.ts). The SDK's -// input pump is eager: it awaits the sidecar's queue, takes one message, -// writes it to the CLI and comes back for the next. So the window in which a -// /compact is still in the sidecar's own queue is a stdin chunk: lines that -// arrive together are all pushed before the pump returns (sendLines), and -// everything after the first is queued. Nothing here depends on wall-clock -// timing: a hung model request (HANG) keeps the CLI from starting a queued -// compact until the test lets it. - -import { describe, expect, it } from 'vitest' -import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness' - -type Frame = SidecarFrame - -async function bootSession(h: RealSidecar): Promise { - return h.create({ requestId: 'q-create' }) -} - -const isQuiescedFor = (sid: string, probeId: string) => (f: Frame) => - f.type === 'sdk.rollback.quiesced' && f.sessionId === sid && f.probeId === probeId - -describe('rollback quiesce protocol (ep4-r3)', () => { - it('drains never-handed queued compacts and reports the count (all-clear verdict)', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - // One stdin chunk: the first send is handed to the awaiting SDK pump; both - // /compact sends land in the sidecar's queue before the pump comes back, - // and the probe runs in the same chunk. - h.sendLines([ - { type: 'send', sessionId: sid, text: 'HANG' }, - { type: 'send', sessionId: sid, text: '/compact' }, - { type: 'send', sessionId: sid, text: '/compact focus X' }, - { type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-drain-1' }, - ]) - const answer = await h.waitFor(isQuiescedFor(sid, 'probe-drain-1'), 'quiesced drained') - expect(answer.cancelledQueue).toBe(2) - expect(answer.inFlightTurn).toBe(false) - expect(answer.handedCompactLikely).toBe(false) - }) - - it('flags handedCompactLikely when a compact crossed the same-tick handoff, then clears it at the run result (cases 2+3)', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - // The SDK pump idles on next() between prompts — a /compact pushed now is - // handed over synchronously (un-cancellable). - h.send({ type: 'send', sessionId: sid, text: '/compact' }) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-handed-1' }) - const busy = await h.waitFor(isQuiescedFor(sid, 'probe-handed-1'), 'quiesced handed busy') - expect(busy.handedCompactLikely).toBe(true) - expect(busy.cancelledQueue).toBe(0) - - // The compact RUN provably happened (its own result frame): both flags - // discharge into the evidence stream — the next probe is all-clear again. - await h.waitFor((f) => f.type === 'sdk.result', 'compact run result') - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-handed-2' }) - const clear = await h.waitFor(isQuiescedFor(sid, 'probe-handed-2'), 'quiesced after result') - expect(clear.handedCompactLikely).toBe(false) - expect(clear.inFlightTurn).toBe(false) - }) - - it('flags inFlightTurn while a turn is open (its result never landed)', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - // A long Bash call: its tool_use assistant frame arrives and the turn stays open. - h.send({ type: 'send', sessionId: sid, text: 'TOOL:sleep=600' }) - await h.waitFor((f) => f.type === 'sdk.assistant', 'assistant frame') - // give the sidecar's bookkeeping a beat (the frame already ordered in) - await new Promise((r) => setTimeout(r, 40)) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-inflight-1' }) - const busy = await h.waitFor(isQuiescedFor(sid, 'probe-inflight-1'), 'quiesced inflight busy') - expect(busy.inFlightTurn).toBe(true) - }) - - it('ep4-r4: a compact QUEUED behind another send arming on its LATER pull (the queued-cells handoff) is busy-flagged until its result (case 6)', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - // One stdin chunk: the /compact is queued behind the first send, and the - // pre-pull probe in the same chunk drains it → all-clear. - h.sendLines([ - { type: 'send', sessionId: sid, text: 'HANG' }, - { type: 'send', sessionId: sid, text: '/compact' }, - { type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-prepull' }, - ]) - const pre = await h.waitFor(isQuiescedFor(sid, 'probe-prepull'), 'quiesced pre-pull') - expect(pre.cancelledQueue).toBe(1) - expect(pre.handedCompactLikely).toBe(false) - - // ep4-r0 regression anchor for this exact finding: with the compact - // REMOVED from the queue pre-pull (drained above), there is nothing left - // to pull — assert instead with a fresh compact queued behind another - // send in one chunk, so its HANDOFF happens when the SDK pump comes back - // for it (the pull path this case names). The CLI is still busy with the - // hung first turn, so the compact cannot start (and discharge the flag - // with its own evidence) before the probe. - h.sendLines([ - { type: 'send', sessionId: sid, text: 'HANG' }, - { type: 'send', sessionId: sid, text: '/compact queued-second' }, - ]) - // The pull happens right after that chunk; a probe in a LATER chunk sees - // it — synchronize on the sidecar having read the chunk, never on - // wall-clock arithmetic (the reviewer-flagged flake). - await h.barrier(sid) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-pulled' }) - const pulled = await h.waitFor(isQuiescedFor(sid, 'probe-pulled'), 'quiesced post-pull busy') - expect(pulled.handedCompactLikely).toBe(true) - expect(pulled.cancelledQueue).toBe(0) - }) - - it('ep4-r6 F1: an unrelated turn result never discharges the handed-compact busy truth; only the compact evidence does', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - // Two turns ahead of the compact: the first finishes (an unrelated - // result), the second is a hung model request that keeps the CLI from - // starting the compact until it is interrupted. - h.sendLines([ - { type: 'send', sessionId: sid, text: 'first turn' }, - { type: 'send', sessionId: sid, text: 'HANG' }, - ]) - await h.barrier(sid) - // /compact against an idle (awaiting) SDK pump: the handoff is same-tick (armed). - h.send({ type: 'send', sessionId: sid, text: '/compact' }) - // An unrelated turn's terminal result lands FIRST (the SDK drives inputs - // and results independently) — then, and only then, the compact's own - // evidence. - await h.waitFor( - (f) => f.type === 'sdk.result', - 'the unrelated result lands mid-window', - ) - // MID-WINDOW probe: the handed flag must survive the unrelated result. - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-f1-mid' }) - const busy = await h.waitFor(isQuiescedFor(sid, 'probe-f1-mid'), 'mid-window busy answer') - expect(busy.handedCompactLikely).toBe(true) - - // Stopping the hung turn lets the CLI run the compact. Its OWN evidence - // lands: status discharges the handed flag, its result closes the turn — - // a probe after both is all-clear again. - h.send({ type: 'interrupt', sessionId: sid }) - await h.waitFor((f) => f.type === 'sdk.status' && f.status === 'compacting', 'compacting status') - await h.waitFor( - () => h.frames.filter((x) => x.type === 'sdk.result').length >= 3, - 'the compact result (after the first turn and the interrupted one)', - ) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-f1-after' }) - const clear = await h.waitFor(isQuiescedFor(sid, 'probe-f1-after'), 'post-evidence all-clear') - expect(clear.handedCompactLikely).toBe(false) - expect(clear.inFlightTurn).toBe(false) - }) - - it('echoes only the requesting probeId (an unrelated probeId is its own answer)', async () => { - const h = await startRealSidecar() - const sid = await bootSession(h) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-correlation-a' }) - h.send({ type: 'rollback.quiesce', sessionId: sid, probeId: 'probe-correlation-b' }) - const a = await h.waitFor(isQuiescedFor(sid, 'probe-correlation-a'), 'quiesced probe A') - const b = await h.waitFor(isQuiescedFor(sid, 'probe-correlation-b'), 'quiesced probe B') - expect(a.probeId).toBe('probe-correlation-a') - expect(b.probeId).toBe('probe-correlation-b') - }) -}) diff --git a/test/unit/claude-sidecar/sdk-content-frames.test.ts b/test/unit/claude-sidecar/sdk-content-frames.test.ts index 916cf67df..403c7d062 100644 --- a/test/unit/claude-sidecar/sdk-content-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-content-frames.test.ts @@ -317,16 +317,15 @@ describe('frameLogRecord', () => { describe('framesForSdkMessage: what a status frame reports besides compacting', () => { const compactEnd = (scenario: string) => recorded(scenario).find((msg) => msg.type === 'system' && msg.subtype === 'status' && msg.status === null && msg.compact_result)! - it('a failed compaction is a notice with the CLI\'s reason; the turn that ran it is running again', () => { + it('a failed compaction is a notice with the CLI\'s reason; runtime status is computed in Rust', () => { expect(framesForSdkMessage('s1', compactEnd('compact-fail'), session(undefined, { turnPending: true }))).toEqual([ { type: 'sdk.notice', sessionId: 's1', level: 'error', code: 'COMPACTION_FAILED', message: 'Error during compaction: API Error: 400 compaction refused by fake' }, - { type: 'sdk.status', sessionId: 's1', status: 'running' }, ]) }) - it('a finished compaction reports running only while a send is still waiting for its result', () => { + it('a finished compaction does not synthesize a runtime status', () => { expect(framesForSdkMessage('s1', compactEnd('compact-ok'), session(undefined, { turnPending: true }))) - .toEqual([{ type: 'sdk.status', sessionId: 's1', status: 'running' }]) + .toEqual([]) expect(framesForSdkMessage('s1', compactEnd('compact-ok'), session(undefined, { turnPending: false }))).toEqual([]) expect(framesForSdkMessage('s1', compactEnd('compact-fail'), session(undefined, { turnPending: false })).map((frame: Msg) => frame.type)).toEqual(['sdk.notice']) }) diff --git a/test/unit/claude-sidecar/sdk-message-frames.test.ts b/test/unit/claude-sidecar/sdk-message-frames.test.ts index f30b05345..5fff074df 100644 --- a/test/unit/claude-sidecar/sdk-message-frames.test.ts +++ b/test/unit/claude-sidecar/sdk-message-frames.test.ts @@ -5,9 +5,14 @@ // are the pinned CLI's (claude-cli-contract.test.ts): T11 for a Stop (an // error_during_execution whose only error is an `[ede_diagnostic]` line), T18 // for an API error (success with is_error). +// +// From C18 a result maps to its facts alone: whether the turn rings and the +// failure banner it shows (the copy for each result kind, the plain words for +// an aborted turn nobody stopped) are the server's verdict +// (crates/freshell-freshagent/src/claude_turns_stop_tests.rs). import { describe, expect, it } from 'vitest' -import { resultErrorText, resultFrames } from '../../../crates/freshell-claude-sidecar/sdk-message-frames.mjs' +import { resultErrors, resultFrames } from '../../../crates/freshell-claude-sidecar/sdk-message-frames.mjs' const STOP_DIAGNOSTIC = '[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null' const TOOL_STOP_DIAGNOSTIC = '[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use' @@ -24,62 +29,57 @@ const stoppedResult = (terminalReason = 'aborted_streaming', diagnostic = STOP_D usage: { input_tokens: 3, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, service_tier: 'standard' }, }) -describe('resultErrorText', () => { - it('has no text for a successful result, including an API error (the transcript shows that one, T18)', () => { - expect(resultErrorText({ subtype: 'success', is_error: false, terminal_reason: 'completed' })).toBeUndefined() - expect(resultErrorText({ subtype: 'success', is_error: true, terminal_reason: 'api_error' })).toBeUndefined() - }) - - it('reports the errors the CLI gave', () => { - expect(resultErrorText({ subtype: 'error_during_execution', errors: ['Request timed out'] })).toBe('Request timed out') - expect(resultErrorText({ subtype: 'error_during_execution', errors: ['First', ' ', 'Second'] })).toBe('First\nSecond') +describe('resultErrors', () => { + it('is the CLI\'s own error text with its internal diagnostics removed, and nothing more', () => { + expect(resultErrors({ subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC, ' Request timed out ', 'Overloaded'] })).toBe('Request timed out\nOverloaded') + // No plain-words fallback here: a result whose errors are all diagnostics has none. + expect(resultErrors(stoppedResult())).toBeUndefined() + expect(resultErrors({ subtype: 'error_max_turns', errors: [] })).toBeUndefined() + expect(resultErrors({ subtype: 'success', is_error: false })).toBeUndefined() + expect(resultErrors({ subtype: 'error_during_execution', errors: [42, null, 'Real'] })).toBe('Real') }) +}) - it('never shows the CLI\'s internal [ede_diagnostic] lines', () => { - expect(resultErrorText({ subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC, 'Request timed out'] })).toBe('Request timed out') - expect(resultErrorText({ subtype: 'error_during_execution', errors: [' [ede_diagnostic] stop_reason=null', 'Overloaded'] })).toBe('Overloaded') - expect(resultErrorText({ subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC] })).not.toContain('ede_diagnostic') +describe('resultFrames', () => { + it('forwards a successful result with no failure frame', () => { + const frames = resultFrames('s1', { type: 'result', subtype: 'success', is_error: false, terminal_reason: 'completed', num_turns: 1, duration_ms: 40, total_cost_usd: 0.01, usage: { input_tokens: 5, output_tokens: 2, cache_creation_input_tokens: 0, cache_read_input_tokens: 1, service_tier: 'standard' } }) + expect(frames).toEqual([ + { type: 'sdk.result', sessionId: 's1', result: 'success', subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1, durationMs: 40, costUsd: 0.01, usage: { input_tokens: 5, output_tokens: 2, cache_creation_input_tokens: 0, cache_read_input_tokens: 1 } }, + ]) }) - it('explains an aborted turn nobody stopped in plain words when the CLI gave only diagnostics', () => { - expect(resultErrorText(stoppedResult('aborted_streaming'))).toBe('Claude stopped the turn before it finished.') - expect(resultErrorText(stoppedResult('aborted_tools', TOOL_STOP_DIAGNOSTIC))).toBe('Claude stopped the turn before it finished.') + // The verdict facts the server's turn ledger reads (design §5.3): how the + // turn ended and the CLI's own error text, with no diagnostics. + it('carries how the turn ended: an API error (T18), a Stop (T11), a failure with its text', () => { + expect(resultFrames('s1', { type: 'result', subtype: 'success', is_error: true, terminal_reason: 'api_error', num_turns: 1 })[0]) + .toMatchObject({ subtype: 'success', isError: true, terminalReason: 'api_error', numTurns: 1 }) + const stopped = resultFrames('s1', { ...stoppedResult(), num_turns: 2 })[0] + expect(stopped).toMatchObject({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 2 }) + expect(stopped).not.toHaveProperty('errorText') + expect(resultFrames('s1', { type: 'result', subtype: 'error_during_execution', is_error: true, num_turns: 3, errors: [STOP_DIAGNOSTIC, 'Request timed out'] })[0]) + .toMatchObject({ subtype: 'error_during_execution', isError: true, numTurns: 3, errorText: 'Request timed out' }) }) - it('falls back to the copy for the result kind when no error text is left', () => { - expect(resultErrorText({ subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC] })).toBe('Claude could not complete this turn. Try sending your message again.') - expect(resultErrorText({ subtype: 'error_during_execution' })).toBe('Claude could not complete this turn. Try sending your message again.') - expect(resultErrorText({ subtype: 'error_max_turns', errors: [] })).toBe('Claude reached its turn limit. Send a message to continue.') - expect(resultErrorText({ subtype: 'error_max_budget_usd' })).toBe('Claude reached the configured spending limit.') - expect(resultErrorText({ subtype: 'error_max_structured_output_retries' })).toBe('Claude could not produce the requested response format.') + it('leaves out what an older CLI does not report', () => { + const [frame] = resultFrames('s1', { type: 'result', subtype: 'error_during_execution', errors: ['boom'] }) + expect(frame).not.toHaveProperty('terminalReason') + expect(frame).not.toHaveProperty('numTurns') + expect(frame).toMatchObject({ isError: true, errorText: 'boom' }) }) -}) -describe('resultFrames', () => { - it('forwards a successful result with no failure frame', () => { - const frames = resultFrames('s1', { type: 'result', subtype: 'success', is_error: false, duration_ms: 40, total_cost_usd: 0.01, usage: { input_tokens: 5, output_tokens: 2, cache_creation_input_tokens: 0, cache_read_input_tokens: 1, service_tier: 'standard' } }, { userStopped: false }) + it('maps a failed result to one frame with its sanitized error text: the banner is the server\'s', () => { + const frames = resultFrames('s1', { type: 'result', subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC, 'Request timed out'], duration_ms: 3 }) expect(frames).toEqual([ - { type: 'sdk.result', sessionId: 's1', result: 'success', durationMs: 40, costUsd: 0.01, usage: { input_tokens: 5, output_tokens: 2, cache_creation_input_tokens: 0, cache_read_input_tokens: 1 } }, + { type: 'sdk.result', sessionId: 's1', result: 'error_during_execution', subtype: 'error_during_execution', isError: true, errorText: 'Request timed out', durationMs: 3, costUsd: undefined, usage: undefined }, ]) }) - it('follows a failed result with the turn failure, sanitized', () => { - const frames = resultFrames('s1', { type: 'result', subtype: 'error_during_execution', errors: [STOP_DIAGNOSTIC, 'Request timed out'], duration_ms: 3 }, { userStopped: false }) - expect(frames.map((frame) => frame.type)).toEqual(['sdk.result', 'sdk.error']) - expect(frames[0]).toMatchObject({ sessionId: 's1', result: 'error_during_execution', durationMs: 3, usage: undefined }) - expect(frames[1]).toEqual({ type: 'sdk.error', sessionId: 's1', message: 'Request timed out', turnFailure: true }) - }) - - it('reports the user\'s own Stop as a plain result: no failure banner (G03)', () => { + it('maps an aborted result (a Stop, T11) to one frame whose diagnostics never reach the server', () => { for (const result of [stoppedResult('aborted_streaming'), stoppedResult('aborted_tools', TOOL_STOP_DIAGNOSTIC)]) { - const frames = resultFrames('s1', result, { userStopped: true }) + const frames = resultFrames('s1', result) expect(frames).toHaveLength(1) - expect(frames[0]).toMatchObject({ type: 'sdk.result', sessionId: 's1', result: 'error_during_execution' }) + expect(frames[0]).toMatchObject({ type: 'sdk.result', sessionId: 's1', result: 'error_during_execution', terminalReason: result.terminal_reason }) + expect(JSON.stringify(frames)).not.toContain('ede_diagnostic') } }) - - it('still reports an aborted turn the user did not stop', () => { - expect(resultFrames('s1', stoppedResult(), { userStopped: false })[1]) - .toEqual({ type: 'sdk.error', sessionId: 's1', message: 'Claude stopped the turn before it finished.', turnFailure: true }) - }) }) diff --git a/test/unit/claude-sidecar/session-settings.test.ts b/test/unit/claude-sidecar/session-settings.test.ts index 332d1daae..9334bd96a 100644 --- a/test/unit/claude-sidecar/session-settings.test.ts +++ b/test/unit/claude-sidecar/session-settings.test.ts @@ -72,6 +72,30 @@ describe('Claude message content', () => { { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'YWJj' } }, ]) }) + + it('accepts every image type Claude reads, sent as data (the server never names a kind) or by URL', () => { + for (const mediaType of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) { + expect(userMessageContent('x', [{ mediaType, data: 'YWJj' }])[1]).toEqual({ type: 'image', source: { type: 'base64', media_type: mediaType, data: 'YWJj' } }) + } + expect(userMessageContent('x', [{ kind: 'url', url: 'https://example.test/cat.png' }])[1]) + .toEqual({ type: 'image', source: { type: 'url', url: 'https://example.test/cat.png' } }) + }) + + // A bad image is refused before it reaches the CLI: the send is rejected + // with INVALID_IMAGE (the sidecar answers sdk.send.rejected and lives on). + it.each([ + { what: 'a type Claude cannot read', image: { mediaType: 'image/svg+xml', data: 'YWJj' }, message: /image\/svg\+xml/ }, + { what: 'something that is not an image', image: { mediaType: 'application/pdf', data: 'YWJj' }, message: /application\/pdf/ }, + { what: 'no data', image: { mediaType: 'image/png', data: '' }, message: /cannot be read/ }, + { what: 'data that is not base64', image: { mediaType: 'image/png', data: 'not base64!' }, message: /cannot be read/ }, + { what: 'an unknown kind', image: { kind: 'file', path: '/tmp/cat.png' }, message: /cannot be read/ }, + { what: 'a URL that is not web', image: { kind: 'url', url: 'file:///etc/passwd' }, message: /cannot be read/ }, + ])('refuses $what as INVALID_IMAGE', ({ image, message }) => { + let refusal: { code?: string; message?: string } | undefined + try { userMessageContent('Describe it', [image]) } catch (error) { refusal = error as typeof refusal } + expect(refusal?.code).toBe('INVALID_IMAGE') + expect(refusal?.message).toMatch(message) + }) }) // The process-level cases run the real index.mjs over the CLI simulator @@ -95,7 +119,7 @@ async function sidecar(extraEnv: Record = {}) { const harness = await startRealSidecar({ env: extraEnv, program: FAILING_TURN_PROGRAM }) const waitFor = (type: string, requestId?: string) => harness.waitFor((frame) => frame.type === type && (!requestId || frame.requestId === requestId), requestId ? `${type} ${requestId}` : type) - return { ...harness, waitFor } + return { ...harness, waitFor, waitForFrame: harness.waitFor } } /** The query() options the simulated CLI received (the sidecar makes one query per create). */ @@ -213,15 +237,14 @@ describe('Claude sidecar configuration protocol', () => { expect(bridge.frames.some((frame) => frame.type === 'sdk.exit')).toBe(false) }) - it('surfaces failed turn details and rings the unified attention edge', async () => { + it('reports a failed turn\'s own error text on its result, and no banner or edge (the server mints both)', async () => { const bridge = await sidecar() const sessionId = await bridge.create({ requestId: 'create', model: 'opus' }) bridge.send({ type: 'send', sessionId, text: '__fail__' }) - expect(await bridge.waitFor('sdk.error')).toMatchObject({ message: 'Request timed out', turnFailure: true }) - // Unified needs-attention contract: EVERY result subtype rings — the - // failed turn's edge is identical to a success turn's (no outcome rides - // the wire); only a result consumed by a pending accepted user-interrupt - // mark stays silent (turn-complete-gate.mjs). - expect(await bridge.waitFor('sdk.turn.complete')).toMatchObject({ sessionId }) + expect(await bridge.waitFor('sdk.result')).toMatchObject({ sessionId, subtype: 'error_during_execution', isError: true, errorText: 'Request timed out' }) + await bridge.waitForFrame((frame) => frame.type === 'sdk.session.state' && frame.state === 'idle', 'the CLI goes idle') + // C18: the failure banner and the attention edge are the server's + // (claude_turns.rs verdict, claude_edges.rs); the sidecar sends neither. + expect(bridge.frames.filter((frame) => frame.type === 'sdk.error' || frame.type === 'sdk.turn.complete')).toEqual([]) }) }) diff --git a/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts b/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts index e529bc7a2..f7399fc1c 100644 --- a/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts +++ b/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -53,7 +54,7 @@ describe('Claude sidecar with a lost stdout reader', () => { send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'create', model: 'opus' }) await expect.poll(() => frames.find((frame) => frame.type === 'created'), { timeout: 5_000 }).toBeTruthy() const { sessionId } = frames.find((frame) => frame.type === 'created')! - send({ type: 'send', sessionId, text: 'stream until stopped' }) + send({ type: 'send', sessionId, uuid: randomUUID(), text: 'stream until stopped' }) await expect.poll(() => frames.filter((frame) => frame.type === 'sdk.stream').length, { timeout: 5_000 }).toBeGreaterThan(2) child.stdout!.destroy() diff --git a/test/unit/claude-sidecar/stream-end.test.ts b/test/unit/claude-sidecar/stream-end.test.ts index 69d54227e..d73220ef9 100644 --- a/test/unit/claude-sidecar/stream-end.test.ts +++ b/test/unit/claude-sidecar/stream-end.test.ts @@ -5,8 +5,9 @@ // session, so when its SDK message stream ends without being asked to, the // sidecar is done: it reports how the stream ended in `sdk.stream.ended`, its // last stdout line, and exits with code 3. The Rust server's end-of-output path -// then ends the session. A requested teardown (shutdown, stdin closing) exits 0 -// and reports nothing about the stream. +// then ends the session, and rings a turn the user was owed (C18: the sidecar +// mints no edge of its own). A requested teardown (shutdown, stdin closing) +// exits 0 and reports nothing about the stream. // // Driven through the production index.mjs over the CLI simulator (harness.ts), // plus the pure `streamEnd` classification on the SDK's exact error strings @@ -18,6 +19,24 @@ import { startRealSidecar, type RealSidecar, type SidecarFrame } from './harness const ALERT_FRAMES = new Set(['sdk.stream.ended', 'sdk.turn.complete', 'sdk.error', 'sdk.status']) +/** Frames only the server may originate (C18). */ +const EDGE_FRAMES = new Set(['sdk.turn.complete', 'sdk.turn.waiting', 'sdk.exit']) + +/** + * A frame that reports an end or a status the client shows. A model request + * (`sdk.status requesting`) and the end of a status (`null`) are lifecycle + * facts for the server's turn accounting (lifecycle-frames.mjs), not alerts. + */ +function isAlertFrame(frame: SidecarFrame): boolean { + if (frame.type === 'sdk.status' && (frame.status === 'requesting' || frame.status === null)) return false + return ALERT_FRAMES.has(frame.type) +} + +/** The statuses the client is shown, in order. */ +function shownStatuses(sidecar: RealSidecar): unknown[] { + return framesOf(sidecar, 'sdk.status').filter(isAlertFrame).map((frame) => frame.status) +} + /** A session whose simulated CLI has booted, with nothing in flight. */ async function bootedSession(sidecar: RealSidecar) { const sessionId = await sidecar.create() @@ -73,9 +92,8 @@ describe('the sidecar exits when its SDK stream ends', () => { // The cause travels in sdk.stream.ended; no second error frame repeats it. expect(framesOf(sidecar, 'sdk.error')).toEqual([]) expect(framesOf(sidecar, 'sdk.result')).toEqual([]) - // Interim (until Rust mints every edge, C18): the sidecar still rings the - // result-less turn end, once. - expect(framesOf(sidecar, 'sdk.turn.complete')).toHaveLength(1) + // The result-less turn end rings from the server, not from here (C18). + expect(sidecar.frames.filter((frame) => EDGE_FRAMES.has(frame.type))).toEqual([]) // No stream-end idle: the server reports the ended session as `exited` (C09). expect(framesOf(sidecar, 'sdk.status').filter((frame) => frame.status === 'idle')).toEqual([]) }) @@ -84,16 +102,17 @@ describe('the sidecar exits when its SDK stream ends', () => { const sidecar = await startRealSidecar() const sessionId = await bootedSession(sidecar) sidecar.send({ type: 'send', sessionId, text: 'something to compact' }) - await sidecar.waitFor('sdk.turn.complete') + await sidecar.waitFor('sdk.result') sidecar.send({ type: 'send', sessionId, text: '/compact HANG' }) - await sidecar.waitFor('sdk.status') - expect(framesOf(sidecar, 'sdk.status').at(-1)).toMatchObject({ status: 'compacting' }) + await sidecar.waitFor((frame) => frame.type === 'sdk.status' && frame.status === 'compacting', 'compacting') // Kill the (stand-in) CLI, not the sidecar. process.kill((await sidecar.waitForBoot()).cliPid, 'SIGKILL') expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) expect(sidecar.frames.at(-1)).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'cli_exited' }) - expect(framesOf(sidecar, 'sdk.status').map((frame) => frame.status)).toEqual(['compacting']) + expect(shownStatuses(sidecar)).toEqual(['compacting']) + // Nothing reports a status after compacting: no idle, and no end of the compaction. + expect(framesOf(sidecar, 'sdk.status').at(-1)).toMatchObject({ status: 'compacting' }) }) it('a natural end of stream mid-turn (the CLI exits 0): sdk.stream.ended (stream_eof) and exit code 3', async () => { @@ -102,14 +121,14 @@ describe('the sidecar exits when its SDK stream ends', () => { ] } }) const sessionId = await bootedSession(sidecar) sidecar.send({ type: 'send', sessionId, text: 'first' }) - await sidecar.waitFor('sdk.turn.complete') + await sidecar.waitFor('sdk.result') sidecar.send({ type: 'send', sessionId, text: 'second' }) expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) expect(sidecar.frames.at(-1)).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'stream_eof' }) expect(framesOf(sidecar, 'sdk.result')).toHaveLength(1) - // One edge for the first turn's result, one (interim) for the second turn's end. - expect(framesOf(sidecar, 'sdk.turn.complete')).toHaveLength(2) + // Both turn ends ring from the server (C18): the sidecar wrote no edge. + expect(sidecar.frames.filter((frame) => EDGE_FRAMES.has(frame.type))).toEqual([]) }) it('a stream that fails for another reason: sdk.stream.ended (stream_error) and exit code 3', async () => { @@ -132,7 +151,7 @@ describe('the sidecar exits when its SDK stream ends', () => { expect(await sidecar.waitExit()).toEqual({ code: STREAM_ENDED_EXIT_CODE, signal: null }) expect(sidecar.frames.at(-1)).toMatchObject({ type: 'sdk.stream.ended', sessionId, reason: 'cli_exited' }) - expect(framesOf(sidecar, 'sdk.turn.complete')).toEqual([]) + expect(sidecar.frames.filter((frame) => EDGE_FRAMES.has(frame.type))).toEqual([]) }) it('a shutdown request mid-turn exits 0 and reports nothing alert-related', async () => { @@ -143,7 +162,7 @@ describe('the sidecar exits when its SDK stream ends', () => { sidecar.send({ type: 'shutdown' }) expect(await sidecar.waitExit()).toEqual({ code: 0, signal: null }) - expect(sidecar.frames.filter((frame) => ALERT_FRAMES.has(frame.type))).toEqual([]) + expect(sidecar.frames.filter(isAlertFrame)).toEqual([]) }) it('stdin closing (the server let go of the sidecar) exits 0 and reports nothing alert-related', async () => { @@ -154,6 +173,6 @@ describe('the sidecar exits when its SDK stream ends', () => { sidecar.child.stdin!.end() expect(await sidecar.waitExit()).toEqual({ code: 0, signal: null }) - expect(sidecar.frames.filter((frame) => ALERT_FRAMES.has(frame.type))).toEqual([]) + expect(sidecar.frames.filter(isAlertFrame)).toEqual([]) }) }) diff --git a/test/unit/claude-sidecar/user-message-replay.test.ts b/test/unit/claude-sidecar/user-message-replay.test.ts index 6d9202c8a..1b34a362f 100644 --- a/test/unit/claude-sidecar/user-message-replay.test.ts +++ b/test/unit/claude-sidecar/user-message-replay.test.ts @@ -20,28 +20,33 @@ import { startRealSidecar, type SidecarFrame } from './harness' const UNKNOWN_SLASH_COMMAND = '/no-such-command-xyz' +/** A checkpointed send: the attempt binds under the send's own uuid. */ interface Attempt { uuid: string; requestId: string; logicalAttemptId: string } +function checkpointedSend(sessionId: string, text: string, { uuid, requestId, logicalAttemptId }: Attempt) { + return { type: 'send', sessionId, uuid, text, checkpointAttempt: { requestId, logicalAttemptId } } +} + const MODES: Record object[]; results: number }> = { match: { - lines: (sessionId, checkpointAttempt) => [{ type: 'send', sessionId, text: 'same prompt', checkpointAttempt }], + lines: (sessionId, checkpointAttempt) => [checkpointedSend(sessionId, 'same prompt', checkpointAttempt)], results: 1, }, mismatch: { lines: (sessionId, checkpointAttempt) => [ - { type: 'send', sessionId, text: UNKNOWN_SLASH_COMMAND, checkpointAttempt }, + checkpointedSend(sessionId, UNKNOWN_SLASH_COMMAND, checkpointAttempt), { type: 'configure', sessionId, requestId: 'model-1', settings: { model: 'claude-opus-4-1-20250805' } }, ], results: 1, }, absent: { - lines: (sessionId, checkpointAttempt) => [{ type: 'send', sessionId, text: UNKNOWN_SLASH_COMMAND, checkpointAttempt }], + lines: (sessionId, checkpointAttempt) => [checkpointedSend(sessionId, UNKNOWN_SLASH_COMMAND, checkpointAttempt)], results: 1, }, ordinary: { lines: (sessionId, checkpointAttempt) => [ { type: 'send', sessionId, text: 'TOOL:sleep=0' }, - { type: 'send', sessionId, text: UNKNOWN_SLASH_COMMAND, checkpointAttempt }, + checkpointedSend(sessionId, UNKNOWN_SLASH_COMMAND, checkpointAttempt), ], results: 2, }, diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 7bb27d11d..7fe259c08 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -414,6 +414,26 @@ describe('fresh-agent-ws', () => { expect(store.getState().freshAgent.sessions['freshclaude:claude:stale-thread-1']).toBeUndefined() }) + // C17: the server's result frame says whether the session is still busy + // (a queued send runs next); the pane follows it instead of forcing idle. + it('passes the result frame\'s sessionBusy through to the session status', () => { + const store = createFreshAgentStore() + const sessionId = 'claude-thread-queued' + const key = `freshclaude:claude:${sessionId}` + const sendEvent = (event: Record) => handleFreshAgentMessage(store.dispatch, { + type: 'freshAgent.event', + sessionId, + sessionType: 'freshclaude', + provider: 'claude', + event: { sessionId, ...event }, + }) + expect(sendEvent({ type: 'freshAgent.status', status: 'running' })).toBe(true) + expect(sendEvent({ type: 'freshAgent.result', subtype: 'success', sessionBusy: true })).toBe(true) + expect(store.getState().freshAgent.sessions[key].status).toBe('running') + expect(sendEvent({ type: 'freshAgent.result', subtype: 'success', sessionBusy: false })).toBe(true) + expect(store.getState().freshAgent.sessions[key].status).toBe('idle') + }) + it('projects every fresh-agent provider event carried by freshAgent.event into fresh-agent state', () => { const store = createFreshAgentStore() const sessionId = 'claude-thread-parity' diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts index d7baab5e2..1100cffe5 100644 --- a/test/unit/client/store/freshAgentSlice.test.ts +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -69,6 +69,28 @@ describe('freshAgentSlice busy/streaming clearing', () => { expect(isFreshAgentBusy({ kind: 'fresh-agent', ...loc, createRequestId: 'r', status: 'exited' }, state.sessions[key])).toBe(false) }) + // C17 (G08): a result ends one turn, not the session's work. When the server + // says the session is still busy (a send queued behind the finished turn runs + // next), the pane stays running; otherwise it settles idle. + it('turnResult keeps the session running while the server reports it still busy', () => { + let state = streaming() + state = reducer(state, turnResult({ ...loc, sessionBusy: true, costUsd: 0.01 })) + expect(state.sessions[key].status).toBe('running') + expect(state.sessions[key].streamingActive).toBe(false) + expect(state.sessions[key].totalCostUsd).toBe(0.01) + expect(isFreshAgentBusy({ kind: 'fresh-agent', ...loc, createRequestId: 'r', status: 'running' }, state.sessions[key])).toBe(true) + + state = reducer(state, turnResult({ ...loc, sessionBusy: false })) + expect(state.sessions[key].status).toBe('idle') + }) + + it('turnResult without sessionBusy (an older server) settles idle', () => { + let state = streaming() + state = reducer(state, turnResult({ ...loc })) + expect(state.sessions[key].status).toBe('idle') + expect(state.sessions[key].streamingActive).toBe(false) + }) + it('sessionError (non-RESTORE) clears streamingActive and resets running -> idle', () => { let state = streaming() state = reducer(state, sessionError({ ...loc, message: 'boom' })) diff --git a/test/unit/sidecar/claude-turn-complete-gate.test.ts b/test/unit/sidecar/claude-turn-complete-gate.test.ts deleted file mode 100644 index 189f54010..000000000 --- a/test/unit/sidecar/claude-turn-complete-gate.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createTurnCompleteGate } from '../../../crates/freshell-claude-sidecar/turn-complete-gate.mjs' - -// Result shapes are the pinned CLI's (claude-cli-contract.test.ts): a Stop -// that landed ends the turn with error_during_execution and -// terminal_reason aborted_streaming / aborted_tools (T11); a Stop that missed -// the turn (sent while the CLI was still starting) lets it run to -// success/completed (T13). -const result = (subtype: string, terminalReason?: string, isError = subtype !== 'success') => ({ - type: 'result', - subtype, - is_error: isError, - ...(terminalReason ? { terminal_reason: terminalReason } : {}), -}) -const completed = result('success', 'completed') -const stoppedStreaming = result('error_during_execution', 'aborted_streaming') -const stoppedTools = result('error_during_execution', 'aborted_tools') - -/** A gate whose user Stop was accepted (the settle came back ok). */ -function stoppedGate() { - const gate = createTurnCompleteGate() - gate.noteInterruptRequest() - gate.noteInterruptSettled(true) - return gate -} - -describe('freshell-claude-sidecar turn-complete gate', () => { - it('rings for every result with no Stop pending', () => { - for (const ending of [ - completed, - result('success', 'api_error', true), - result('error_during_execution', 'model_error'), - result('error_max_turns', 'max_turns'), - result('error_max_budget_usd'), - result('error_max_structured_output_retries'), - // An aborted turn the user did not stop still rings. - stoppedStreaming, - ]) { - const gate = createTurnCompleteGate() - expect(gate.resultEmitsAttention(ending), `${ending.subtype}/${ending.terminal_reason}`).toBe(true) - } - }) - - it('silences the stopped turn\'s own aborted result, and only that one', () => { - for (const stopped of [stoppedStreaming, stoppedTools]) { - const gate = stoppedGate() - expect(gate.resultEmitsAttention(stopped)).toBe(false) - expect(gate.resultEmitsAttention(completed)).toBe(true) - } - }) - - it('a Stop that missed its turn never silences it: the turn completes and rings (G06)', () => { - const gate = stoppedGate() - expect(gate.resultEmitsAttention(completed)).toBe(true) - }) - - it('a Stop that missed its turn leaves no mark behind for a later result', () => { - const gate = stoppedGate() - expect(gate.resultEmitsAttention(completed)).toBe(true) - // A later aborted result (no new Stop) is not the user's Stop: it rings. - expect(gate.resultEmitsAttention(stoppedStreaming)).toBe(true) - }) - - it('a failed turn under a missed Stop rings', () => { - const gate = stoppedGate() - expect(gate.resultEmitsAttention(result('error_max_turns', 'max_turns'))).toBe(true) - }) - - it('treats error_during_execution as the stopped turn on a CLI that reports no terminal_reason', () => { - const olderCliStopped = { ...result('error_during_execution'), num_turns: 1 } - const gate = stoppedGate() - expect(gate.resultEmitsAttention(olderCliStopped)).toBe(false) - // Without a Stop pending, the same result rings. - expect(createTurnCompleteGate().resultEmitsAttention(olderCliStopped)).toBe(true) - }) - - it('a boot refusal under a pending Stop rings: it ran no turn, so no Stop ended it (T5)', () => { - // The pinned CLI's refusal of a missing resume or a bad fork point: - // error_during_execution with no terminal_reason and num_turns 0. - const refusal = { - ...result('error_during_execution'), - num_turns: 0, - errors: ['No conversation found with session ID: 2fb6caa2-8b35-41d7-ba0c-f6f6acaffaa0'], - } - expect(stoppedGate().resultEmitsAttention(refusal)).toBe(true) - }) - - it('a rejected interrupt (ok:false) never silences a later result', () => { - const gate = createTurnCompleteGate() - gate.noteInterruptRequest() - gate.noteInterruptSettled(false) - expect(gate.resultEmitsAttention(stoppedStreaming)).toBe(true) - }) - - it('a mark survives a queued send and silences only the stopped turn\'s result (no reset-at-send)', () => { - // The SDK serializes turns within one query, so the stopped turn's result - // is the FIRST result after the settle even if the next prompt was queued - // before it arrived. - const gate = stoppedGate() - // (queued send happens here — the gate deliberately does NOT reset) - expect(gate.resultEmitsAttention(stoppedStreaming)).toBe(false) - expect(gate.resultEmitsAttention(completed)).toBe(true) - }) - - it('an interrupt with nothing pending never arms (the arm site skips the arm when no turn awaits a terminal frame)', () => { - // The gate stays pure; the ARM-SITE policy it serves (index.mjs - // handleInterrupt) is `if (st.pendingResults > 0) - // st.turnCompleteGate.noteInterruptRequest()`. With NOTHING pending the - // SDK RESOLVES the interrupt (settle ok:true, no result ever follows — - // sdk.d.ts:2384-2394), so no mark may survive to eat the NEXT unrelated - // turn's result. - const gate = createTurnCompleteGate() - const pendingResults = 0 - if (pendingResults > 0) gate.noteInterruptRequest() - gate.noteInterruptSettled(true) - expect(gate.resultEmitsAttention(stoppedStreaming)).toBe(true) - }) -}) diff --git a/test/unit/sidecar/monotonic-clock.test.ts b/test/unit/sidecar/monotonic-clock.test.ts deleted file mode 100644 index 2e138b2ee..000000000 --- a/test/unit/sidecar/monotonic-clock.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { nextMonotonic } from '../../../crates/freshell-claude-sidecar/monotonic-clock.mjs' - -// The clamp behind every `sdk.turn.complete` / `sdk.turn.waiting` mint in the -// real sidecar AND the e2e fake (task-004 review F-M1): the client folds edges -// under an `at <= last` per-session dedupe (src/store/turnCompletionSlice.ts), -// so two edges landing in the same millisecond MUST still carry strictly -// increasing `at` — a raw Date.now() mint silently loses the second ring. -describe('freshell-claude-sidecar monotonic edge clock', () => { - it('uses now for the first edge of a session', () => { - expect(nextMonotonic(undefined, 1_000)).toBe(1_000) - expect(nextMonotonic(null, 1_000)).toBe(1_000) - }) - - it('bumps past the last minted at when two edges land in the same millisecond', () => { - const first = nextMonotonic(undefined, 1_000) - expect(nextMonotonic(first, 1_000)).toBe(1_001) - expect(nextMonotonic(1_001, 1_001)).toBe(1_002) - }) - - it('bumps past the last minted at when the wall clock stalls or steps backward', () => { - expect(nextMonotonic(2_000, 1_998)).toBe(2_001) - expect(nextMonotonic(2_001, 2_000)).toBe(2_002) - }) - - it('uses a genuinely later now unchanged', () => { - expect(nextMonotonic(1_000, 1_500)).toBe(1_500) - }) -})