diff --git a/AGENTS.md b/AGENTS.md index 63ce2b848..c563d59b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Package manager: this repo runs on pnpm, pinned exactly (see [Package Manager (p ## Destructive Test Sandbox - Process-kill, config-corruption, and restart-storm suites run inside a disposable Docker sandbox, never directly on host: `scripts/sandbox-test.sh ""` (or `pnpm run test:sandbox ""`). +- Set `FRESHELL_RUST_TEST_SANDBOX=1` for agent-launched `pnpm run test`, `check`, `verify`, `test:rust`, and `test:source-runtime`; the standard Rust and source-runtime runners honor it. Direct Cargo lanes (`test:server` and `test:integration`) still require the sandbox wrapper. - See `docs/development/test-sandbox.md` for the safety guarantees, the `--corpus` read-only real-data flag, and cache-volume management. ## Amplifier Skills Deployment (machine note) @@ -281,10 +282,10 @@ stderr note). Provisioning, rotation, and revocation live in **Rename scope contract:** Claude, Codex, and OpenCode coding-agent sessions (terminal CLI panes and fresh* panes) have ONE durable saved name per session, shared by the pane header, the tab (when it takes its name from the session), the sidebar/history rows, the terminal presentation, and every API projection; renaming from any surface (UI inline editors and menus, the session/terminal/pane/tab routes, CLI verbs, MCP actions) writes that same record everywhere, automation defaults to a non-permanent automatic suggestion, and only Rename is exposed (no generate/reset/custom-label control). Everything else keeps the legacy four-scope model (layout-local pane/tab labels, terminal-scoped titles, provider-specific session overrides). The unified contract, native sync status semantics (finite unsynced recovery; no native human-intent provenance; serial generation queuing), and the migration recovery procedure (immutable `name-migration-v1` evidence + a deliberate Rename; never reactivate aliases) live in [docs/development/rename-scope-contract.md](docs/development/rename-scope-contract.md). -**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype except the stopped turn's own result: the sidecar's interrupt gate silences a result only when it holds a pending accepted user-interrupt mark AND the result is aborted (`terminal_reason` aborted_streaming/aborted_tools), so a Stop that missed its turn (sent while the CLI was still starting) never silences it (the mark is armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, and consumed by the next result either way — ordering-scoped, deliberately no reset-at-send; a stopped turn shows no error banner), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF; a pending mark never silences it) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). +**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (Claude/Kilroy and Codex deadmen), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy use the Rust turn ledger to correlate accepted user-message UUIDs, CLI lifecycle events and results. Rust mints all completion and waiting edges; the sidecar forwards SDK facts. Stop latches one target (the started turn, otherwise the oldest accepted turn, otherwise the active CLI turn), and only that target's user-caused abort is silent. A Stop that misses its target leaves other results audible; an idle Stop is a no-op. A stream error or EOF ends the runtime, publishes `exited`, and rings once if a turn was still owed; requested teardown and idle death are silent. Send and compact transparently recover an ended runtime using its saved conversation and settings, and the exited pane also exposes Resume; freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. Freshclaude/Kilroy pause their quiet timer while an approval or question is pending. With a turn owed, the timer defaults to 10 min (`FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS`); tool heartbeats stop feeding it after `FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS`, defaulting to `BASH_MAX_TIMEOUT_MS + 120000` (12 min when unset). A fenced deadline marks the runtime `stuck` and rings once. Restart sidecar uses `freshAgent.recovery.stop` to stop the process while retaining the conversation, genesis, settings and attention clock; it does not close the saved session. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). -**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the standalone Node MCP client accept `agent`/`model`/`effort` parameters where the Rust contract supports them. The Rust orchestration layer dispatches to the registered fresh-agent runtimes. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"`; terminal-mode resume uses an explicit provider-matched `sessionRef` (raw Codex resume IDs are rejected because they are not sufficient restore identity). Unsupported legacy actions return a deterministic unavailable result instead of contacting a removed backend route. +**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and standalone CLI/MCP client accept `agent`/`model`/`effort` where supported. In the default server, `new-tab` supports `agent: "claude" | "kilroy" | "opencode"`; fresh-agent `split-pane` supports Claude and Kilroy. Claude operations use the SAME `FreshClaudeState` and `createRequestId` deduplication as browser panes, including headless creation. Create defaults are resolved once from live settings and copied into the provider request and pane content. On MCP `new-tab`, `resume`/`resumeSessionId` map Claude and Kilroy to a Claude `sessionRef`, and OpenCode to an OpenCode ref; terminal-mode resume uses a provider-matched `sessionRef` (raw Codex IDs remain insufficient restore identity). REST Claude send strips exactly one trailing CR or LF; line-terminator-only input returns `noop`. A completed send returns its actual `submittedTurnId`; a deadline returns `approx`. Sends recover stopped Claude conversations automatically. An ended runtime returns a retryable 409; an explicitly closed session returns a non-retryable 409, unreadable storage returns a retryable 503, and confirmed missing history returns a non-retryable 404. Capture reads durable history, including after restart. The opt-in hosted gateway takes precedence over local lanes; installing a local REST lane does not change the snapshot HTTP authority. Unsupported legacy actions return a deterministic unavailable result. Agent-driven tab/pane creation is focus-neutral by contract: server-broadcast ui.command create/split folds carry `activate:false` into `addTab`/`splitPane` and never change the user's active tab/pane — focus moves only via the explicit select-tab/select-pane verbs. The Rust layout store (`crates/freshell-freshagent/src/layout_store.rs`) mirrors the same rule: agent creates/splits never move the server cursor either, so REST/MCP cursor-relative operations (next/prev tab, omitted targets) keep addressing the user's selection through the mirror window. Client-side this contract has three layers: (1) pane components gate mount-time autofocus on `focusEligible` (tab active + pane active + visible), and non-eligible browser iframes render `inert`; (2) eligible mounts are additionally ownership-gated (`usePaneFocusAdoption` + `src/lib/pane-focus-ownership.ts`): components record at teardown whether their pane held document focus, so an agent-driven leaf→split remount refocuses only if the pane owned focus before — flips (explicit select) always bypass, and same-target re-selects (no eligibility transition) are signaled via a per-pane focus epoch (`panes.focusEpochByPaneId`, nudged only by the select folds, never by pointer mousedowns, and forwarded by PaneContainer as the `focusEpoch` prop); (3) the app-wide focus-steal guard (`src/lib/focus-steal-guard.ts` via `useFocusStealGuard` in App) rebuffs nested-document focus hoists from `data-focus-locked` non-eligible iframes, which `inert` alone cannot stop (Chromium-verified). A store-level selection serial (`paneSelectionMiddleware`) bumps on every `setActivePane`/`nudgePaneFocus`/`setActiveTab` dispatch, and — by before/after state diff — on any `addTab`/`switchToNextTab`/`switchToPrevTab`/`splitPane`/`addPane`/`removeTab`/`closePane` that actually moved the selection (agent folds pass `activate:false` and stay serial-invisible; background closes move nothing): ownership records captured before any later selection are voided (newer selection wins). Screenshot captures (`captureUiScreenshot`, driven by the `screenshot.capture` ui.command behind `POST /api/screenshots` and the MCP capture verbs) render through html2canvas's off-DOM clone and never mutate the live page: background tabs stay mounted and fully laid out under `.tab-hidden` (CSS `visibility`, not `display` — kept measurable for xterm), html2canvas skips visibility-hidden subtrees when painting, so the clone's target chain (target up to body) gets an inline `visibility: visible` reveal via `onclone` — armed live-side only when the target has a hidden ancestor. Because nothing dispatches and nothing moves, there is no queue, no deadline/ttl, no `screenshot.cancel`, no restore, and no engagement detection; iframes are pre-rendered separately (html2canvas cannot paint them) and swapped into the clone by index+src-fingerprint correlation (a tree that changed between preparation and clone gets no replacements rather than a wrong one), and xterm's WebGL renderers are suspended around the render (refcounted `suspendTerminalRenderersForScreenshot`) so their canvases read back complete. Records for panes re-appearing in layouts (e.g. reopened tabs) are forgotten, and an eligible mount with focus stranded on `document.body` always uses unknown-pane focus semantics (stranding typing is worse than a rare honest refocus — round-14/16 trade). Locked iframes wake on pointerdown anywhere in the pane shell and on Enter/Space targeted at the shell itself (`useIframeFocusLock`) — keyboard-parity click-to-wake. diff --git a/Cargo.lock b/Cargo.lock index f510e36e4..727bd8b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ dependencies = [ name = "freshell-ws" version = "0.1.0" dependencies = [ + "async-trait", "axum", "chrono", "freshell-activity", diff --git a/README.md b/README.md index 82430b108..ba58f782e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,13 @@ turning it off removes the server from Freshclaude and Kilroy agents started or resumed after the change, with no restart. Terminal panes do not follow the switch yet. +With fresh agents enabled, the CLI and MCP client can create Freshclaude or +Kilroy tabs, split them into panes, send prompts, and capture their conversation +as plain text. These operations also work without an open browser. Use +`agent: "claude"` or `agent: "kilroy"` with MCP `new-tab` and `split-pane`, or +`--agent claude` / `--agent kilroy` with the CLI. Both support resuming a saved +Claude conversation through `resume`. + ### Rust server scope The Rust server supports the browser UI, terminal and session workflows, the diff --git a/config/vitest/vitest.config.ts b/config/vitest/vitest.config.ts index b14b4f65f..bddfd40f7 100644 --- a/config/vitest/vitest.config.ts +++ b/config/vitest/vitest.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./test/setup/dom.ts'], + globalSetup: ['./test/setup/runtime-build-preflight.ts'], exclude: [ 'test/runtime/**', '**/node_modules/**', diff --git a/crates/freshell-agent-runtime/src/lib.rs b/crates/freshell-agent-runtime/src/lib.rs index 62f124311..b287f3ec7 100644 --- a/crates/freshell-agent-runtime/src/lib.rs +++ b/crates/freshell-agent-runtime/src/lib.rs @@ -28,6 +28,7 @@ pub use qualification_policy::{ }; pub mod host_actor; pub mod snapshot_projection; +pub mod snapshot_text; #[cfg(test)] #[path = "snapshot_projection_tests.rs"] diff --git a/crates/freshell-agent-runtime/src/snapshot_text.rs b/crates/freshell-agent-runtime/src/snapshot_text.rs new file mode 100644 index 000000000..461d42e16 --- /dev/null +++ b/crates/freshell-agent-runtime/src/snapshot_text.rs @@ -0,0 +1,95 @@ +//! Plain-text capture shared by local and hosted fresh-agent REST lanes. +//! The byte cap always ends at a UTF-8 boundary. + +use serde_json::Value; + +/// Render role-prefixed turn text, commands, output and error-only turns. +pub fn render_snapshot_text(snapshot: &Value, max_bytes: usize) -> (String, bool) { + let mut output = String::new(); + if let Some(turns) = snapshot.get("turns").and_then(Value::as_array) { + for turn in turns { + let role = turn + .get("role") + .and_then(Value::as_str) + .unwrap_or("assistant"); + let mut body = Vec::new(); + if let Some(items) = turn.get("items").and_then(Value::as_array) { + for item in items { + if let Some(text) = item.get("text").and_then(Value::as_str) { + body.push(text); + } else if let Some(command) = item.get("command").and_then(Value::as_str) { + body.push(command); + } else if let Some(result) = item.get("output").and_then(Value::as_str) { + body.push(result); + } + } + } + if body.is_empty() { + // An item-less turn carrying only the durable error module + // (a claude API error or failed local command) reads as its + // error; anything else falls back to the summary. + if let Some(message) = turn.pointer("/error/message").and_then(Value::as_str) { + body.push(message); + } else if let Some(summary) = turn.get("summary").and_then(Value::as_str) { + body.push(summary); + } + } + if !body.is_empty() { + output.push_str(role); + output.push_str(": "); + output.push_str(&body.join("\n")); + output.push('\n'); + } + } + } + if output.len() <= max_bytes { + return (output, false); + } + let mut boundary = max_bytes; + while boundary > 0 && !output.is_char_boundary(boundary) { + boundary -= 1; + } + output.truncate(boundary); + (output, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn snapshot_text_renderer_is_plain_and_utf8_bounded() { + let snapshot = json!({"turns":[ + {"role":"user","summary":"ignored","items":[{"kind":"text","text":"héllo"}]}, + {"role":"assistant","summary":"world","items":[]} + ]}); + let (full, truncated) = render_snapshot_text(&snapshot, 100); + assert_eq!(full, "user: héllo\nassistant: world\n"); + assert!(!truncated); + let (short, truncated) = render_snapshot_text(&snapshot, 8); + assert!(short.len() <= 8); + assert!(short.is_char_boundary(short.len())); + assert!(truncated); + } + + #[test] + fn snapshot_text_renders_error_only_turns_by_their_error_message() { + // Real claude snapshots (pinned CLI 2.1.237 goldens): a failed /compact + // and an API error are item-less turns carrying only the error module. + for (golden, expected) in [ + ( + include_str!("../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json"), + "system: Error during compaction: API Error: 400 compaction refused by fake\n", + ), + ( + include_str!("../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json"), + "assistant: API Error: 400 fixture bad request\n", + ), + ] { + let snapshot: Value = serde_json::from_str(golden).unwrap(); + let (text, _) = render_snapshot_text(&snapshot, 1 << 20); + assert!(text.contains(expected), "{text}"); + } + } +} diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 9a43400f9..36b96e6f9 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -36,8 +36,10 @@ // sidecar → Rust (stdout, one JSON per line): // { type:'created', requestId, sessionId, protocol:2, cliPid } // — sent only once the CLI booted (initializationResult() resolved, -// T2); sessionId is the SDK bridge's BARE nanoid placeholder and -// cliPid the CLI's pid. The command catalog follows it. +// T2); sessionId is the bridge's control id (the requested UUID +// when provided), and cliPid the CLI's pid. The command catalog +// follows it. Control messages keep using this bridge id after +// a conversation reset; Rust follows the new durable id below. // { type:'create.failed', requestId, code, message } // — the CLI refused the boot (boot.mjs names the codes) or the // request is not protocol v2. Nothing from the refused boot is @@ -46,6 +48,9 @@ // { type:'sdk.configured', sessionId, requestId, ok, settings?, message? } // { 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.session.rekeyed', sessionId, newSessionId, reason:'conversation_reset' } +// — before the post-reset init, names its actual durable UUID. +// conversation_reset.new_conversation_id is only a reset nonce. // { 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 @@ -109,10 +114,9 @@ // ({ severity, component, event, ...ids }, log.mjs). Rust re-emits each // through `tracing` at its severity (claude_sidecar_io.rs). // -// The Rust side normalizes `sdk.* -> freshAgent.*` (a port of -// server/fresh-agent/sdk-events.ts) and wraps each in a `freshAgent.event` -// envelope — so this sidecar is the faithful analog of server/sdk-bridge.ts's -// SdkBridge (it emits the SAME `sdk.*` shapes SdkBridge broadcasts). +// Rust maps content facts into `freshAgent.event` envelopes. Its per-session +// turn ledger owns busy status, single-command Stop targets, and attention +// edges; process EOF owns death and recovery. This bridge forwards CLI facts. // // 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 @@ -286,6 +290,12 @@ function handleSdkMessage(sessionId, msg) { for (const frame of lifecycleFrames(sessionId, msg, st.lifecycle)) emit(frame) switch (msg.type) { + case 'conversation_reset': { + // The reset's new_conversation_id is a nonce, not the next session UUID + // (pinned CLI T16). Its next init identifies the conversation to follow. + st.pendingConversationReset = true + break + } case 'user': { // The SDK replays actual persisted user messages when this query opts in. // Only the unique UUID that this sidecar supplied for a checkpointed send @@ -296,6 +306,10 @@ function handleSdkMessage(sessionId, msg) { } case 'system': { if (msg.subtype === 'init') { + if (st.pendingConversationReset && isUuid(msg.session_id)) { + st.pendingConversationReset = false + emit({ type: 'sdk.session.rekeyed', sessionId, newSessionId: msg.session_id, reason: 'conversation_reset' }) + } st.cliSessionId = msg.session_id st.settings.cwd ??= msg.cwd st.terminalCommandNames = Array.isArray(msg.terminal_slash_commands) ? msg.terminal_slash_commands : [] @@ -447,6 +461,7 @@ function handleCreate(req) { abort, permissionMode: req.permissionMode, settings: { model: req.model, effort: req.effort, permissionMode: req.permissionMode, cwd: req.cwd }, + pendingConversationReset: false, // False until `created` is sent; until then nothing reaches Rust and // bootEvidence collects what a refusal is named from (boot.mjs). booted: false, diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 46734d457..f9dd787b3 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -12,8 +12,13 @@ //! //! | Client→server | Behaviour | //! |---|---| -//! | `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) | +//! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | allocate a native Claude UUID, boot its owned sidecar, establish ownership and recovery metadata, then publish `freshAgent.created` with its durable ref | +//! | `freshAgent.send {sessionId,text}` | recover an ended runtime when needed, apply settings, then push the user turn into the SDK input stream and broadcast its correlated acceptance | +//! +//! The live map uses the current native UUID. A conversation reset moves the +//! same runtime to its new UUID; its native history keeps late requests routed +//! to that runtime through further resets and process recovery. Each observer +//! receives a private attach acknowledgement without changing event identity. //! //! ## Events + the attention edges //! @@ -43,9 +48,8 @@ //! //! ## Safety //! -//! The Node sidecar (and the `claude` CLI grandchild the SDK spawns) inherit the server's -//! isolated HOME (so they authenticate from + write ALL transcript data under -//! `/.claude`, never the user's real store) and carry a +//! The Node sidecar and SDK-spawned Claude CLI use the configured provider +//! environment. Test rigs supply an isolated HOME and transcript store. Both carry a //! `FRESHELL_CLAUDE_SIDECAR_ID` ownership tag. Every teardown, and //! [`FreshClaudeState::shutdown`] for all sessions concurrently, first asks the sidecar to //! shut down (it aborts its SDK query, which stops the claude CLI), escalates to @@ -75,11 +79,35 @@ use freshell_protocol::{ use crate::claude_handshake::{read_created, BootContext}; use crate::claude_launch::{ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; +use runtime_identity::RuntimeIdentity; use turn_status::LedgerStep; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). pub(crate) const PROVIDER: &str = "claude"; +#[derive(Debug)] +pub struct ClaudeCleanupError { + pub code: &'static str, + pub message: String, +} + +impl std::fmt::Display for ClaudeCleanupError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ClaudeCleanupError {} + +impl ClaudeCleanupError { + fn unconfirmed() -> Self { + Self { + code: "CLAUDE_CLEANUP_UNCONFIRMED", + message: "Claude could not confirm cleanup of this cancelled pane. Try again.".into(), + } + } +} + /// Build the sidecar-only correlation record for one checkpointed Claude /// 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 @@ -125,13 +153,8 @@ pub struct FreshClaudeState { broadcast_tx: Arc>, /// Server-owned durability edge for checkpoint-related provider frames. checkpoint_event_sink: crate::CheckpointEventSink, - /// placeholder-nanoid → live claude session (sidecar stdin + owned child + consumer). + /// Current native Claude UUID → owned sidecar runtime and consumer. sessions: Arc>>, - /// durable Claude UUID (`cliSessionId` from `sdk.session.init`) -> sessions-map key. - /// THE restart-parity index (plan §2.8 item 2): lets attach/snapshot find a live - /// session by its durable identity instead of the process-ephemeral placeholder. - /// pub(crate) so in-crate tests (and snapshot wiring) can inspect it. - pub(crate) cli_index: Arc>>, /// `freshAgent.create` requestId dedup (parity gap fix -- see the module doc on /// [`crate::FreshAgentCreateDedup`]): single-flight + replay cache so a client /// resending the SAME `requestId` on every reconnect while a pane is @@ -186,26 +209,17 @@ pub struct FreshClaudeState { /// target-spawn-window abort test's deterministic hold. `None` in /// production and every other test. handoff_resume_pause: Option>, - /// b8ke focused episode-2 round-2 F3 test seam: when set, the - /// session-init adoption parks BETWEEN its phase-1 read scope and the - /// claim/publication — the deterministic hold for the - /// kill-between-read-and-publish window the publication's incarnation - /// revalidation closes. `None` in production and every other test. - #[cfg(test)] - adoption_pause: Option>, - /// b8ke focused episode-2 post-cap F3 test seam: park AFTER the claim - /// and publication, immediately BEFORE the ownership commit — the - /// deterministic stale-commit window (the test fences the claimed key - /// while parked; the released commit must fail, tear down, and - /// answer Abandoned with NOTHING persisted). - #[cfg(test)] - adoption_commit_pause: Option>, /// b8ke e3r2 F2 test seam: park in the KILL path between the stop /// claim and the retained-stamp take — the deterministic /// watcher-race window (the test removes the stamp while parked; /// the released take must be Option-tolerant, never a panic). #[cfg(test)] kill_stop_take_pause: Option>, + /// C24 regression seam: park after the kill's first alias resolution, + /// before the close gate's index+sessions critical section. A concurrent + /// conversation rekey can then prove the gate recomputes the live map key. + #[cfg(test)] + kill_after_resolve_pause: Option>, /// b8ke e3r3 F8 test seam: park in the KILL path's clean-close /// FAILURE arm, after the granted take and immediately BEFORE the /// atomic verdict+restore — the deterministic exit-after-verdict @@ -249,48 +263,10 @@ pub struct FreshClaudeState { /// child is refused, never a second child the pane cannot adopt (the /// codex `fork_in_flight` rule). fork_in_flight: crate::InFlightRegistry, - /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): retained ALIAS - /// tombstones. When a session's `cli_index` aliases are evicted (consumer - /// exit, kill, a lease-revoked/rollback teardown), the mapping is DEMOTED - /// here (placeholder map key -> every durable id it answered to), TTL'd, - /// instead of dropped. Read for RETIRE resolution ONLY by - /// [`Self::handle_kill`]: the client closes a pane by its ORIGINAL bare - /// placeholder sessionId, and without this record a kill arriving after - /// the sidecar's exit-eviction cannot recover the durable id the ledger - /// row is keyed on — the retire silently missed it (the finding). Round - /// 3 lifecycle (focused-ep5-r2 Finding 2): a genuine claim (resume/ - /// attach of the durable) CONSUMES its records - /// ([`AliasTombstones::clear_for_durable`] via `commit_session_claim`), - /// and `collect_kill_retire_ids` additionally skips any surviving record - /// whose durable id is live on a DIFFERENT session — a stale alias can - /// never retire a reopened session. Never consulted by live-resolution - /// paths (`resolve_session_key`, send/interrupt/attach, snapshots): a - /// tombstoned session is DEAD, and this record answers ids for retire - /// writes only — it makes nothing look alive. - alias_tombstones: Arc>, - /// Focused-episode-6 round 4 (Finding F5): the kill's enumeration gate — - /// map_key -> in-flight close count. A kill INCREMENTS this under the - /// same one critical section (cli_index → sessions → alias_tombstones → - /// this map — the only acquire order anywhere) that enumerates the - /// session's complete durable identity set, so that set is COMPLETE by - /// construction: any adoption minting a durable id behind the - /// enumeration reads a nonzero gate under the identical locks and - /// ABANDONS (never a post-envelope discovery). Decremented only when - /// the envelope fails Clean (the kill aborts and the session is - /// genuinely untouched — sends/mints resume); a successful or - /// persisted-durably close tears the session down and the gate entry - /// dies with it. - close_pending: Arc>>, /// Unified agent names (Task 2): the injected naming authority — same /// set-once/shared model as [`Self::identity_sink`]. Wired by /// `freshell-server::main`; unwired = scoped naming fails unavailable. naming: crate::naming::NamingSink, - /// Unified agent names: placeholder session id → the pre-durable naming - /// handle admitted at create. Popped by [`Self::adopt_session_init`]'s - /// naming hook, so the handle binds to the durable UUID exactly once - /// (idempotent in the store) even when blank settings bypass - /// `record_binding`. - naming_handles: Arc>>, /// Sessions whose sidecar ended unrequested (C09, G05): the end-of-output /// path records each one here as it evicts it from [`Self::sessions`], so the /// pane's ids still resolve to an ended session — its snapshot overlay says @@ -305,185 +281,6 @@ pub struct FreshClaudeState { test_holds: Arc, } -/// Focused-ep5-r1 Finding 1: retention for demoted alias records. The -/// kill-after-exit window is human-scale (the user notices the crashed pane -/// and closes it) — hours cover it with margin while bounding memory. The -/// CAP is a deliberate second bound for a pathological crash storm (records -/// are tiny; eviction is oldest-freshest-first). -/// -/// Retire-on-kill round 5 (focused-ep5-r4 Finding 2): the alias lifetime IS -/// the row lifetime. A long-lived pane (days) whose sidecar exit-demoted its -/// alias is still closed by its bare placeholder, so the placeholder→durable -/// mapping must survive as long as the row it can resolve to. TTL expiry and -/// capacity eviction therefore consult the ROW STATE (via the `row_bound` -/// probe every public method takes): a record whose durable row is still -/// Bound is never expired and never an eviction candidate; only a record -/// whose row is already Retired-or-GC'd may be discarded (its close can no -/// longer miss anything live). A wholly bound set may exceed the cap -/// (logged; correctness outranks the memory bound — the records are tiny). -const ALIAS_TOMBSTONE_TTL_MS: i64 = 6 * 60 * 60 * 1000; -const ALIAS_TOMBSTONE_CAP: usize = 1024; - -/// One demoted alias: the durable claude UUID the placeholder map key -/// aliased, and when the demotion happened (the TTL clock). -struct AliasTombstone { - durable: String, - at_ms: i64, -} - -/// The demoted-alias store (Finding 1). Writes sweep lazily (TTL + cap); the -/// kill path's read sweeps too, so expired records never answer a kill. -#[derive(Default)] -struct AliasTombstones { - by_placeholder: HashMap>, -} - -impl AliasTombstones { - /// Demote one (placeholder, durable) alias. A repeat demotion refreshes - /// the record's time (the newest evidence wins the TTL). `row_bound` is - /// the retention probe (round 5, Finding 2): the durable row's raw Bound - /// state — see [`Self::sweep`]. - fn record( - &mut self, - placeholder: &str, - durable: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) { - let entries = self - .by_placeholder - .entry(placeholder.to_string()) - .or_default(); - if let Some(existing) = entries.iter_mut().find(|e| e.durable == durable) { - existing.at_ms = now_ms; - } else { - entries.push(AliasTombstone { - durable: durable.to_string(), - at_ms: now_ms, - }); - } - self.sweep(now_ms, row_bound); - } - - /// Every durable id this placeholder has answered to within the TTL — - /// the kill path's evicted-alias resolution (retire set material, never - /// a liveness answer). Round 5 (Finding 2): TTL-discard consults the row - /// state — a still-Bound row's records never age out (the pane's close - /// still needs them). - fn durables_for( - &mut self, - placeholder: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) -> Vec { - self.sweep(now_ms, row_bound); - self.by_placeholder - .get(placeholder) - .map(|entries| entries.iter().map(|e| e.durable.clone()).collect()) - .unwrap_or_default() - } - - /// Round 6 (focused-ep5-r5 Finding 2): fold a DURABLE alias record into - /// the in-memory store — the kill consult's persisted arm. The persisted - /// `at_ms` is kept as the TTL truth (never refreshed to now by the fold; - /// a fresher in-memory demotion keeps its newer stamp). The sweep - /// follows, so the same row-state retention rule decides. - fn record_persisted( - &mut self, - placeholder: &str, - durable: &str, - at_ms: i64, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) { - let entries = self - .by_placeholder - .entry(placeholder.to_string()) - .or_default(); - if let Some(existing) = entries.iter_mut().find(|e| e.durable == durable) { - existing.at_ms = existing.at_ms.max(at_ms); - } else { - entries.push(AliasTombstone { - durable: durable.to_string(), - at_ms, - }); - } - self.sweep(now_ms, row_bound); - } - - /// Focused-ep5-r2 Finding 2 (retire-on-kill round 3): a genuine claim of - /// `durable` CONSUMES every demoted alias record pointing to it — the - /// reopened identity is live again, so a later kill naming one of those - /// old placeholders must no longer retire the live durable row. Returns - /// the placeholder keys whose records pointed at `durable` (the claim - /// clears their LEDGER kill fences too — every known alias of the - /// reopened identity reopens together). Other durables' records under - /// the same placeholder (a same-key re-registration answered to several) - /// are untouched. - fn clear_for_durable( - &mut self, - durable: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) -> Vec { - self.sweep(now_ms, row_bound); - let mut cleared = Vec::new(); - self.by_placeholder.retain(|placeholder, entries| { - let before = entries.len(); - entries.retain(|e| e.durable != durable); - if entries.len() != before { - cleared.push(placeholder.clone()); - } - !entries.is_empty() - }); - cleared - } - - /// Round 5 (focused-ep5-r4 Finding 2): the sweep's discard rule consults - /// ROW STATE, never age alone. TTL expiry drops only records whose - /// durable row is already Retired-or-GC'd (`row_bound` false — the - /// mapping can no longer miss a live close); a record resolving to a - /// still-Bound row survives any age because the pane's close (by the - /// bare placeholder, days after the exit-eviction) still needs it. - /// Capacity eviction likewise only ever takes an UNBOUND placeholder - /// (oldest-newest first); if every record is bound, the store runs over - /// the cap (loudly — records are tiny, and correctness outranks the - /// memory bound). - fn sweep(&mut self, now_ms: i64, row_bound: &dyn Fn(&str) -> bool) { - self.by_placeholder.retain(|_, entries| { - entries.retain(|e| row_bound(&e.durable) || now_ms - e.at_ms < ALIAS_TOMBSTONE_TTL_MS); - !entries.is_empty() - }); - while self.by_placeholder.len() > ALIAS_TOMBSTONE_CAP { - // Evict the placeholder whose NEWEST record is oldest — but only - // among placeholders with NO bound-row record (a bound-row record - // outlives the cap by the finding's rule). - let oldest = self - .by_placeholder - .iter() - .filter(|(_, entries)| entries.iter().all(|e| !row_bound(&e.durable))) - .map(|(k, entries)| { - ( - k.clone(), - entries.iter().map(|e| e.at_ms).max().unwrap_or(i64::MIN), - ) - }) - .min_by_key(|(_, newest)| *newest) - .map(|(k, _)| k); - let Some(oldest) = oldest else { - tracing::warn!(target: "freshell_freshagent::claude", - size = self.by_placeholder.len(), - cap = ALIAS_TOMBSTONE_CAP, - "freshagent.claude.alias_tombstones_over_cap: every record resolves to a \ - still-Bound row; the alias lifetime is the row lifetime (finding 2)"); - break; - }; - self.by_placeholder.remove(&oldest); - } - } -} - -/// The cached result of a completed claude/kilroy `freshAgent.create`, keyed by /// `requestId` in [`FreshClaudeState::create_dedup`]: what the original /// `freshAgent.created` said, so the replay branch re-answers identically (a resume /// create's `sessionRef` included). @@ -505,7 +302,7 @@ fn claude_session_ref(session_id: &str) -> freshell_protocol::SessionLocator { /// The per-session pending approval/question set folded from the sidecar's stdout /// stream (Task 2). Feeds the respond handlers' membership check and the Task 3 -/// snapshot overlay. Lives behind a shared handle (the `broadcast_id` precedent): the +/// snapshot overlay. Lives behind a shared handle (the `identity` precedent): the /// detached stdout consumer folds into it while the respond handlers read/mutate it. /// Session eviction on EOF/exit drops the Arc with the record — no extra handling. #[derive(Default)] @@ -599,39 +396,12 @@ struct ClaudeSession { /// (Task 6), where the map key is the CLIENT's original id. `handle_send`/ /// `handle_interrupt` MUST address the sidecar with this id, never the map key. sidecar_session_id: String, - /// Best-effort copy of the durable Claude UUID, recorded from `sdk.session.init` - /// by the stdout consumer. Attach/eviction resolve durable ids through - /// [`FreshClaudeState::cli_index`], and the snapshot adapter is disk-only; the - /// settings paths (a settings apply, the CLI's own permission-mode change) read - /// it to write the session's binding row once the session has one. - cli_session_id: Option, - /// b8ke focused episode-2 round-2 F2: the operation id of the lifecycle - /// op that OWNS this runtime's coordinator claim — the create-resume - /// claim's op id, or the handoff runner's op id for an under-ticket - /// target resume. The session-init adoption's Blocked arm correlates - /// the blocking coordinator state's operation id against this marker: - /// only the runtime's OWN operation (which will resolve the key — the - /// create's commit, the runner's commit) may proceed without a claim; - /// an UNRELATED blocking operation (another device's handoff on the - /// still-vacant canonical key, a foreign start) fails CLOSED — the - /// freshly spawned runtime is torn down, never left running unowned - /// while the other operation believes there was no prior owner. - owning_operation: Option, - /// b8ke ext r27 F2: the GENERATION of the lifecycle op that owns this - /// runtime's coordinator transition (the handoff runner's under-ticket - /// target resume) — `owning_operation`'s numeric twin. The session-init - /// adoption's durable binding write stamps the row with this generation - /// when the adoption holds no own ticket (the under-ticket shape), so - /// the target row carries the HANDOFF's generation instead of - /// preserving the prior one (pre-r27 the under-ticket write carried no - /// pair and a delayed prior-generation write could pass the ledger's - /// comparison and replace the new owner's recovery metadata). + /// A handoff target's supplied generation, used to bind recovery metadata + /// before the runner commits its replacement ownership. owning_generation: Option, - /// The envelope-stamp id the stdout consumer reads PER EVENT (Task 10b). Starts as - /// the sessions-map key; an attach-by-durable REBIND flips it to the durable id so - /// the pane keyed on the durable receives events. A shared mutable handle because - /// the consumer task runs detached from this record. - broadcast_id: Arc>, + /// Current native UUID, prior native IDs, and process-local close guards. + /// Consumers and diagnostics share it so a reset changes identity once. + identity: RuntimeIdentity, /// The folded pending approval/question set (Task 2) — see [`ClaudePending`]. pending: Arc>, /// The session's busy truth (design §5.2, C17): every command the server @@ -739,20 +509,6 @@ pub struct AdoptionTestPause { pub reached: std::sync::atomic::AtomicBool, } -/// b8ke focused episode-2 round-3 F2: the adoption's tri-state answer. -/// `Published` — the alias publication landed (the session is reachable by -/// its advertised durable id). `Abandoned` — the adoption definitively -/// landed NOTHING (the mint gate, a session that is gone or replaced, a -/// refused claim) — the caller must FAIL, never continue with an -/// unreachable live runtime. `Deferred` — the session is not yet -/// registered; the publication lands post-registration and the completion -/// hook carries the final answer. -enum SessionInitAdoptionOutcome { - Published, - Abandoned, - Deferred, -} - impl FreshClaudeState { /// Build the state around the shared broadcast bus. pub fn new(broadcast_tx: Arc>) -> Self { @@ -764,7 +520,6 @@ impl FreshClaudeState { broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), sessions: Arc::new(TokioMutex::new(HashMap::new())), - cli_index: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), resuming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), resuming_changed: Arc::new(tokio::sync::Notify::new()), @@ -775,10 +530,7 @@ impl FreshClaudeState { terminal_liveness: Arc::new(|_, _| false), rollback_in_flight: crate::InFlightRegistry::new(), fork_in_flight: crate::InFlightRegistry::new(), - alias_tombstones: Arc::new(std::sync::Mutex::new(AliasTombstones::default())), - close_pending: Arc::new(std::sync::Mutex::new(HashMap::new())), naming: crate::naming::NamingSink::default(), - naming_handles: Arc::new(TokioMutex::new(HashMap::new())), dead_sessions: Default::default(), test_holds: Arc::default(), handoff_kill_pause: None, @@ -789,12 +541,10 @@ impl FreshClaudeState { handoff_resume_pause: None, condemned_priors: Arc::new(std::sync::Mutex::new(HashMap::new())), #[cfg(test)] - adoption_pause: None, - #[cfg(test)] - adoption_commit_pause: None, - #[cfg(test)] kill_stop_take_pause: None, #[cfg(test)] + kill_after_resolve_pause: None, + #[cfg(test)] kill_close_failure_pause: None, } } @@ -921,25 +671,16 @@ impl FreshClaudeState { self.resolve_session_key(session_id).await } - /// b8ke focused episode-2 round-3 F3: crate-visible adoption driver for - /// in-crate cross-module tests (the handoff-runner tests drive an - /// in-stream re-key: an init naming a durable id other than the one the - /// session already carries). - #[cfg(test)] - pub(crate) async fn test_adopt_session_init( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - ) { - self.adopt_session_init(cli_id, session_id, session_type, None, None, None) - .await; - } - - /// b8ke focused episode-2 round-2 F3 test seam installer. #[cfg(test)] - pub fn set_adoption_test_pause(&mut self, pause: Option>) { - self.adoption_pause = pause; + pub(crate) async fn test_rekey_conversation(&self, old: &str, new: &str) { + let (pending, session_type) = { + let sessions = self.sessions.lock().await; + let runtime = sessions.get(old).expect("the native runtime exists"); + (runtime.pending.clone(), runtime.session_type.clone()) + }; + self.rekey_conversation(old, new, &session_type, &pending, "test/native-rekey") + .await + .expect("the native conversation rekeys"); } /// b8ke e3r3 F8 test seam installer (the clean-failure pre-restore park). @@ -960,28 +701,38 @@ impl FreshClaudeState { self.kill_stop_take_pause = pause; } - /// b8ke focused episode-2 post-cap F3 test seam installer (the - /// pre-commit park). + /// C24 regression seam: park after the kill's initial session-key lookup. #[cfg(test)] - pub fn set_adoption_commit_test_pause( + pub fn set_kill_after_resolve_test_pause( &mut self, pause: Option>, ) { - self.adoption_commit_pause = pause; + self.kill_after_resolve_pause = pause; } - /// b8ke focused episode-2 round-3 F3: resolve a wire session id to - /// its canonical coordinator key. The COORDINATOR is the single - /// source of truth: a re-keyed session's old key holds an - /// `Aliased{to: new}` record there (written atomically by the rekey - /// commit), and this resolves through the registry's own fixpoint - /// walk — never a lane-local map, never a bounded link cap. Unwired - /// (no coordinator): the identity (legacy behavior). + /// Active coordinator records take precedence over a staged durable + /// successor. After restart, persisted supersession supplies the chain + /// that the new coordinator has not observed yet. pub fn resolve_ownership_key(&self, session_id: &str) -> String { - match self.ownership.as_ref() { - Some(registry) => registry.resolve_canonical(PROVIDER, session_id), - None => session_id.to_string(), + if let Some(registry) = &self.ownership { + let current = registry.resolve_canonical(PROVIDER, session_id); + if current != session_id + || !matches!( + registry.observe(PROVIDER, ¤t).state, + freshell_ownership::OwnershipState::Vacant + ) + { + return current; + } } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, session_id)) + .unwrap_or_else(|| session_id.to_string()); + self.ownership.as_ref().map_or_else( + || durable.clone(), + |registry| registry.resolve_canonical(PROVIDER, &durable), + ) } /// Test seam (Task 6 round-3 review I-1): arm the deterministic holds @@ -1173,59 +924,25 @@ impl FreshClaudeState { &self.leases } - /// The coordinator key a claude session is owned under. The coordinator - /// keys a claude session by its durable id (the map key and the pane's wire - /// id are the lane's own placeholder), so once the CLI has reported the - /// session (`system/init`, which it emits only once a prompt runs, T1) that - /// id is the key, as in `handle_attach`. Before the report, or when - /// `map_key` names no live session, the wire id is. Either resolves through - /// the coordinator's alias walk. Kill and send both address the owner - /// through this one rule. C23 (durable id minted at create) makes the - /// durable id known from the start and removes the wire-id fallback. - async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> ClaudeOwnerKey { - let durable = match map_key { - Some(map_key) => self + /// Every registered runtime has a native UUID before create returns. + /// A live runtime remains authoritative while a replacement binding is + /// staged; unknown IDs consult the durable supersession chain. + async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> String { + let native = match map_key { + Some(key) => self .sessions .lock() .await - .get(map_key) - .and_then(|session| session.cli_session_id.clone()), + .get(key) + .map(|runtime| runtime.identity.current()), None => None, }; - match durable { - Some(durable) => ClaudeOwnerKey { - key: self.resolve_ownership_key(&durable), - reported: true, - }, - None => ClaudeOwnerKey { - key: self.resolve_ownership_key(wire_id), - reported: false, - }, - } - } - - /// The coordinator key that fences a send to the session behind `map_key`, - /// or `None` when the send runs unfenced. A reported session is always - /// fenced on its durable id, and a supplied observed pair is always checked - /// (against a record the coordinator does not hold, it is stale and the - /// guard refuses it typed). The one unfenced case is a session the CLI has - /// not reported, with no record in the coordinator and no pair on the - /// send: a new pane's first message, which is what makes the CLI report - /// it. Such a session has no durable id to hand off, reattach or roll back, - /// and a close removes it from the map. - async fn send_owner_key( - &self, - map_key: &str, - wire_id: &str, - fence: Option, - ) -> Option { - let owner = self.claude_owner_key(Some(map_key), wire_id).await; - if owner.reported || fence.is_some() { - return Some(owner.key); - } - match self.ownership_snapshot(PROVIDER, &owner.key).state { - freshell_ownership::OwnershipState::Vacant => None, - _ => Some(owner.key), + match native { + Some(native) => self.ownership.as_ref().map_or_else( + || native.clone(), + |registry| registry.resolve_canonical(PROVIDER, &native), + ), + None => self.resolve_ownership_key(wire_id), } } @@ -1314,10 +1031,6 @@ impl FreshClaudeState { return crate::session_handoff::StopResult::AlreadyGone; }; let removed = self.sessions.lock().await.remove(&map_key); - self.close_pending - .lock() - .expect("close-pending lock") - .remove(&map_key); let Some(session) = removed else { return crate::session_handoff::StopResult::AlreadyGone; }; @@ -1345,7 +1058,7 @@ impl FreshClaudeState { .unwrap_or(TREE_DEATH_CONFIRM_ROUNDS), ) .await; - self.evict_cli_index_aliases(&map_key).await; + self.leases.clear_binding(PROVIDER, session_id); match confirmation { TeardownConfirmation::Confirmed => { @@ -1421,7 +1134,6 @@ impl FreshClaudeState { cwd: Option<&str>, tab_id: Option<&str>, pane_id: Option<&str>, - operation_id: &str, generation: u64, ) -> Result { let Some(flavor) = session_type_from_flavor(session_type) else { @@ -1489,13 +1201,6 @@ impl FreshClaudeState { }, &mut lease_guard, &mut own_ticket, - // b8ke focused episode-2 round-2 F2: the RUNNER's handoff op - // owns this target resume's coordinator transition — the - // session records it for the adoption's Blocked correlation. - Some(operation_id.to_string()), - // b8ke ext r27 F2: the SUPPLIED handoff generation — the - // session-init adoption's binding write stamps the target - // row with it (the under-ticket shape holds no own ticket). Some(generation), ) .await; @@ -1679,7 +1384,7 @@ impl FreshClaudeState { // Retain the stamp under the NEW id with the SAME // consistent identity the registry committed (the lane's // kill/exit paths resolve the canonical durable through - // the session's current cli_session_id — the new id — and + // the runtime's current native UUID — the new id — and // take the stamp from exactly this key). let (stamp_generation, stamp_operation_id) = match ticket.as_ref() { Some(ticket_ref) => ( @@ -1798,21 +1503,10 @@ impl FreshClaudeState { self.naming.get() } - /// Unified agent names (Task 2): admit a freshclaude create's pre-durable - /// naming handle and stash it against the placeholder session id, so the - /// `sdk.session.init` adoption binds it to the durable UUID. Kilroy is - /// out of scope (its `naming_handle` is ignored); a create WITHOUT a - /// handle simply proceeds unnamed. Returns the frame projection when a - /// handle was admitted. - /// Unified agent names (Task 8 acceptance): the pending RECORD for the - /// create frame's handle, admitted at REQUEST RECEIPT — BEFORE the - /// sidecar spawn/`created` handshake (seconds) during which the pane - /// content and the client's rename capture already carry the handle. - /// A rename committed inside that window must resolve the same record - /// the server accepted on the wire (the pre-fix shape answered 404 - /// NAME_NOT_FOUND and silently lost the manual pre-durable name). - /// Idempotent: the post-`created` re-ensure in [`Self::admit_create_handle`] - /// answers the same record and adds the placeholder-keyed stash. + /// Admit the pending name at request receipt, before the CLI boots, so + /// a rename during startup reaches the same record. Establishment binds + /// it to the native UUID after saving recovery metadata. Kilroy ignores + /// naming handles; a create without a handle proceeds unnamed. async fn admit_create_handle_early(&self, msg: &FreshAgentCreate, session_type: &str) { if session_type == "kilroy" { return; @@ -1835,41 +1529,7 @@ impl FreshClaudeState { .await; } - async fn admit_create_handle( - &self, - msg: &FreshAgentCreate, - session_type: &str, - placeholder: &str, - ) -> Option<( - freshell_protocol::session_names::SessionNameRef, - freshell_protocol::session_names::SessionNameRecord, - )> { - if session_type == "kilroy" { - return None; - } - let handle = msg.naming_handle.as_deref()?.trim().to_string(); - if handle.is_empty() { - return None; - } - let sink = self.naming(); - let projection = crate::naming::admit_pending_projection( - &sink, - &handle, - freshell_protocol::session_names::NamedProvider::Claude, - msg.cwd.as_deref(), - ) - .await?; - self.naming_handles - .lock() - .await - .insert(placeholder.to_string(), handle); - Some(projection) - } - - /// Unified agent names: the current frame projection for an ALREADY - /// created session (the dedup-replay arm) — the stashed handle's - /// record, resolved through the wired authority (a pending ref and its - /// redirected durable record answer the same record via `get`). + /// Resolve the native name record for an already-created conversation. async fn created_projection_for( &self, session_id: &str, @@ -1877,18 +1537,12 @@ impl FreshClaudeState { freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, )> { - let handle = self.naming_handles.lock().await.get(session_id).cloned()?; - let sink = self.naming(); - let target = freshell_protocol::session_names::SessionNameRef::Pending { id: handle }; - let updates = sink? - .get(vec![target.clone()]) - .await - .map_err(|error| crate::naming::log_name_error("get", &target, &error)) - .ok()?; - updates - .into_iter() - .next() - .map(|update| (update.record.name_ref.clone(), update.record)) + crate::naming::session_projection( + &self.naming(), + freshell_protocol::session_names::NamedProvider::Claude, + session_id, + ) + .await } /// Broadcast a `freshAgent.error` alarm/degradation frame (P1.13; Task 10 @@ -1925,10 +1579,33 @@ impl FreshClaudeState { /// rather than one session grace per session. Called on server shutdown. pub async fn shutdown(&self) { use tracing::instrument::WithSubscriber; - let drained: Vec = { + let mut drained: Vec = { let mut guard = self.sessions.lock().await; guard.drain().map(|(_, s)| s).collect() }; + // Stop the stdout consumers before checking which turns remain owed. + // Otherwise a result can be folded while the awaited marker writes are + // in progress, publishing completion after shutdown has already decided + // to persist an interruption notice for that same turn. + for session in &drained { + session.consumer.abort(); + } + for session in &mut drained { + while !session.consumer.is_finished() { + tokio::task::yield_now().await; + } + } + let interrupted_ids: Vec = drained + .iter() + .filter(|session| session.ledger.lock().expect("turn ledger lock").busy()) + .map(|session| session.identity.current()) + .collect(); + futures_util::future::join_all( + interrupted_ids + .iter() + .map(|session_id| self.mark_turn_interrupted_for_shutdown(session_id)), + ) + .await; let confirm_rounds = self.tree_confirm_rounds(); // One task per session: each teardown's synchronous `/proc` scans run // on the runtime's workers in parallel, so no session's shutdown @@ -1953,7 +1630,6 @@ impl FreshClaudeState { ); } } - self.cli_index.lock().await.clear(); } fn broadcast(&self, msg: &ServerMessage) { @@ -2017,6 +1693,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(&cached.session_id, session_type) + .await; return ClaudeCreateOutcome::Created { session_id: cached.session_id, session_ref: cached.session_ref, @@ -2037,7 +1715,7 @@ impl FreshClaudeState { // and the client's rename capture carry the handle immediately, // while the sidecar spawn + `created` handshake below takes seconds. // Idempotent with the post-`created` re-ensure - // (`admit_create_handle`, which also stashes the placeholder key). + // The pending name binds to the native identity during establishment. self.admit_create_handle_early(&msg, session_type).await; // Task 12 (D8 for fresh agents): a create-with-resume claims the per-sessionRef @@ -2365,20 +2043,17 @@ impl FreshClaudeState { // b8ke focused episode-2 round-2 F2: the create's claim owns this // runtime's coordinator transition (the session-init adoption's Blocked // arm correlates its own in-flight claim through it). - let owning_operation = own_ticket.as_ref().map(|t| t.operation_id().to_string()); let established = match self .establish_runtime( EstablishPlan { lane: EstablishLane::Create, spec, - register_under: RegisterUnder::Adopted(launch_id.clone()), + native_id: launch_id.clone(), claim: Some(DurableClaim { durable: launch_id.clone(), dead_state: claim_dead_state, }), - rekey_provisional_claim: false, recorded_settings: Some(settings), - owning_operation, owning_generation: None, provenance, naming: Some(&msg), @@ -2413,10 +2088,8 @@ impl FreshClaudeState { // codex/opencode: a duplicate `create` arriving right after this point must see // the cache populated, never race past this guard's release and spawn a second // sidecar). - // A resume create names the conversation the CLI booted: `created` comes - // only after that boot, so the caller learns the native identity here - // instead of waiting for a turn's init (design §6, §7.3). A fresh create - // has no durable id until its first turn, so it carries none. + // Every successful create names its boot-verified native conversation, + // including a fresh conversation that has not received a prompt. let session_ref = Some(claude_session_ref(&launch_id)); self.create_dedup .record_success( @@ -2430,9 +2103,7 @@ impl FreshClaudeState { ) .await; - // Broadcast freshAgent.created (ws-handler.ts:3378); placeholder == the bare - // nanoid. Unified agent names: the created frame carries the pane's naming - // projection (the pending handle's record) when one was admitted. + // Publish the native identity and its current durable naming projection. let name_ref = naming_projection .as_ref() .map(|(reference, _)| reference.clone()); @@ -2447,6 +2118,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(&launch_id, session_type) + .await; // 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; @@ -2512,7 +2185,7 @@ impl FreshClaudeState { /// The HAS-LIVE→ADOPT arm (Task 12, V1: new server behavior): answer a loser's /// create-with-resume with `freshAgent.created` naming the adopted DURABLE session - /// (send/attach route to it via Task 10b's `cli_index` resolution) under the + /// (send/attach route to it via Task 10b's native runtime history resolution) under the /// loser's own `requestId` — no spawn, no second writer. async fn adopt_live_create( &self, @@ -2555,6 +2228,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(durable, session_type) + .await; self.announce_claude_status(durable, session_type).await; ClaudeCreateOutcome::Created { session_id: durable.to_string(), @@ -2573,6 +2248,36 @@ impl FreshClaudeState { /// `ws-handler.ts:3673`), then broadcast `freshAgent.killed`. Idempotent for an /// unknown session id, matching the codex/opencode `success:true` pattern. pub async fn handle_kill(&self, msg: FreshAgentKill) { + let _ = self.handle_kill_inner(msg, None).await; + } + + /// Release a runtime allocated by a create whose destination disappeared. + /// The receipt is authority only for the incarnation that create allocated. + pub async fn abandon_created_runtime( + &self, + session_id: &str, + session_type: SessionType, + ownership_id: &str, + ) -> Result<(), ClaudeCleanupError> { + self.handle_kill_inner( + FreshAgentKill { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type, + observed_epoch: None, + observed_generation: None, + cwd: None, + }, + Some(ownership_id), + ) + .await + } + + async fn handle_kill_inner( + &self, + msg: FreshAgentKill, + expected_owner: Option<&str>, + ) -> Result<(), ClaudeCleanupError> { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); @@ -2595,87 +2300,79 @@ impl FreshClaudeState { code: Some(err.code().to_string()), message: Some(err.message().to_string()), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } }; - // Retire-on-kill (delta-review round 5 + focused-ep5-r1 round 2, - // restore-open-sessions-only): an explicit kill is an intentional - // session END — every durable id this kill covers retires its - // pane-ledger row `Closed`, so the recovery inventory (Bound-only at - // its `row_is_bound` pre-filter) can never re-offer a pane the user - // just closed inside the 7s creation-race grace window. The row is - // keyed on the DURABLE claude UUID while the wire kill names the - // pane's ORIGINAL bare placeholder (or a durable alias), so the - // retire set is { the wire id } ∪ { the removed session's - // cli_session_id } ∪ { every LIVE cli_index alias of the map key } ∪ - // { every EVICTED alias the placeholder ever answered to (the round-2 - // alias tombstones — Finding 1: the client closes the pane by its - // bare placeholder even after the sidecar's exit-eviction dropped the - // alias map entry; the round-1 retire set silently missed the row - // in exactly that case). An UNKNOWN/evicted durable id is still - // retired by name: the session map is process memory while the row - // is durable. - // - // Finding 2's closing fence rides WITH the retire: the ledger folds a - // durable KILL TOMBSTONE into `retire_closed`, and the ONE binder - // choke point (`record_fresh_agent_binding`) consults it under the - // same index guard as the write — so a binding write already in - // flight at kill time (an aborted consumer's orphaned spawn_blocking - // closure, which NO task abort can cancel) suppresses itself instead - // of restoring Bound after the retire. - // - // Delta-r6-r3 (focused-episode-6 round 2, Finding 4) — ONE close - // envelope, then teardown. The close's identity set is computed - // FIRST (the Task-10b alias resolution + the pre-destruction - // collection below — no await ever interleaves per-identity DURABLE - // writes while live state is being destroyed), then the WHOLE set - // closes as a single failure-atomic ledger envelope - // (`retire_closed_batch` → `PaneLedger::close_identities`): - // tombstones + row retires under ONE guard hold, rolling the - // envelope's OWN writes back on any failure. The delta-r6/r6-r2 - // two-pass shape could close the wire/alias identities first and — a - // later authoritative-session close failing — report `success:false` - // with the live session preserved and standing tombstones still on - // disk: a tombstone with no matching row reads as CLOSED in the - // recovery inventory, suppressing a genuinely open session. A FAILED - // envelope here aborts the kill the same way but leaves NOTHING - // durable; a retried kill re-attempts idempotently. - // - // Task 10b: durable ids resolve through `cli_index` (alias, don't move) -- - // a kill addressed by the durable id must tear down the live aliased session. - // Unresolvable ids keep today's idempotent success path. - let map_key = self + // Discover the full native lineage before durable close or teardown. + // The ledger closes it atomically; a failed close preserves the writer. + let mut map_key = self .resolve_session_key(&session_id) .await - .unwrap_or_else(|| session_id.clone()); - // Focused-episode-6 round 4 (Finding F5) — discovery completes - // BEFORE the one envelope and BEFORE teardown. ONE critical section - // (cli_index → sessions → alias_tombstones → close_pending — the - // exact acquisition `adopt_session_init` duplicates) arms the mint - // gate AND enumerates the session's COMPLETE durable identity set: - // the wire id, the recorded cli id, every live cli_index alias, - // every evicted/persisted alias record pointing at the map key. - // From this instant every adoption abandons, so the enumerated set - // is complete by construction — nothing durable exists yet (the - // envelope writes next), and a Clean failure simply releases the - // gate with the session untouched. + .unwrap_or_else(|| self.resolve_ownership_key(&session_id)); + #[cfg(test)] + if let Some(pause) = &self.kill_after_resolve_pause { + pause + .reached + .store(true, std::sync::atomic::Ordering::SeqCst); + let _ = pause.notify.notified().await; + } + // Serialize identity discovery with native rekeys. The owned guard + // prevents further rekeys until this close settles or is cancelled. let mut retire_ids = vec![session_id.clone()]; + let mut expected_stop_stamp = None; + let _close_guard; { - let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - if sessions.contains_key(&map_key) { - let mut gates = self.close_pending.lock().expect("close-pending lock"); - *gates.entry(map_key.clone()).or_insert(0) += 1; + if let Some(current_key) = Self::session_key_in(&sessions, &session_id) + .or_else(|| Self::session_key_in(&sessions, &map_key)) + { + map_key = current_key; + } + if let Some(expected_owner) = expected_owner { + let Some(session) = sessions + .get(&map_key) + .filter(|session| session.ownership_id == expected_owner) + else { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_already_released", + session_id, expected_owner, + "the create receipt no longer owns this runtime; cleanup is complete"); + return Ok(()); + }; + if self.ownership.is_some() { + let durable = session.identity.current(); + let Some(stamp) = crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &durable, + ) + .filter(|stamp| { + // The receipt names the process-tree tag; the + // coordinator names a lifecycle operation. The + // exact runtime record ties them together. + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() == Some(map_key.as_str()) + && stamp.owner.pid == session.child.id() + }) else { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_stamp_missing", + session_id, expected_owner, + "the create receipt has no matching retained ownership stamp"); + return Err(ClaudeCleanupError::unconfirmed()); + }; + // Capture authority while inspecting that exact runtime. A + // later stop must never borrow a replacement's stamp. + expected_stop_stamp = Some((durable.to_string(), stamp)); + } } - self.collect_kill_retire_ids_under_guards( + _close_guard = sessions + .get(&map_key) + .map(|session| session.identity.begin_close()); + self.collect_kill_retire_ids_under_guard( &session_id, &map_key, &mut retire_ids, - &index, &sessions, - &mut tombstones, ); } // b8ke delta round-3 F1: THE COORDINATOR CLAIM COMES FIRST — @@ -2715,30 +2412,34 @@ impl FreshClaudeState { // progressing stop is not stale; never fenced on age alone). let mut _stop_settlement: Option = None; if let Some(registry) = self.ownership.as_ref() { - let stop_stamp = match crate::ownership_lane::peek_retained_stamp( - &self.ownership_stamps, - &session_id, - ) { - Some(stamp) => Some((session_id.clone(), stamp)), - None => { - let map_key = self.resolve_session_key(&session_id).await; - match map_key { - Some(map_key) => { - let durable = self - .sessions - .lock() - .await - .get(&map_key) - .and_then(|s| s.cli_session_id.clone()); - durable.and_then(|d| { - crate::ownership_lane::peek_retained_stamp( - &self.ownership_stamps, - &d, - ) - .map(|st| (d, st)) - }) + let stop_stamp = if expected_owner.is_some() { + expected_stop_stamp + } else { + match crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &session_id, + ) { + Some(stamp) => Some((session_id.clone(), stamp)), + None => { + let map_key = self.resolve_session_key(&session_id).await; + match map_key { + Some(map_key) => { + let durable = self + .sessions + .lock() + .await + .get(&map_key) + .map(|s| s.identity.current()); + durable.and_then(|d| { + crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &d, + ) + .map(|st| (d, st)) + }) + } + None => None, } - None => None, } } }; @@ -2829,25 +2530,8 @@ impl FreshClaudeState { "fresh_agent_kill_refused: the ownership coordinator refused the \ stop (kata b8ke) — nothing is killed" ); - // Task 3 review I-1: the refusal killed NOTHING, so it - // must roll the pre-kill mint gate back exactly like - // the Clean-failure abort arm above — a held gate makes - // `adopt_session_init` abandon forever, suppressing the - // still-live session's alias/binding corrector writes, - // and the refusal's own "retry after it settles" - // message promises a retryable, fully operational - // session. - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } + // Returning releases this runtime's close guard, so a + // refused stop leaves it able to accept a later reset. self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -2856,7 +2540,7 @@ impl FreshClaudeState { code: Some(refusal_code.to_string()), message: Some(refusal_message), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } } else { // b8ke delta round-3 F1: NO retained stamp — the kill STILL @@ -2871,8 +2555,7 @@ impl FreshClaudeState { let live_key = self.resolve_session_key(&session_id).await; let canonical = self .claude_owner_key(live_key.as_deref(), &session_id) - .await - .key; + .await; let snap = registry.observe(PROVIDER, &canonical); match snap.state.clone() { freshell_ownership::OwnershipState::Live { @@ -2894,18 +2577,6 @@ impl FreshClaudeState { owner is NOT this lane's Fresh Agent runtime — a delayed \ kill never fabricates a foreign-kind claim" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = - self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -2918,7 +2589,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } // e3r1 F1: honor the REQUEST'S fence — a delayed // kill carrying a stale observed pair is refused by @@ -3029,18 +2700,6 @@ impl FreshClaudeState { coordinator refused the observed-owner stop — nothing \ is killed, nothing durable is touched" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = - self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled( FreshAgentKilled { provider: PROVIDER.to_string(), @@ -3051,7 +2710,7 @@ impl FreshClaudeState { message: Some(refusal_message), }, )); - return; + return Err(ClaudeCleanupError::unconfirmed()); } } } @@ -3070,17 +2729,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: a handoff owns this \ session's transition — the kill is refused typed" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3092,7 +2740,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } freshell_ownership::OwnershipState::Starting { .. } | freshell_ownership::OwnershipState::Stopping { .. } => { @@ -3101,17 +2749,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: a lifecycle operation is \ in flight — the kill is refused typed" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3123,7 +2760,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } freshell_ownership::OwnershipState::Fenced { .. } => { tracing::warn!(target: "freshell_freshagent::claude", @@ -3131,17 +2768,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: the key is FENCED — the \ kill is refused typed (the fence owns the transition)" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3154,7 +2780,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } _ => { // Genuinely Vacant (or this-lane rekey residue): @@ -3252,17 +2878,6 @@ impl FreshClaudeState { } } } - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3273,7 +2888,7 @@ impl FreshClaudeState { "the pane-ledger close failed; nothing was killed — retry".to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } let main_close_reported_failure = close_answer == crate::identity_sink::CloseAnswer::Persisted; @@ -3283,13 +2898,22 @@ impl FreshClaudeState { // synchronous (the minter channel closes). // C09: a closed session is not an ended one waiting to be resumed. self.dead_sessions.forget(&retire_ids, "killed"); - let removed = self.sessions.lock().await.remove(&map_key); - // The gate entry dies with the session (the map rule was the gate's - // whole meaning; an aborted session adopts nothing more). - self.close_pending - .lock() - .expect("close-pending lock") - .remove(&map_key); + let removed = { + let mut sessions = self.sessions.lock().await; + if expected_owner.is_some_and(|owner| { + sessions + .get(&map_key) + .is_some_and(|session| session.ownership_id != owner) + }) { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_replacement_preserved", + session_id, + "a replacement appeared during the close; its runtime is preserved"); + None + } else { + sessions.remove(&map_key) + } + }; if let Some(session) = &removed { session.consumer.abort(); } @@ -3344,12 +2968,6 @@ impl FreshClaudeState { .clear_for_session(|record| record.session_id == map_key) .await; - // Evict the killed session's durable-id index entries — DEMOTED to - // the alias tombstones (Finding 1), so a repeat kill naming the bare - // placeholder still resolves the durable row (the `sdk.session.init` - // race-in case is covered: the alias collection above ran first). - self.evict_cli_index_aliases(&map_key).await; - // Round 5 (focused-ep5-r4 Finding 1), the kill's post-close SWEEP: // the durable close and the session-map removal are not one atomic // step, and a concurrent claim lane registers through the same edge @@ -3371,6 +2989,8 @@ impl FreshClaudeState { // confirmation (non-Linux) fences the key typed — never a Vacant // over an unverifiable descendant tree. NEVER before the reap // (round-1 review), never unconfirmed (round-2 review). + let cleanup_confirmed = + expected_owner.is_none() || matches!(&teardown, LoggedTeardown::Confirmed); let mut kill_failure: Option<(&'static str, String)> = None; if let (Some(registry), Some(generation), Some(op_id), Some(key)) = ( self.ownership.as_ref(), @@ -3492,15 +3112,24 @@ impl FreshClaudeState { } } + let success = !main_close_reported_failure + && !invariant_broken + && kill_failure.is_none() + && cleanup_confirmed; self.edges.forgotten(&session_id); self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, session_type: session_type.to_string(), - success: !main_close_reported_failure && !invariant_broken && kill_failure.is_none(), + success, code: kill_failure.as_ref().map(|(code, _)| code.to_string()), message: kill_failure.as_ref().map(|(_, message)| message.clone()), })); + if success { + Ok(()) + } else { + Err(ClaudeCleanupError::unconfirmed()) + } } /// Round 5 (focused-ep5-r4 Finding 1): for every id this kill retired @@ -3548,14 +3177,16 @@ impl FreshClaudeState { if sink.kill_tombstone_at_ms(PROVIDER, id).is_none() { continue; // a claim re-opened the identity after the close — not ours to kill } - let map_key = self - .resolve_session_key(id) - .await - .unwrap_or_else(|| id.to_string()); // The ONE critical section the decision rule above lives in: // remove + re-verify, no await between the two. let removed = { let mut sessions = self.sessions.lock().await; + // Only this runtime's retained lineage belongs to the close. + // A persisted successor may have reopened independently and + // cleared its own fence while this older ID remains fenced. + let Some(map_key) = Self::session_key_in(&sessions, id) else { + continue; + }; match sessions.remove(&map_key) { None => None, Some(session) => { @@ -3572,12 +3203,12 @@ impl FreshClaudeState { ); None } else { - Some(session) + Some((map_key, session)) } } } }; - if let Some(session) = removed { + if let Some((map_key, session)) = removed { tracing::info!(target: "freshell_freshagent::claude", durable = %id, map_key = %map_key, @@ -3598,7 +3229,6 @@ impl FreshClaudeState { // Keep the alias bookkeeping consistent with the removal // (demote, never drop — a later kill naming this placeholder // still resolves the durable row). - self.evict_cli_index_aliases(&map_key).await; } } } @@ -3645,122 +3275,56 @@ impl FreshClaudeState { } } - /// Retire-on-kill round 3 (focused-ep5-r2 Findings 2+3): the kill's - /// retire-set completion — ONE critical section across all three - /// alias-resolution sources, with the lock order `cli_index` → - /// `sessions` → `alias_tombstones` held for the whole consult (no awaits - /// inside). Focused-episode-6 round 4 (Finding F5): the kill calls it - /// ONCE, under the SAME acquisition that arms the close-pending mint - /// gate — identity discovery is complete BEFORE the one durable close - /// envelope is written, so post-envelope discovery is impossible by - /// construction (the round's "discovery before teardown" rule). A - /// post-removal re-run (the defensive probe) sees the demoted - /// tombstones/persisted records where the live copies went (focused- - /// ep5-r2 Finding 3's exit-eviction gap). - /// - /// Finding 2 (the tightened tombstone rule): a TOMBSTONE-sourced - /// candidate is followed only when live session state does not - /// contradict it — if the durable id currently resolves to a DIFFERENT - /// placeholder whose session is live, the identity was genuinely - /// reopened after the close, and the stale alias record is dead. (The - /// genuine claim normally CONSUMES those records at reopen — - /// `commit_session_claim`; this check is the belt to that suspenders, - /// catching any record that escapes consumption.) Live aliases of THIS - /// kill's own map key and the session's recorded cli id always retire: - /// they ARE the identity the kill is closing. + /// Close every native identity retained by the runtime or its death + /// record. Persisted legacy placeholder records remain readable so panes + /// saved by older servers can still close their durable conversation. async fn collect_kill_retire_ids( &self, wire_session_id: &str, map_key: &str, retire_ids: &mut Vec, ) { - // Lock order: cli_index -> sessions -> alias_tombstones (-> the - // kill's close-pending gate). All reads are synchronous once the - // guards are held (the std Mutexes are never held across an await). - let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - self.collect_kill_retire_ids_under_guards( - wire_session_id, - map_key, - retire_ids, - &index, - &sessions, - &mut tombstones, - ); + self.collect_kill_retire_ids_under_guard(wire_session_id, map_key, retire_ids, &sessions); } - /// The consult body, running under the caller's already-held guard trio - /// (focused-episode-6 round 4, Finding F5): the kill's enumeration and - /// its close-pending gate increment share ONE critical section — the - /// exact acquisition `adopt_session_init`'s mint gate duplicates — so - /// the enumerated set is complete by construction. - fn collect_kill_retire_ids_under_guards( + fn collect_kill_retire_ids_under_guard( &self, wire_session_id: &str, map_key: &str, retire_ids: &mut Vec, - index: &HashMap, sessions: &HashMap, - tombstones: &mut AliasTombstones, ) { - let now = crate::session_lease::now_epoch_ms() as i64; - // Round 5 (Finding 2): the tombstone consult carries the retention - // probe — a still-Bound row's alias records never age out, so the - // kill reaches the durable row however old the demotion is. - let row_bound = self.alias_row_bound_probe(); - // Round 6 (focused-ep5-r5 Finding 2): fold the DURABLE alias records - // into the consult — the restart-surviving half of the mapping (the - // in-memory store dies with the process; the ledger's - // `alias-tombstones/` subtree does not). The fold preserves the - // persisted stamps and the same row-state sweep decides retention, - // so a close naming the bare placeholder reaches the durable row - // even when the server restarted after every in-memory alias died. - // The sink read is memory-fast + sync (the ledger's write-through - // index), permitted inside this no-await section. - if let Some(sink) = self.identity_sink() { - for (durable, at_ms) in sink.alias_tombstone_records(PROVIDER, wire_session_id) { - tombstones.record_persisted(wire_session_id, &durable, at_ms, now, &row_bound); - } - } - // The session's own recorded cli id (the live map holds it: keyed by - // the placeholder OR the durable, whichever addresses the session) — - // delta-r6-r2: read HERE inside the consult, since the kills lane - // calls this both pre-removal (the entry stands) and post-abort (a - // repurposed consult has nothing newly recorded to add). - if let Some(cli_id) = sessions + let mut candidates = sessions .get(map_key) - .and_then(|s| s.cli_session_id.as_deref()) - { - if cli_id != map_key && !retire_ids.iter().any(|id| id == cli_id) { - retire_ids.push(cli_id.to_string()); - } + .map(|session| session.identity.ids()) + .or_else(|| { + self.dead_sessions + .find(wire_session_id) + .map(|dead| dead.ids) + }) + .unwrap_or_default(); + if !sessions.contains_key(map_key) { + // A persisted current UUID can have a Bound row without a live + // runtime in this server. It must close with the addressed ID. + candidates.push(map_key.to_string()); } - for (durable, mapped) in index.iter() { - if mapped == map_key && !retire_ids.iter().any(|id| id == durable) { - retire_ids.push(durable.clone()); + if let Some(sink) = self.identity_sink() { + for (durable, _) in sink.alias_tombstone_records(PROVIDER, wire_session_id) { + // An old placeholder cannot close a genuinely reopened writer. + if Self::session_key_in(sessions, &durable).is_some_and(|key| key != map_key) { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.stale_legacy_alias_skipped", + session_id = durable, previous_session_id = wire_session_id, + "the old placeholder no longer owns the live conversation"); + continue; + } + candidates.push(durable); } } - for durable in tombstones.durables_for(wire_session_id, now, &row_bound) { - // The live-contradiction check: a stale alias tombstone is - // followed only when the durable id it names is not now owned by - // a DIFFERENT live session. `mk == map_key` is this kill's own - // identity (its session entry is gone — removed above, or the - // eviction mid-flight took it), so it always retires. - let contradicted = match index.get(&durable) { - Some(mk) if mk != map_key => sessions.contains_key(mk), - _ => false, - }; - if contradicted { - tracing::info!(target: "freshell_freshagent::claude", - durable = %durable, - placeholder = %wire_session_id, - "freshagent.claude.stale_alias_tombstone_skipped: the durable is live on \ - another session; the old placeholder's close cannot reach it"); - continue; - } - if !retire_ids.iter().any(|id| id == &durable) { - retire_ids.push(durable); + for id in candidates { + if !retire_ids.contains(&id) { + retire_ids.push(id); } } } @@ -3858,13 +3422,7 @@ impl FreshClaudeState { return false; } }; - let now = crate::session_lease::now_epoch_ms() as i64; - let row_bound = self.alias_row_bound_probe(); - let mut cleared_aliases = self - .alias_tombstones - .lock() - .expect("alias tombstones lock") - .clear_for_durable(durable_id, now, &row_bound); + let mut cleared_aliases = Vec::new(); // Round 6: the DURABLE half of the consumption — the accepted claim // consumes the persisted alias records too (restart-surviving // evidence), before their kill fences clear below. @@ -3950,97 +3508,7 @@ impl FreshClaudeState { } } - /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): evict every live - /// `cli_index` alias of this map key, DEMOTING each to the retained alias - /// tombstones (`alias_tombstones`) instead of dropping it — so a later - /// kill naming the bare placeholder resolves the durable row anyway. The - /// ONE eviction funnel for the index (consumer exit, kill, - /// lease-revoked teardown, rollback-fork teardown); the process-wide - /// `shutdown` reset deliberately drops instead (in-memory state dies - /// with the process — there is no later kill to serve). Returns the - /// demoted durable ids (the consumer-exit lane reopens their leases). - /// - /// Round 5 (focused-ep5-r4 Finding 2): the alias-tombstone retention - /// probe — the durable row's raw Bound state through the identity sink - /// ([`PaneIdentitySink::row_is_bound`]. With no sink wired the probe - /// answers false and retention falls back to the pre-round-5 TTL/cap - /// regime (the sink is always wired in server builds; the alias records - /// are in-memory best-effort there anyway). - fn alias_row_bound_probe(&self) -> impl Fn(&str) -> bool + 'static { - let sink = self.identity_sink(); - move |durable: &str| { - sink.as_ref() - .is_some_and(|s| s.row_is_bound(PROVIDER, durable)) - } - } - - /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): evict every live - /// `cli_index` alias of this map key, DEMOTING each to the retained alias - /// tombstones (`alias_tombstones`) instead of dropping it — so a later - /// kill naming the bare placeholder resolves the durable row anyway. The - /// ONE eviction funnel for the index (consumer exit, kill, - /// lease-revoked teardown, rollback-fork teardown); the process-wide - /// `shutdown` reset deliberately drops instead (in-memory state dies - /// with the process — there is no later kill to serve). Returns the - /// demoted durable ids (the consumer-exit lane reopens their leases). - /// - /// Focused-ep5-r3 Finding 2 (retire-on-kill round 4): the demote and the - /// remove are ONE CRITICAL SECTION — `cli_index` is HELD while the - /// tombstone records land (lock order `cli_index` then - /// `alias_tombstones`, exactly the order the kill's consult - /// (`collect_kill_retire_ids`) takes them — no inversion exists: - /// `alias_tombstones` is never acquired first anywhere). In the round-3 - /// shape (index released between the two map ops) a kill landing in the - /// gap observed NEITHER the removed live alias NOR the not-yet-recorded - /// tombstone and retired only the placeholder, leaving the durable Bound - /// row recovery-eligible. No `await` inside the section (the std Mutex - /// never crosses one). - /// Round 6 (focused-ep5-r5 Finding 2): the demotion has a DURABLE half — - /// the same records are re-stamped into the ledger's `alias-tombstones/` - /// subtree (post-section: the lock discipline above stays synchronous), - /// so the mapping outlives the process the way the close that needs it - /// can outlast a restart. Warn-loud on failure, never a lane blocker - /// (the in-memory record still answers this process's kills). - async fn evict_cli_index_aliases(&self, map_key: &str) -> Vec { - let now = crate::session_lease::now_epoch_ms() as i64; - let row_bound = self.alias_row_bound_probe(); - let demoted: Vec = { - // The ONE critical section (lock order cli_index → - // alias_tombstones, no await inside — the std-mutex guard never - // crosses an await; the block scopes strictly so). - let mut index = self.cli_index.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - let mut demoted: Vec = Vec::new(); - index.retain(|durable, mapped| { - if mapped == map_key { - demoted.push(durable.clone()); - false - } else { - true - } - }); - for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { - tombstones.record(map_key, durable, now, &row_bound); - } - demoted - }; - if !demoted.is_empty() { - if let Some(sink) = self.identity_sink() { - for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { - if let Err(e) = sink - .record_alias_tombstone(PROVIDER, map_key, durable, now) - .await - { - tracing::warn!(error = %e, session = %durable, placeholder = %map_key, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - } - } - demoted - } - - // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── + // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── /// 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 @@ -4097,7 +3565,7 @@ impl FreshClaudeState { String, Arc>, crate::claude_turns::SharedLedger, - Option, + String, deadman::SharedQuiet, )> { let key = self.resolve_session_key(wire_id).await?; @@ -4107,7 +3575,7 @@ impl FreshClaudeState { key, session.turn_lock.clone(), session.ledger.clone(), - session.cli_session_id.clone(), + session.identity.current(), session.quiet.state.clone(), )) } @@ -4126,16 +3594,15 @@ impl FreshClaudeState { op: TurnLockedRequest, map_key: &str, wire_id: &str, - durable: Option<&str>, + durable: &str, turn_lock: &Arc>, ) -> Option { if self.sessions.lock().await.contains_key(map_key) { return Some(map_key.to_string()); } - let terminus = durable.and_then(|durable| { - self.identity_sink() - .and_then(|sink| sink.chain_terminus(PROVIDER, durable)) - }); + let terminus = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, durable)); let candidates = terminus.iter().map(String::as_str).chain([wire_id]); for candidate in candidates { let Some(key) = self.resolve_session_key(candidate).await else { @@ -4233,7 +3700,7 @@ impl FreshClaudeState { TurnLockedRequest::Send, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -4244,51 +3711,28 @@ impl FreshClaudeState { 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 - // generation, so the captured fence is checked against the owner that - // will actually receive this prompt. Checkpointed sends go through the - // guard when the coordinator is wired, and any supplied fence is - // honored; ordinary legacy sends keep their existing unfenced - // behavior. The fence keys on the record that owns the session (its - // durable id once reported), never on a new pane's placeholder wire id - // (`send_owner_key`). The one exception is `send_owner_key`'s: an - // unfenced send to a session the CLI has not reported and the - // coordinator holds no record for (a new pane's first message) runs - // unguarded. A lane without a coordinator (the session host's) has - // nothing to fence. + // Fence the runtime that actually receives the prompt after the + // turn wait. Native ownership already exists before its first send. let fenced = msg.checkpoint_intent.is_some() || send_fence.is_some(); let ownership_guard = if fenced && self.ownership.is_some() { - if let Some(owner_key) = self.send_owner_key(&map_key, &session_id, send_fence).await { - match crate::ownership_lane::arm_reclaimless_op_guard( - &self.ownership, - PROVIDER, - &owner_key, - &format!("send-{}", uuid::Uuid::new_v4()), - send_fence, - "freshclaude/send", - ) { - crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { - tracing::warn!(target: "freshell_freshagent::claude", + let owner_key = self.claude_owner_key(Some(&map_key), &session_id).await; + match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + &owner_key, + &format!("send-{}", uuid::Uuid::new_v4()), + send_fence, + "freshclaude/send", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { + tracing::warn!(target: "freshell_freshagent::claude", session_id = %session_id, owner_key = %owner_key, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - return self.refuse_reserved_send(&request_id, &message, owner); - } + return self.refuse_reserved_send(&request_id, &message, owner); } - } else { - // Nothing owns the session in the coordinator yet and the send - // names no owner: the CLI reports the session only once a - // prompt runs (T1), and this send is what makes it run. - // Refusing it would refuse every new pane's first message. - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %session_id, request_id = ?request_id, - event = "freshagent.claude.send_before_session_report", - "the send runs unfenced: the CLI has not reported the session yet \ - and the coordinator holds no record for it"); - None } } else { None @@ -4311,10 +3755,7 @@ impl FreshClaudeState { // device's model surfaces — the same frame `handle_configure` // emits (a device that missed the configure still lands here). let settings_before = self.stated_settings(&map_key).await; - if let Err(err) = self - .configure_for_send(&map_key, settings, session_type) - .await - { + if let Err(err) = self.configure_for_send(&map_key, settings).await { return self.refuse_send(&request_id, "CLAUDE_SETTINGS_FAILED", &err); } let settings_after = self.stated_settings(&map_key).await; @@ -4331,19 +3772,13 @@ impl FreshClaudeState { // (the rollback-row MOVE old→new) while we parked. Keying the destroy by // the pre-MOVE id would no-op `destroy_redo_on_submit` and leak redo // (decision 5): `destroy_redo_on_submit` keys the record by its CURRENT - // durable id, i.e. the session record's `cli_session_id` after the + // durable id, i.e. the runtime's current native UUID after the // adoption re-key. A bare pre-init placeholder can have no rollback // record — the addressed canonical id is its only possible key then. let destroy_target = { let guard = self.sessions.lock().await; match guard.get(&map_key) { - Some(s) => s.cli_session_id.clone().or_else(|| { - if is_canonical_claude_uuid(&session_id) { - Some(session_id.clone()) - } else { - None - } - }), + Some(s) => Some(s.identity.current()), None => { drop(guard); return self.refuse_send_after_runtime_stopped( @@ -4431,75 +3866,26 @@ impl FreshClaudeState { } drop(ownership_guard); - // Unified agent names (Task 4): the shared accepted-input callback — - // the send reached the sidecar, so the user input is ACCEPTED. The - // naming target is the stashed pre-durable handle when one exists - // (it redirects to the bound durable record once adopted), else - // the adopted durable claude session, else — review N1 — the - // pending ref for the ADDRESSED id itself: a send racing the init - // adoption (the stash entry just consumed by the bound cleanup, - // the addressed id still the placeholder) must never silently - // drop the feed; the store's redirect resolution decides, and - // the next send addresses the adopted id anyway. Kilroy is out - // of scope; a failed feed never blocks the turn. - // - // Task 8 acceptance: once the init ADOPTION consumed the stash and - // recorded the session's durable `cli_session_id`, a placeholder- - // addressed send targets the ADOPTED durable record — the old - // pending- fallback can never resolve in the real - // store (no record is keyed by a placeholder id), and the - // first-message fallback upgrade was silently lost for exactly - // these sends. - let naming_target = { - // Sequential lock reads (never nested): the pre-durable stash, - // then the session row's adopted durable id. - let stashed_handle = self.naming_handles.lock().await.get(&session_id).cloned(); - let adopted_cli_id = self - .sessions - .lock() - .await - .get(&map_key) - .and_then(|s| s.cli_session_id.clone()); - stashed_handle - .map( - |handle| freshell_protocol::session_names::SessionNameRef::Pending { - id: handle, - }, - ) - .or_else(|| { - adopted_cli_id.map(|cli_id| { - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: cli_id, - } - }) - }) - .or_else(|| { - is_canonical_claude_uuid(&session_id).then(|| { - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: session_id.clone(), - } - }) - }) - .or_else(|| { - Some(freshell_protocol::session_names::SessionNameRef::Pending { - id: session_id.clone(), - }) - }) - }; + // Accepted input belongs to the conversation created before this send. + // Resolve a concurrent reset through the same native lineage as routing. if session_type != "kilroy" { - if let Some(target) = naming_target { - crate::naming::report_accepted_input( - &self.naming(), - &target, - session_type, - request_id.as_deref().unwrap_or(""), - &msg.text, - msg.cwd.as_deref(), - ) - .await; - } + let current_id = self + .resolve_session_key(&map_key) + .await + .unwrap_or(map_key.clone()); + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: current_id, + }; + crate::naming::report_accepted_input( + &self.naming(), + &target, + session_type, + request_id.as_deref().unwrap_or(""), + &msg.text, + msg.cwd.as_deref(), + ) + .await; } self.broadcast(&ServerMessage::FreshAgentSendAccepted( @@ -4525,7 +3911,6 @@ impl FreshClaudeState { &self, map_key: &str, requested: &freshell_protocol::FreshAgentSendSettings, - session_type: &str, ) -> Result<(), String> { let request_id = uuid::Uuid::new_v4().to_string(); let (response, next, writer, frame, ownership_id) = { @@ -4586,44 +3971,25 @@ impl FreshClaudeState { .into(), ), }; - let (durable, effective) = { + { let mut sessions = self.sessions.lock().await; let session = sessions - .get_mut(map_key) - .ok_or("Claude session is no longer available.")?; - if session.ownership_id != ownership_id { - return Err( - "Claude changed while applying settings. Your message was not sent; try again." - .into(), - ); - } + .values_mut() + .find(|session| session.ownership_id == ownership_id) + .ok_or( + "Claude changed while applying settings. Your message was not sent; try again.", + )?; session.configuration.pending = None; session.configuration.needs_configure = applied.is_err(); if applied.is_ok() { session.configuration.settings = next; } - // A setter can succeed before a later setter rejects. The receipt - // reconciles that partial success; persist the actual runtime state - // even though the user's prompt must remain unsent. - ( - session.cli_session_id.clone(), - session.configuration.settings.clone(), - ) - }; - if let Some(durable) = durable { - self.adopt_session_init( - &durable, - map_key, - session_type, - Some(&effective), - self.identity_sink(), - // Settings-apply is conn-less from the pane's perspective: not a - // browser assertion — the ledger merge keeps the row's prior - // stamps (ep4 writer rules). - None, - ) - .await; } + // A partial setter success is actual runtime state even when a later + // setter rejects. Save it without repeating identity establishment. + self.persist_runtime_settings(&ownership_id) + .await + .map_err(|message| format!("{message} Your message was not sent."))?; applied } @@ -4671,7 +4037,7 @@ impl FreshClaudeState { TurnLockedRequest::Configure, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -4695,10 +4061,7 @@ impl FreshClaudeState { let map_key = live_key; let before = self.stated_settings(&map_key).await; - if let Err(err) = self - .configure_for_send(&map_key, &msg.settings, session_type) - .await - { + if let Err(err) = self.configure_for_send(&map_key, &msg.settings).await { // The refusal/failure is user-facing session state (busy turn, // sidecar death): the pane banner is the surface, on every device. self.emit_control_error( @@ -4757,7 +4120,7 @@ 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 clone session handles for the sidecar write. A miss at + /// native runtime history) 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 response_session( @@ -5000,7 +4363,7 @@ impl FreshClaudeState { TurnLockedRequest::Compact, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -5021,11 +4384,11 @@ impl FreshClaudeState { }; let map_key = live_key; let _ownership_guard = if fence.is_some() && self.ownership.is_some() { - let owner_key = self.send_owner_key(&map_key, &session_id, fence).await; + let owner_key = self.claude_owner_key(Some(&map_key), &session_id).await; match crate::ownership_lane::arm_reclaimless_op_guard( &self.ownership, PROVIDER, - owner_key.as_deref().unwrap_or(&map_key), + &owner_key, &format!("compact-{}", uuid::Uuid::new_v4()), fence, "freshclaude/compact", @@ -5104,7 +4467,7 @@ impl FreshClaudeState { /// `.strict()` contract shape (`{requestId, toolName?, toolUseID?, blockedPath?, /// decisionReason?, input?, suggestions?}` / `{requestId, questions}`, no extra /// keys, absent keys omitted). `any_id` resolves like every handler here: the map - /// key OR the durable Claude UUID via `cli_index`. An untracked id yields two empty + /// key OR the durable Claude UUID via native runtime history. An untracked id yields two empty /// vecs. pub(crate) async fn snapshot_pending_overlay(&self, any_id: &str) -> (Vec, Vec) { let Some(map_key) = self.resolve_session_key(any_id).await else { @@ -5210,7 +4573,7 @@ impl FreshClaudeState { /// The Task 4 handler keys the ledger row by the CURRENT durable id and /// re-keys it old→new inside the adoption write batch, so the record always /// lives under the live durable id. `any_id` resolves like every handler - /// here (map key or durable UUID via `cli_index`); an UNTRACKED id (e.g. a + /// here (map key or durable UUID via native runtime history); an UNTRACKED id (e.g. a /// disk-only read after a server restart) falls back to the raw id so a /// rolled-back session keeps its marked bucket without a live session. pub(crate) async fn load_rollback_record( @@ -5222,15 +4585,10 @@ impl FreshClaudeState { .and_then(|s| s.load_rollback(PROVIDER, &durable)) } - /// Reconcile liveness probe (campaign §4.3, Task 13): resolve the DURABLE - /// claude UUID through [`Self::cli_index`] to its sessions-map key, then - /// check the map. The sessions map is read UNFILTERED — no session_type - /// filter, so kilroy sessions count for free (V2 N-A17-1). + /// Reconcile liveness follows current and prior native UUIDs for both + /// Freshclaude and Kilroy runtimes. pub async fn has_live_session(&self, session_id: &str) -> bool { - let Some(key) = self.cli_index.lock().await.get(session_id).cloned() else { - return false; - }; - self.sessions.lock().await.contains_key(&key) + self.resolve_session_key(session_id).await.is_some() } /// A runtime that disappears after a successful resolution is a transient @@ -5257,12 +4615,12 @@ impl FreshClaudeState { let key = self.resolve_session_key(native_id).await?; let sessions = self.sessions.lock().await; let session = sessions.get(&key)?; - let canonical = session.cli_session_id.as_deref().unwrap_or(native_id); + let canonical = session.identity.current(); crate::ownership_lane::current_local_snapshot_owner( &self.ownership, &self.ownership_stamps, PROVIDER, - canonical, + &canonical, &key, session.child.id(), ) @@ -5273,9 +4631,8 @@ impl FreshClaudeState { /// The pane re-key ride (kata 1wxv Task 4): the existing /// `freshAgent.session.materialized` broadcast shape (codex's mint-new respawn /// precedent), old client-facing id → new adopted durable id. - /// Unified agent names: the frame carries the durable record's current - /// projection — the pending→durable transfer commits in - /// [`Self::adopt_session_init`] BEFORE this publishes the identity. + /// The frame carries the durable naming projection after its identity + /// binding has been established. async fn materialized_message( &self, old_id: &str, @@ -5329,20 +4686,33 @@ impl FreshClaudeState { self.broadcast(&message); } - /// Resolve a client-addressed session id to the sessions-map key (Task 10b): the id - /// itself when tracked, else through [`Self::cli_index`] (durable UUID -> map key). - /// The map is never re-keyed (in-flight consumers hold the placeholder key); - /// `cli_index` IS the alias table, so every map consumer works under BOTH keys. - async fn resolve_session_key(&self, session_id: &str) -> Option { - if self.sessions.lock().await.contains_key(session_id) { + /// Native history belongs to one runtime and follows it through resets + /// and recovery. Direct current keys take precedence over older IDs. + fn session_key_in( + sessions: &HashMap, + session_id: &str, + ) -> Option { + if sessions.contains_key(session_id) { return Some(session_id.to_string()); } - let mapped = self.cli_index.lock().await.get(session_id).cloned()?; - self.sessions - .lock() - .await - .contains_key(&mapped) - .then_some(mapped) + sessions + .iter() + .find_map(|(key, runtime)| runtime.identity.contains(session_id).then(|| key.clone())) + } + + async fn resolve_session_key(&self, session_id: &str) -> Option { + { + let sessions = self.sessions.lock().await; + if let Some(key) = Self::session_key_in(&sessions, session_id) { + return Some(key); + } + } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, session_id))?; + let sessions = self.sessions.lock().await; + Self::session_key_in(&sessions, session_id) + .or_else(|| Self::session_key_in(&sessions, &durable)) } /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store @@ -5361,23 +4731,8 @@ impl FreshClaudeState { // at the registration tail below; every error return leaves it armed // for the caller's drop (RAII typed fail). own_ticket: &mut Option, - // b8ke focused episode-2 round-2 F2: the operation id of the - // lifecycle op that OWNS this runtime's coordinator transition — - // the attach's own claim, or the handoff runner's op for the - // under-ticket target resume. Recorded on the session so the - // session-init adoption's Blocked arm can correlate a blocking - // coordinator state with the runtime being adopted (only the - // runtime's OWN operation may proceed claim-free; an unrelated - // blocker fails closed). - owning_operation: Option, - // b8ke ext r27 F2: the GENERATION of the lifecycle op that owns - // this runtime's coordinator transition (the handoff runner's - // under-ticket target resume) — `owning_operation`'s numeric - // twin, recorded on the session beside it. The session-init - // adoption's durable binding write stamps the row with this - // generation when the adoption holds no own ticket (the - // under-ticket shape), so the target row carries the HANDOFF's - // generation instead of preserving the prior one. + // Under-ticket resumes bind recovery metadata at the handoff's + // generation before the runner commits the replacement runtime. handoff_generation: Option, ) -> Result<(), ResumeClaudeError> { let durable = claim.durable.as_str(); @@ -5483,14 +4838,12 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::AttachResume, spec, - register_under: RegisterUnder::Pane(msg.session_id.clone()), + native_id: msg.session_id.clone(), claim: Some(DurableClaim { durable: durable.to_string(), dead_state: claim_dead_state, }), - rekey_provisional_claim: false, recorded_settings: recovered, - owning_operation, owning_generation: handoff_generation, // Conn-less lane (D8): attach-resume is not a create; the ledger // merge keeps the row's existing stamps. @@ -5545,891 +4898,113 @@ impl FreshClaudeState { })); } - /// The sdk.session.init adoption block (the consumer's in-stream arm, and - /// a settings apply; a rollback or a fork never adopts through it, C13/C14: - /// its runtime registers already adopted): record the durable Claude UUID - /// in cli_index + the session record, then AWAIT-write the identity binding - /// row (V8/A11). A failed write is surfaced user-visibly, never - /// warn-and-drop, then the identity event proceeds. `provenance` (D8): the - /// creating connection's stamps, when this adoption flows from a - /// connection-scoped create; `None` on conn-less lanes — the ledger's - /// keep-when-None merge then keeps the prior stamps. - async fn adopt_session_init( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - ) -> SessionInitAdoptionOutcome { - self.adopt_session_init_inner( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - false, - ) - .await - } - - /// [`Self::adopt_session_init`] with a completion hook: `Deferred` - /// outcomes settle asynchronously, and the deferred task answers - /// `Some(cli_id)` (published) or `None` (abandoned) on the caller's - /// channel. Only tests observe the deferred adoption this way since the - /// rollback stopped adopting a pre-read init (C13). - #[cfg(test)] - #[allow(clippy::too_many_arguments)] // the adoption field set + the completion channel - async fn adopt_session_init_with_completion( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - on_settled: Option>>, - ) -> SessionInitAdoptionOutcome { - // The adoption can run before its session is registered — the - // registration gate must decide synchronously so the completion hook - // routes to the deferred task on `Deferred`. - let registered = self.sessions.lock().await.contains_key(session_id); - if !registered { - self.defer_session_init_adoption( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - on_settled, - ); - return SessionInitAdoptionOutcome::Deferred; - } - let outcome = self - .adopt_session_init_inner( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - false, - ) - .await; - if let Some(tx) = on_settled { - match outcome { - SessionInitAdoptionOutcome::Published => { - let _ = tx.send(Some(cli_id.to_string())); - } - _ => { - let _ = tx.send(None); - } - } - } - outcome - } + // ── stdout consumer (the completion edge normalization) ────────────────────────── - /// b8ke focused episode-2 round-1 F2: the pre-registration deferral. - /// `handle_create` starts the detached consumer BEFORE it inserts the - /// `ClaudeSession` into `self.sessions`, so the consumer's - /// `sdk.session.init` adoption can run while the runtime is NOT yet - /// registered/killable — committing a Live{FreshAgent} owner that a - /// handoff cannot stop (kill_for_handoff answers AlreadyGone, the - /// terminal target starts, and the create then inserts its still-live - /// sidecar with no later commit: two writers). The deferred task - /// waits (bounded) for the registration and re-runs the FULL - /// adoption — gate, claim, publish-or-teardown — once the session is - /// killable. A timeout means the create failed before registering - /// (its own failure path killed the child): nothing adopted, nothing - /// committed. - #[allow(clippy::too_many_arguments)] // the adoption field set + the completion channel - fn defer_session_init_adoption( + /// Consume the sidecar's stdout event stream (one `sdk.*` JSON per line), normalize + /// each `sdk.* → freshAgent.*` and broadcast it wrapped in a `freshAgent.event`. On EOF + /// (a clean end OR a mid-turn death) the loop stops — never a false completion — and + /// the dead session is evicted from both maps (identity-guarded, ledger A9). + /// `sidecar_session_id` is the id THIS consumer's sidecar is keyed by; it guards the + /// eviction so a stale consumer can never evict a newer session re-registered under + /// the same map key (attach-resume, Task 6). + /// + /// The shared current UUID follows native conversation resets. Attaching + /// another observer never changes it or repeats identity establishment. + /// `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). + /// `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. + fn spawn_consumer( &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - on_settled: Option>>, - ) { + mut reader: tokio::io::Lines>, + session_id: String, + ownership_id: String, + session_type: String, + sidecar_session_id: String, + identity: RuntimeIdentity, + pending: Arc>, + ledger: crate::claude_turns::SharedLedger, + quiet: deadman::SharedQuiet, + // 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, + ) -> tokio::task::JoinHandle<()> { + let broadcast_tx = self.broadcast_tx.clone(); + let sessions = self.sessions.clone(); let state = self.clone(); - let cli_id = cli_id.to_string(); - let session_id = session_id.to_string(); - let session_type = session_type.to_string(); - let settings = settings.cloned(); - let provenance = provenance.cloned(); tokio::spawn(async move { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if state.sessions.lock().await.contains_key(&session_id) { - let outcome = state - .adopt_session_init_inner( - &cli_id, - &session_id, - &session_type, - settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), - true, - ) + let mut session_id = session_id; + // The sidecar's report of its SDK stream's end (its last line before it + // exits), read by the end-of-output path below. + let mut stream_ended: Option = None; + while let Ok(Some(line)) = reader.next_line().await { + let trimmed = line.trim(); + if trimmed.is_empty() { + state + .note_claude_stdout(&session_id, &ownership_id, None) .await; - if let Some(tx) = on_settled { - let answer = match outcome { - SessionInitAdoptionOutcome::Published => Some(cli_id.clone()), - _ => None, - }; - let _ = tx.send(answer); - } - return; + continue; } - if tokio::time::Instant::now() >= deadline { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred_timeout: the \ - session never registered (the create failed and tore its child \ - down) — nothing adopted, nothing committed" - ); - if let Some(tx) = on_settled { - let _ = tx.send(None); + let Ok(value) = serde_json::from_str::(trimmed) else { + state + .note_claude_stdout(&session_id, &ownership_id, None) + .await; + continue; + }; + if let Some(ended) = crate::claude_sidecar_exit::StreamEnded::from_line(&value) { + state + .note_claude_stdout(&session_id, &ownership_id, Some(&value)) + .await; + stream_ended = Some(ended); + continue; + } + if value["type"] == "sdk.session.rekeyed" { + if let Some(new) = value["newSessionId"].as_str() { + match state + .rekey_conversation( + &session_id, + new, + &session_type, + &pending, + "conversation_reset", + ) + .await + { + Ok(new) => session_id = new, + Err(cause) => tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_refused", provider = PROVIDER, + session_id = %session_id, requested_session_id = new, cause, + "the conversation identity could not follow its reset"), + } } - return; + continue; } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - }); - } - - /// b8ke focused episode-2 round-1 F3: the refused-adoption teardown. - /// A refused canonical claim (a foreign owner or an in-flight - /// transition) must NEVER leave the freshly spawned runtime live and - /// unowned — the pre-fix path only logged and the sidecar kept - /// running while a competitor acquired the vacant key. Fail closed: - /// remove the registered session, run the standard typed teardown, - /// evict its placeholder aliases, and engage the client's - /// lost-session recovery. - async fn teardown_unadopted_session(&self, session_id: &str, session_type: &str) { - tracing::error!(target: "invariant", - placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_torn_down: the canonical key moved \ - on — the freshly spawned runtime is torn down, never left unowned" - ); - // DETACHED: the adoption's caller is usually the session's OWN - // consumer task, and the standard teardown aborts + joins the - // consumer — running it inline would abort the caller mid-teardown - // (the self-abort drops the teardown before the reap or the - // lost-session frame). The spawned task performs the whole - // fail-closed sequence; the consumer unwinds at its next await. - let state = self.clone(); - let session_id = session_id.to_string(); - let session_type = session_type.to_string(); - tokio::spawn(async move { - if let Some(session) = state.sessions.lock().await.remove(&session_id) { - // R2-2: the typed outcome is propagated; this unwind path - // holds no coordinator fence, so the helper's typed log + - // its detached escalation are the whole discipline here. - let _teardown: LoggedTeardown = - state.teardown_removed_session_logged(session).await; - } - state.evict_cli_index_aliases(&session_id).await; - let typed = match session_type.as_str() { - "kilroy" => SessionType::Kilroy, - _ => SessionType::Freshclaude, - }; - state.broadcast(&lost_session_frame(&session_id, typed)); - }); - } - - /// The adoption's alias publication (the mint-gated section): inserts - /// `cli_index[durable] = map key` and stamps the session's durable id. - /// Re-checks the kill's close gate under the SAME lock acquisition the - /// gate discipline requires — a kill that armed the gate between the - /// adoption's first read and this publication owns the session, so the - /// publication abandons (`false`). Returns whether the publication - /// landed. - /// - /// b8ke focused episode-2 round-2 F3: the publication ALSO revalidates - /// the session's INCARNATION — the session must still exist AND its - /// sidecar child must still be the exact runtime the adoption read at - /// phase 1 (`expect_pid`). An explicit kill that completed between the - /// read and here removed the session AND its close-pending entry, so - /// the gate check alone cannot see it; without this validation the - /// adoption would mint a dangling alias and commit `Live` over the - /// captured pid of a KILLED runtime (snapshots then reported the dead - /// runtime as the authoritative owner). Missing/incarnation-changed → - /// `false`: no alias, no commit — the kill owns the session. - async fn publish_session_init_alias( - &self, - cli_id: &str, - session_id: &str, - expect_pid: Option, - ) -> bool { - let mut index = self.cli_index.lock().await; - let mut sessions = self.sessions.lock().await; - let gated = { - let gates = self.close_pending.lock().expect("close-pending lock"); - gates.get(session_id).copied().unwrap_or(0) > 0 - }; - if gated { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, - session_id = %session_id, - "freshagent.claude.adoption_abandoned_by_kill_gate: the kill's one \\ - envelope already enumerated this session; the mint lands nowhere" - ); - return false; - } - // F3: the session must still exist with the SAME runtime incarnation - // the adoption observed — a completed kill (or a re-registered - // replacement) invalidates the adoption's captured identity. - match sessions.get_mut(session_id) { - None => { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.adoption_abandoned_session_gone: the session was \\ - removed (a completed kill) before the publication — no alias, no \\ - commit of the dead runtime's identity" - ); - return false; - } - Some(session) => { - if session.child.id() != expect_pid { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - expected_pid = ?expect_pid, - current_pid = ?session.child.id(), - "freshagent.claude.adoption_abandoned_incarnation_changed: the \\ - session's runtime was replaced since the adoption's read — no \\ - alias, no commit of a stale identity" - ); - return false; - } - session.cli_session_id = Some(cli_id.to_string()); - } - } - index.insert(cli_id.to_string(), session_id.to_string()); - true - } - - #[allow(clippy::too_many_arguments)] // the adoption field set (the deferral adds the re-entry flag) - async fn adopt_session_init_inner( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - deferred_reentry: bool, - ) -> SessionInitAdoptionOutcome { - // Phase 1 — the mint-gate READ scope (never publishes): the gate - // flag, the session's existing durable id (the in-stream remint - // test), the registration presence (the F2 pre-registration test), - // and the sidecar pid. - // b8ke ext r27 F2: the session's recorded handoff generation reads - // HERE — inside the pre-publication read scope — so the binding - // pair's derivation (below) adds NO await between the publication - // and the commit (a mid-flow sessions-lock await there widened - // the publication→commit window a concurrent stamp take could - // land inside). - let (gated, existing_cli_id, registered, sidecar_pid, owning_generation); - { - let _index = self.cli_index.lock().await; - let sessions = self.sessions.lock().await; - gated = { - let gates = self.close_pending.lock().expect("close-pending lock"); - gates.get(session_id).copied().unwrap_or(0) > 0 - }; - if gated { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, - session_id = %session_id, - "freshagent.claude.adoption_abandoned_by_kill_gate: the kill's one \ - envelope already enumerated this session; the mint lands nowhere" - ); - return SessionInitAdoptionOutcome::Abandoned; - } - existing_cli_id = sessions - .get(session_id) - .and_then(|s| s.cli_session_id.clone()); - registered = sessions.contains_key(session_id); - sidecar_pid = sessions.get(session_id).and_then(|s| s.child.id()); - owning_generation = sessions.get(session_id).and_then(|s| s.owning_generation); - } - // b8ke focused episode-2 round-2 F3 test seam: the deterministic - // kill-between-read-and-publication hold (never armed in - // production). e2r3 F10: `reached` flips BEFORE the park, so the - // test can prove the adoption reached this exact point before - // releasing — never a vacuous pass. - #[cfg(test)] - if let Some(pause) = &self.adoption_pause { - pause - .reached - .store(true, std::sync::atomic::Ordering::SeqCst); - let _ = pause.notify.notified().await; - } - // b8ke focused episode-2 round-1 F4: the in-stream REMINT gate. An - // init that names a durable id other than the one the session - // already carries (the CLI moved the conversation to a new id) - // must not adopt ownership under the new id: the runtime is owned - // and committed under the id it already carries, and a second - // Live{FreshAgent} record + stamp beside that key would leave - // contradictory snapshots. Such a re-key publishes the bookkeeping - // ONLY — ownership stays where it was committed. - let fork_rekey = existing_cli_id - .as_deref() - .is_some_and(|existing| existing != cli_id); - if !registered { - // b8ke focused episode-2 round-1 F2: the pre-registration - // window must not expose a Live owner that cannot be killed — - // defer the whole adoption (claim + publication) until the - // create registered the runtime. A deferred re-entry that - // STILL finds the session absent gives up (the create died; - // its failure path killed the child). - if deferred_reentry { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred_unregistered: \ - the session vanished before the deferred adoption — nothing \ - published, nothing committed" - ); - return SessionInitAdoptionOutcome::Abandoned; - } - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred: the session is not \ - yet registered (the create's insert window) — the adoption waits for \ - the registration before claiming or publishing" - ); - self.defer_session_init_adoption( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - None, - ); - return SessionInitAdoptionOutcome::Deferred; - } - // b8ke focused episode-2 round-1 F3: CLAIM BEFORE PUBLISHING. The - // pre-fix order minted the alias + the session's durable field - // BEFORE the coordinator claim, so a REFUSED claim left the - // freshly spawned sidecar running unowned under a published - // identity while a competitor acquired the key. The publication - // below happens only after the claim decision; a refused claim - // tears the runtime down instead (fail closed). - let mut adoption_ticket = None; - if !fork_rekey { - adoption_ticket = match self.begin_lane_claim_at( - cli_id, - &format!("claude-init-adopt-{}", uuid::Uuid::new_v4()), - None, - // b8ke focused episode-2 round-1 F7: the provenance-derived - // initiator (the device id), never a fixed lane string. - &Self::initiator_for(provenance, "claude/session-init-adoption"), - ) { - // Granted: the canonical key is vacant (a fresh create's - // first init) — THIS adoption claims it. - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - // Unwired (no coordinator): the legacy behavior. - crate::ownership_lane::LaneClaim::Unwired => None, - // Adopt: the canonical key is ALREADY Live{FreshAgent} — a - // resume/attach committed at create time; the record is - // already authoritative. - crate::ownership_lane::LaneClaim::Adopt => None, - // Refused: the canonical key moved on (a foreign owner or - // an in-flight transition). NEVER steal — and NEVER leave - // the freshly spawned runtime live and unowned: tear it - // down and engage the client's lost-session recovery, - // publishing nothing. - crate::ownership_lane::LaneClaim::Refused(outcome) => { - match &outcome { - // A BLOCKED coordinator state: correlate the - // blocking operation with the runtime being - // adopted (b8ke focused episode-2 round-2 F2). - // ONLY the runtime's OWN operation — recorded on - // the session as its `owning_operation` (the - // create-resume claim, the attach's claim, or the - // handoff runner's under-ticket target op) — may - // proceed: that operation owns the key's - // resolution and will perform the ONE commit, so - // the adoption claims nothing, tears nothing - // down, and the publication keeps the alias - // bookkeeping consistent with its commit. - // An UNRELATED blocker (another device's handoff - // on the still-vacant canonical key, a foreign - // start, a fenced/stop transition that does not - // name this runtime's owner) fails CLOSED: the - // freshly spawned runtime is torn down — never - // left running unowned while the other operation - // believes there was no prior owner. - freshell_ownership::BeginOutcome::Blocked { state, .. } => { - let blocking_op = state.operation_id().map(str::to_string); - let (own_op, own_live_key) = { - let sessions = self.sessions.lock().await; - match sessions.get(session_id) { - Some(session) => ( - session.owning_operation.clone(), - Some(session.sidecar_session_id.clone()), - ), - None => (None, None), - } - }; - // The handoff stopping THIS runtime as its - // PRIOR is also "our" transition: the runner's - // Handoff state captures this session's runtime - // identity as the prior it is stopping — the - // deferred adoption (a create whose bookkeeping - // landed mid-handoff) must neither claim nor - // tear down; the handoff's kill_for_handoff is - // the one stopping this session, and the - // runner resolves the key. - let handoff_stops_this_runtime = matches!( - &state, - freshell_ownership::OwnershipState::Handoff { - prior: Some((prior_owner, _)), - .. - } if own_live_key.is_some() - && prior_owner.live_session_key == own_live_key - ); - if (blocking_op.is_some() && blocking_op == own_op) - || handoff_stops_this_runtime - { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - operation_id = ?blocking_op, - "freshagent.claude.init_ownership_adoption_skipped_own_operation: \\ - the runtime's OWN lifecycle operation owns this key — the \\ - adoption claims nothing, the owning operation resolves it" - ); - None - } else { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - owning_operation = ?own_op, - "freshagent.claude.init_ownership_adoption_blocked_foreign: an \\ - UNRELATED operation owns this key — the freshly spawned \\ - runtime is torn down (fail closed), nothing published" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - // A genuinely FOREIGN owner (another kind holds - // the key): fail closed — the freshly spawned - // runtime is torn down, never left live and - // unowned beside the foreign owner. - _ => { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - "freshagent.claude.init_ownership_adoption_refused: the \\ - canonical key is held by a foreign owner — the freshly \\ - spawned runtime is torn down (fail closed), nothing published" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - } - }; - } - // b8ke ext r22 F2: the binding row's fence pair — the pre-commit - // ticket's generation (the delayed-write fence baseline the durable - // row carries below). `None` when no ticket was claimed (the Adopt - // arms — the record is already authoritative) or the coordinator - // is unwired. - // b8ke ext r27 F2: the UNDER-TICKET handoff continuation holds NO - // own ticket — the session's recorded HANDOFF generation (the - // runner's supplied pair, read in the phase-1 scope above) stamps - // the row instead, so the target's durable row carries the - // handoff's generation. - let (binding_epoch, binding_generation) = ( - self.ownership.as_ref().map(|r| r.boot_epoch()), - adoption_ticket - .as_ref() - .map(|t| t.generation()) - .or(owning_generation), - ); - // The (re-gated) publication: abandons when the kill gate armed - // between the read scope and here — the kill owns the session. - if !self - .publish_session_init_alias(cli_id, session_id, sidecar_pid) - .await - { - return SessionInitAdoptionOutcome::Abandoned; - } - // b8ke ext r27 F4: the durable binding write runs BEFORE the - // commit-Live, inside the SAME coordinator operation (the claim - // held above). Pre-r27 the adoption committed Live FIRST and then - // wrote the binding, so once the commit released the coordinator - // operation a handoff or stop could begin while the binding write - // was blocked, and a later failure tore the runtime down only - // after the owner was already authoritative. The write stays - // AFTER the alias publication deliberately: the publication is - // what makes the durable id resolvable to a concurrent kill (the - // tombstone fold + the sink's orphan-write suppression key off - // it), so a kill landing while the binding write is in flight - // still fences the released write. The row is also UNCONDITIONAL - // now: an all-blank settings snapshot records a LINEAGE-only row - // (blank settings verbatim) — pre-r27 default-settings sessions - // skipped the binding entirely; the sink's was_recorded/ - // load_settings keying (Task 3) keeps a legitimately-default - // session from ever arming a false SETTINGS_RESET on a later - // resume (the codex REST lane's unconditional-lineage discipline). - // Unified agent names (Task 2): the init hook runs EVEN for an - // all-blank settings row — the naming bind is driven by verified - // transcript durability, not by the settings row (their r27-F4 - // write below is unconditional for exactly the same reason). The - // stashed pre-durable handle (keyed by this placeholder) binds to - // the durable UUID exactly once (idempotent in the store); a - // zero-turn init whose transcript has not materialized yet retains - // the handle and records only the acquired location (InitialRecovery - // semantics — the name authority never moves on unverified - // evidence). Deliberately NOT - // a lane blocker: a naming failure never fails the identity event - // (the handle is retained; the main.rs naming tick retries the - // transition visibly), and the bind targets the DURABLE session — - // its verified transcript outlives even this runtime's teardown. - let declared_transition = self - .bind_naming_handle_at_init(cli_id, session_id, session_type) - .await; - if let Some(sink) = &identity_sink { - if let Err(e) = sink - .record_binding(crate::identity_sink::FreshAgentBindingUpsert { - genesis: crate::identity_sink::GenesisUpdate::Keep, - provider: PROVIDER.into(), - session_id: cli_id.to_string(), - mode: session_type.to_string(), - create_request_id: None, - resolves_pending: None, - supersedes: None, - // Unified agent names (Task 4, T2-M5 wired): the init lane's - // DECLARED classification — the same transition the direct - // naming fold above consumed (InitialMaterialization), so - // the identity event carries the naming fact alongside the - // ledger write. - name_transition: declared_transition, - provenance: provenance.cloned().into(), - observed_epoch: binding_epoch, - observed_generation: binding_generation, - // b8ke focused ep5 r2 F1: the UNDER-TICKET shape (no own - // adoption ticket; the session's recorded HANDOFF - // generation supplied the pair) is the handoff runner's - // OWN authoritative target binding — the ordinary - // create's own-ticket write stays a lane write. - authoritative: adoption_ticket.is_none() && owning_generation.is_some(), - settings: settings.cloned().unwrap_or_default(), - }) - .await - { - // b8ke ext r27 F4: the binding failure PROPAGATES — the - // freshly adopted runtime is torn down and the adoption is - // ABANDONED BEFORE the publish and the commit (the ticket's - // RAII drop settles the claim typed; the key never goes - // Live), never a bare LEDGER_WRITE_FAILED notification and - // never a committed Live with no recoverable registration. - tracing::error!(target: "invariant", - error = %e, session = %cli_id, - "freshagent.claude.binding_write_failed_typed: the durable row write \ - failed — the freshly adopted runtime is torn down (kata b8ke ext r27 F4)" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - // b8ke delta round-2 F1: the canonical-key adoption commit — for - // the ordinary-create path (no fork re-key), a Granted claim - // commits the authoritative Live{FreshAgent} ownership under the - // durable id and broadcasts the owner record so every device - // converges. - if let Some(ticket) = adoption_ticket.as_ref() { - let generation = ticket.generation(); - let operation_id = ticket.operation_id().to_string(); - // b8ke focused episode-2 post-cap F3 test seam: the pre-commit - // park (never armed in production). - #[cfg(test)] - if let Some(pause) = &self.adoption_commit_pause { - pause - .reached - .store(true, std::sync::atomic::Ordering::SeqCst); - let _ = pause.notify.notified().await; - } - match self.commit_lane_claim_at(&mut adoption_ticket, session_id, sidecar_pid) { - Ok(()) => { - // Broadcast the authoritative owner record: every device - // holding the durable sessionRef converges on the - // committed Live{FreshAgent} owner (the ready replay - // covers reconnects; the broadcast covers the live ones). - self.broadcast(&ServerMessage::SessionRuntimeOwner(SessionRuntimeOwner { - provider: PROVIDER.to_string(), - session_id: cli_id.to_string(), - epoch: self.ownership.as_ref().map(|r| r.boot_epoch()).unwrap_or(0), - generation, - owner_kind: "fresh-agent".into(), - previous_kind: None, - terminal_id: None, - operation_id, - transition: "handoff-committed".into(), - reason: None, - fenced: None, - alias_of: None, - })); - } - Err(outcome) => { - // Stale/foreign commit: the coordinator moved on between - // the claim and the commit. Fail closed exactly like the - // refused claim — the freshly spawned runtime is never - // left live and unowned (the ticket's Drop performs the - // typed fail on the claimed key). - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - "freshagent.claude.init_ownership_adoption_commit_stale: the \ - coordinator moved on while the adoption committed — the freshly \ - spawned runtime is torn down (fail closed)" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - // b8ke focused episode-2 post-cap F3: the runtime IS - // torn down — the adoption is ABANDONED, HERE. The - // pre-fix fall-through persisted the alias - // tombstone/binding and reported Published for a - // runtime that no longer exists. - return SessionInitAdoptionOutcome::Abandoned; - } - } - } - // Round 6 (focused-ep5-r5 Finding 2): persist the alias AT MINT. The - // finding's window needs the mapping durably recorded before ANY - // demotion — a server restart kills the consumers (no exit-eviction - // ever demotes), so mint-time persistence is the ONLY write that - // reaches the post-restart kill naming this pane's bare placeholder. - // Best-effort: warn-loud on failure, never a lane blocker (the - // in-memory store still answers this process's kills). - if let Some(sink) = identity_sink.as_ref().filter(|_| session_id != cli_id) { - if let Err(e) = sink - .record_alias_tombstone( - PROVIDER, - session_id, - cli_id, - crate::session_lease::now_epoch_ms() as i64, - ) - .await - { - tracing::warn!(error = %e, session = %cli_id, placeholder = %session_id, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - SessionInitAdoptionOutcome::Published - } - - /// Unified agent names (Task 2): the `sdk.session.init` naming hook — - /// see [`Self::adopt_session_init`]. Deliberately NOT a lane blocker: a - /// naming failure never fails the identity event (the handle is - /// retained; the main.rs naming tick retries the transition visibly). - async fn bind_naming_handle_at_init( - &self, - cli_id: &str, - placeholder: &str, - session_type: &str, - ) -> Option { - if session_type == "kilroy" { - return None; - } - let handle = self.naming_handles.lock().await.get(placeholder).cloned()?; - // Existing provider durability evidence: the claude CLI writes the - // session transcript at startup, so a located transcript file - // verifies the durable identity (`claude_snapshot::locate_transcript_selected` - // walks the same ordered candidate roots the snapshot lane reads and - // answers the selected root + original cwd the store retains). - let selected = crate::claude_snapshot::locate_transcript_selected(cli_id); - let (persistence, evidence) = if selected.is_some() { - ( - freshell_protocol::native_location::NativePersistence::Verified, - freshell_protocol::native_location::NativeEvidenceKind::SelectedTranscript, - ) - } else { - ( - freshell_protocol::native_location::NativePersistence::Prospective, - freshell_protocol::native_location::NativeEvidenceKind::InitializedRuntime, - ) - }; - let acquisition = freshell_protocol::native_location::NativeAcquisition { - location: freshell_protocol::native_location::NativeLocation::Claude { - config_root: selected - .as_ref() - .map(|s| s.config_root.display().to_string()) - .or_else(|| { - crate::claude_snapshot::claude_home_candidates() - .first() - .map(|root| root.display().to_string()) - }) - .unwrap_or_default(), - transcript_path: selected - .as_ref() - .map(|s| s.transcript_path.display().to_string()), - project_directory_key: None, - transcript_cwd: selected.as_ref().and_then(|s| s.transcript_cwd.clone()), - effective_project_key_override: None, - }, - evidence, - persistence, - }; - let target = freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: cli_id.to_string(), - }; - // Transition classification: an init is the initial - // materialization (a rollback's or a fork's runtime registers - // already adopted and never reaches here, C13/C14). The shared - // classification-driven fold (plan Task 4) applies the policy: - // verified persistence binds the handle, prospective evidence - // RETAINS it (zero-turn rule — only the routing evidence moves). - let reason = crate::naming::NameTransitionReason::InitialMaterialization; - let transition = crate::naming::NameTransition { - reason, - acquisition, - pending: Some(handle), - }; - let pending_ref = freshell_protocol::session_names::SessionNameRef::Pending { - id: transition.pending.clone().unwrap_or_default(), - }; - match crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) - .await - { - Some(update) => { - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %cli_id, - reason = ?reason, - "freshagent.claude.naming_transition_committed" - ); - // Bound: the stash entry is consumed once the handle is - // redirected to the durable record. - if update.record.name_ref == target { - self.naming_handles.lock().await.remove(placeholder); - } - } - None => { - // Retained (prospective zero-turn), unwired, or a failed - // bind: the stash survives for the tick's visible retry. - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %cli_id, - reason = ?reason, - "freshagent.claude.naming_transition_retained" - ); - let _ = pending_ref; - } - } - Some(transition) - } - - // ── stdout consumer (the completion edge normalization) ────────────────────────── - - /// Consume the sidecar's stdout event stream (one `sdk.*` JSON per line), normalize - /// each `sdk.* → freshAgent.*` and broadcast it wrapped in a `freshAgent.event`. On EOF - /// (a clean end OR a mid-turn death) the loop stops — never a false completion — and - /// the dead session is evicted from both maps (identity-guarded, ledger A9). - /// `sidecar_session_id` is the id THIS consumer's sidecar is keyed by; it guards the - /// eviction so a stale consumer can never evict a newer session re-registered under - /// the same map key (attach-resume, Task 6). - /// - /// `settings` (P1.13): `Some` = record a fresh-agent binding row at - /// `sdk.session.init` (create path / resume-with-record); `None` = do NOT record - /// (resume of a never-recorded session — writing would launder a blank row under - /// the new cliSessionId, V7/A10). The row's `mode` is the `session_type` param - /// (the `"freshclaude"` vs `"kilroy"` flavour; provider is always [`PROVIDER`]). - /// `broadcast_id` (Task 10b): the shared envelope-stamp handle read PER EVENT -- - /// starts as the map key; an attach-by-durable rebind flips it to the durable id. - /// `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). - /// `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, - mut reader: tokio::io::Lines>, - session_id: String, - ownership_id: String, - session_type: String, - sidecar_session_id: String, - settings: Option, - broadcast_id: Arc>, - pending: Arc>, - ledger: crate::claude_turns::SharedLedger, - quiet: deadman::SharedQuiet, - // 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 - // stamps. - provenance: Option, - create_binding_expected: bool, - ) -> tokio::task::JoinHandle<()> { - let broadcast_tx = self.broadcast_tx.clone(); - let sessions = self.sessions.clone(); - let identity_sink = self.identity_sink(); - let state = self.clone(); - tokio::spawn(async move { - // The sidecar's report of its SDK stream's end (its last line before it - // exits), read by the end-of-output path below. - let mut stream_ended: Option = None; - while let Ok(Some(line)) = reader.next_line().await { - let trimmed = line.trim(); - if trimmed.is_empty() { - state - .note_claude_stdout(&session_id, &ownership_id, None) - .await; - continue; - } - let Ok(value) = serde_json::from_str::(trimmed) else { - state - .note_claude_stdout(&session_id, &ownership_id, None) - .await; - continue; - }; - if let Some(ended) = crate::claude_sidecar_exit::StreamEnded::from_line(&value) { - state - .note_claude_stdout(&session_id, &ownership_id, Some(&value)) - .await; - stream_ended = Some(ended); - continue; - } - // 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); - if !is_pending_frame(&value) && value["type"] != "sdk.send.rejected" { - state - .note_claude_stdout(&session_id, &ownership_id, Some(&value)) - .await; + // 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, &identity, &session_type); + if !is_pending_frame(&value) && value["type"] != "sdk.send.rejected" { + state + .note_claude_stdout(&session_id, &ownership_id, Some(&value)) + .await; } // 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(); + let stamp = identity.current(); state .reissue_stop( &session_id, @@ -6444,7 +5019,7 @@ impl FreshClaudeState { stop_id, request_id, } => { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state.report_stop_refused( &stamp, &session_type, @@ -6464,7 +5039,7 @@ impl FreshClaudeState { // 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(); + let stamp = identity.current(); match fact { crate::claude_turns::SidecarFact::SendRejected(rejection) => state .take_back_rejected_send(&ledger, &stamp, &session_type, &rejection), @@ -6503,28 +5078,28 @@ impl FreshClaudeState { // boots the conversation in the mode the CLI is in. Some("sdk.session.metadata") => { if let Some(mode) = value.get("permissionMode").and_then(Value::as_str) { - let adopted = - sessions.lock().await.get_mut(&session_id).map(|session| { + let changed = sessions + .lock() + .await + .get_mut(&session_id) + .filter(|session| session.ownership_id == ownership_id) + .map(|session| { session.configuration.settings.permission_mode = Some(mode.to_string()); - ( - session.cli_session_id.clone(), - session.configuration.settings.clone(), - ) - }); - if let Some((Some(durable), settings)) = adopted { - state - .adopt_session_init( - &durable, - &session_id, + }) + .is_some(); + if changed { + if let Err(message) = + state.persist_runtime_settings(&ownership_id).await + { + let stamp = identity.current(); + state.emit_fresh_agent_error( + &stamp, &session_type, - Some(&settings), - identity_sink.clone(), - // Conn-less, like a settings apply: the - // ledger merge keeps the row's stamps. - None, - ) - .await; + "SETTINGS_PERSIST_FAILED", + &message, + ); + } } } } @@ -6603,55 +5178,55 @@ impl FreshClaudeState { match &step { LedgerStep::Settled(settled) => value["sessionBusy"] = json!(settled.busy), _ => { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state.publish_claude_status(&ledger, &quiet, &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 - // load-bearing; the session-field copy is best-effort — the map - // entry may not exist yet during create). + // Establishment already bound the native identity. Every + // consumer verifies init, including a resumed conversation. if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { if let Some(cli_id) = value.get("cliSessionId").and_then(Value::as_str) { - let (current_settings, known_identity) = { + let known_identity = { let mut guard = sessions.lock().await; guard .get_mut(&session_id) + .filter(|session| Arc::ptr_eq(&session.pending, &pending)) .map(|session| { session.configuration.runtime_cwd = value["cwd"].as_str().map(str::to_string); - ( - Some(session.configuration.settings.clone()), - session.cli_session_id.as_deref() == Some(cli_id), - ) + session.identity.current() == cli_id }) - .unwrap_or_else(|| (settings.clone(), false)) + .unwrap_or(false) }; - if known_identity && create_binding_expected { - // Create/establish already bound this exact durable - // identity. Init verifies it; no second adoption or - // lineage rewrite is needed. A retained naming bind - // may still retry once native persistence appears. - state - .bind_naming_handle_at_init(cli_id, &session_id, &session_type) - .await; - } else { - state - .adopt_session_init( - cli_id, + if !known_identity { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.unannounced_rekey", provider = PROVIDER, + session_id = %session_id, requested_session_id = cli_id, + "the CLI changed conversation without a reset announcement"); + match state + .rekey_conversation( &session_id, + cli_id, &session_type, - current_settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), + &pending, + "unannounced", ) - .await; + .await + { + Ok(new) => session_id = new, + Err(cause) => { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_refused", provider = PROVIDER, + session_id = %session_id, requested_session_id = cli_id, cause, + "the unannounced conversation identity could not be followed") + } + } } } } // 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 stamp = identity.current(); crate::claude_live_events::log_provider_event(&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) @@ -6692,7 +5267,7 @@ impl FreshClaudeState { // 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 + // session, identity-guarded: a newer session // 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. @@ -6703,7 +5278,7 @@ impl FreshClaudeState { 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 stamp = s.identity.current(); let removed = map.remove(&session_id); // The old runtime has no actionable pending cards. Clear // waiting attention before any teardown await or new @@ -6716,15 +5291,9 @@ impl FreshClaudeState { } }; if let Some(evicted) = evicted { - // Focused-ep5-r1 Finding 1: the aliases are DEMOTED to the - // retained alias tombstones, never dropped — the client closes - // a crashed pane by its ORIGINAL bare placeholder, and a kill - // arriving after this eviction must still resolve the durable - // id the ledger row is keyed on. The tombstone answers that - // resolution for the RETIRE write only; it never feeds live - // resolution (the session is dead — sends/attaches keep - // missing it exactly as before). - let removed_durables = state.evict_cli_index_aliases(&session_id).await; + // Native history is retained by the death record and copied + // into a replacement process when this conversation resumes. + let removed_durables = evicted.identity.ids(); // Task 12: a dead BOUND session must reopen its durable id, or the // sessionRef stays adopt-only forever. for durable in &removed_durables { @@ -6742,7 +5311,7 @@ impl FreshClaudeState { "freshclaude/consumer-exit", ); } - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); // G05 (C09): the ended session stays known as ended — its ids // resolve to a dead record, not to nothing. It is recorded // before the stop on purpose: a resume (an attach, which is a @@ -6767,7 +5336,6 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session_type, durables: &removed_durables, - cli_session_id: evicted.cli_session_id.as_deref(), settings: &evicted.configuration.settings, ownership_id: &evicted.ownership_id, launch_genesis: evicted.launch_genesis.as_ref(), @@ -7103,14 +5671,6 @@ fn frame_request_id(value: &Value) -> Option { .or_else(|| value.as_i64().map(|n| n.to_string())) } -/// The coordinator key a claude session is owned under -/// ([`FreshClaudeState::claude_owner_key`]). -struct ClaudeOwnerKey { - key: String, - /// The key is the durable id the CLI reported (else the pane's wire id). - reported: bool, -} - // ── sdk.* → freshAgent.event frame (port of sdk-events.ts normalizeFreshAgentProviderEvent) ─ /// Map an `sdk.*` event line from the sidecar to a `freshAgent.event` wire frame. Renames @@ -7961,7 +6521,7 @@ fn log_claude_teardown( } } -mod create_binding; +mod binding; mod create_outcome; mod deadman; mod recovery; @@ -7977,6 +6537,9 @@ mod fork; mod genesis; mod history; mod pending_runtime; +mod rekey; +mod restart_notice; +mod runtime_identity; use pending_runtime::{is_pending_frame, PendingResponse}; mod rollback; mod sidecar_writer; @@ -7984,7 +6547,6 @@ mod stop; mod turn_status; use establish::{ BootRetry, DurableClaim, EstablishLane, EstablishPlan, InheritedRuntime, LaunchLease, - RegisterUnder, }; #[cfg(test)] @@ -8027,13 +6589,16 @@ pub(crate) mod tests { use super::*; use crate::naming::SessionNaming as _; + mod abandoned_create_tests; mod control_tests; mod create_binding_tests; mod create_cleanup_tests; mod deadman_tests; + mod real_fixture_tests; mod recovery_stop_tests; mod recovery_tests; mod refusal_tests; + mod settings_persistence_tests; fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); @@ -8109,10 +6674,9 @@ pub(crate) mod tests { ownership_id: format!("test-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, + owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), @@ -8219,28 +6783,18 @@ pub(crate) mod tests { let _ = grandchild.wait().await; } - /// Task 3: insert a fake session AND stage its pending set by folding raw `sdk.*` - /// lines through the PRODUCTION fold ([`fold_pending_frame`]) — the snapshot.rs - /// route tests address the overlay this way. `durable` (when `Some`) registers the - /// `cli_index` alias so the route's durable-id resolution (`resolve_session_key`) - /// finds the session, exactly as a live post-init session is reachable. + /// Stage native live pending controls through the production fold. pub(crate) async fn insert_fake_claude_session_with_pending( st: &FreshClaudeState, - map_key: &str, + native_id: &str, durable: Option<&str>, frames: &[Value], ) { - insert_fake_claude_session(st, map_key).await; if let Some(durable) = durable { - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); + assert_eq!(native_id, durable); } - let pending = { - let sessions = st.sessions.lock().await; - Arc::clone(&sessions[map_key].pending) - }; + insert_fake_claude_session(st, native_id).await; + let pending = st.sessions.lock().await[native_id].pending.clone(); for frame in frames { fold_pending_frame(&pending, frame); } @@ -8484,7 +7038,7 @@ pub(crate) mod tests { // A legacy pane now observes the canonical runtime. assert!(st.sessions.lock().await.contains_key(durable)); assert_eq!( - st.cli_index.lock().await.get(durable), + st.resolve_session_key(durable).await.as_ref(), Some(&durable.to_string()) ); // The fake received the resume boot (spawn log now records the create request). @@ -8583,161 +7137,118 @@ pub(crate) mod tests { .get(durable) .expect("the canonical runtime stays registered"); assert_eq!( - session.broadcast_id.lock().unwrap().as_str(), + session.identity.current().as_str(), durable, "attaching a page preserves the runtime's canonical envelope" ); } - /// The rebind arm's ack must announce the session's REAL tracked status (the - /// stdout consumer's status fold), not a hardcoded `"idle"`: attaching to a - /// live session mid-compaction must keep the pane truthfully busy instead of - /// flipping it to idle, and a completion that landed in the reconnect dead - /// window must be told truthfully by the ack that rescues the pane. The - /// arrangement drives a REAL wire value (`compacting` — one of the two - /// statuses the production sidecar actually announces, index.mjs:151-153) - /// through the REAL consumer fold (the fake sidecar's `__set_status__` - /// hook), and the fold is observed on the WIRE (the `freshAgent.status` - /// broadcast) before the attach, so the assertion races nothing. + /// An attach answers with the current native id and the runtime's actual compacting status. #[tokio::test] async fn attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-rebind-status"), None) - .await; - let created = await_claude_created(&mut rx, "req-rebind-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // Alias a durable id at it (live: the consumer's sdk.session.init fold writes - // this row; here the test writes it directly). - let durable = "9c9c9c9c-9c9c-49c9-8c9c-9c9c9c9c9c9c"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); + let _env = FakeClaudeSidecarEnv::install(); - // Drive sdk.status { status: "compacting" } through the REAL stdout consumer - // fold, and wait until its broadcast is observable — the fold (which the ack - // reads) lands before that broadcast, so the attach below sees "compacting". - st.handle_send(send_msg(&session_id, "__set_status__:compacting")) - .await; + let (st, mut rx) = state_with_bus(); + st.handle_create( + dedup_create_msg("attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "/compact HANG")).await; await_status_on_wire(&mut rx, "compacting").await; - - // The reconnect rescue: attach addressing the durable id → the REBIND arm. - st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "compacting", "{frame}"); assert_eq!( - frame["event"]["status"], "compacting", - "the rebind ack must speak the session's tracked status, not a hardcoded idle: {frame}" + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); + st.shutdown().await; } - /// A mid-turn `compacting` must NOT wedge the tracked status for the rest of - /// 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 - /// the REAL consumer fold (fake-sidecar hooks) in the REAL value space, and - /// each fold is observed on the WIRE before the attach, so the assertion - /// races nothing. + /// An attach answers with the current native id and the runtime's actual idle status. #[tokio::test] async fn attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"warm history"}}])); let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-settle-status"), None) + st.handle_create( + dedup_create_msg("attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "warm history")).await; + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + st.handle_send(send_msg(¤t, "/compact")).await; + await_status_on_wire(&mut rx, "compacting").await; + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let created = await_claude_created(&mut rx, "req-settle-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "idle", "{frame}"); + assert_eq!( + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) + ); + st.shutdown().await; + } - // Alias a durable id at it (as above: live the init fold writes this row). - let durable = "8b8b8b8b-8b8b-48b8-8b8b-8b8b8b8b8b8b"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); - - // The wedge repro: a turn compacts mid-flight → tracked status "compacting". - st.handle_send(send_msg(&session_id, "__set_status__:compacting")) - .await; - await_status_on_wire(&mut rx, "compacting").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", - // never the pre-completion "compacting". - st.attach_for_test(attach_msg_with_resume("late-attacher-settled", durable)) - .await; - - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); - assert_eq!( - frame["event"]["status"], "idle", - "a completed turn must settle the tracked status (no sticky compacting): {frame}" - ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); - } - - /// 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 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. + /// An attach answers with the current native id and the runtime's actual running status. #[tokio::test] async fn attach_rebind_ack_speaks_running_while_a_turn_is_in_flight() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"still running"}}, {"kind":"hang"}])); let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-running-status"), None) - .await; - let created = await_claude_created(&mut rx, "req-running-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // Alias a durable id at it (as above: live the init fold writes this row). - let durable = "7a7a7a7a-7a7a-47a7-8a7a-7a7a7a7a7a7a"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); - - // A turn starts: sdk.assistant is the running edge. Observing its - // broadcast proves the running fold has landed. - st.handle_send(send_msg(&session_id, "__emit_assistant__")) - .await; - let _ = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; - - // The reconnect rescue mid-turn: the ack must speak "running". - st.attach_for_test(attach_msg_with_resume("late-attacher-midturn", durable)) + st.handle_create( + dedup_create_msg("attach_rebind_ack_speaks_running_while_a_turn_is_in_flight"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_speaks_running_while_a_turn_is_in_flight", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "show running")).await; + await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "running", "{frame}"); assert_eq!( - frame["event"]["status"], "running", - "the rebind ack must speak \"running\" while a turn is in flight: {frame}" + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); + st.shutdown().await; } /// Pins the DEFAULT: a freshly resumed session has folded no non-idle status, so @@ -8984,18 +7495,15 @@ pub(crate) mod tests { let (log_events, _log_guard) = crate::claude_sidecar_io::test_capture::capture(); let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"text", "data":{"text":"a partial answer before the crash"}}, + {"kind":"hang"}, + ])); let (st, mut rx) = state_with_bus(); - // 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 - // `at`. The `__exit__`/`__emit_assistant__` writes go through - // `inject_raw_send` (NOT handle_send) so no phantom second op arms — - // the armed state under test is exactly the one plain send. + // One accepted command produces an actual assistant block, then its + // model request stays pending. Kill the owned Node process at that + // boundary: stdout ends without sidecar finally or an SDK result. async fn death_cycle( st: &FreshClaudeState, rx: &mut tokio::sync::broadcast::Receiver, @@ -9007,8 +7515,15 @@ pub(crate) mod tests { st.handle_send(send_msg(&sid, "a turn that dies mid-flight")) .await; await_ledger_busy(st, &sid, true).await; - inject_raw_send(st, &sid, "__emit_assistant__").await; - inject_raw_send(st, &sid, "__exit__").await; + await_frame_of_inner_type(rx, "freshAgent.assistant").await; + st.sessions + .lock() + .await + .get_mut(&sid) + .expect("the running session") + .child + .start_kill() + .expect("kill only this test's sidecar"); // 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: a blocking recv could @@ -9291,291 +7806,9 @@ pub(crate) mod tests { // per-file locks would NOT serialize against each other. pub(crate) static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - /// A minimal scripted fake claude sidecar (no real `@anthropic-ai/claude-agent-sdk`, - /// no network, no cost): on `{"type":"create",...}` it appends a marker line to - /// `FRESHELL_TEST_CLAUDE_SPAWN_LOG` (so tests can count spawns without a global - /// tracing subscriber) and replies with a fresh `{"type":"created","sessionId":...}`; - /// on `{"type":"interrupt",sessionId}` it appends `sessionId` to - /// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` (the observable proxy for "the sidecar's - /// `query.interrupt()` was actually invoked", mirroring the real sidecar's - /// `handleInterrupt`); on `{"type":"shutdown"}` it exits. Task 2 arms: a magic send - /// of `__raise_permission__` emits a canned `sdk.permission.request` (the canUseTool - /// stand-in — the fake parks nothing), a magic send of `__set_status__:` - /// emits a real `sdk.status` frame (the attach-ack status-arrangement hook), - /// `__emit_assistant__` emits a real `sdk.assistant` frame (a turn's RUNNING edge) - /// and `__emit_result__` a real `sdk.result` frame (a completed turn's SETTLE edge - /// — the real sidecar emits it on every SDK result whatever the subtype), and - /// `permission.respond`/`question.respond`/non-magic `send` frames append the full - /// received line to `FRESHELL_TEST_CLAUDE_RESPOND_LOG` (the assertion surface for - /// the respond/compact handlers' exact stdin frame shapes). - const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -const spawnLog = process.env.FRESHELL_TEST_CLAUDE_SPAWN_LOG -const respondLog = process.env.FRESHELL_TEST_CLAUDE_RESPOND_LOG -// Task 4 review (C1) determinism knob: DEFER every create ANSWER by N ms. The -// create request is still logged IMMEDIATELY (the spawn-log gate observes the -// rollback parked inside its respawn), so a test can fire a send while the -// rollback deterministically waits on `created` — the mid-rollback send -// interleaving is guaranteed, never luck. -const deferCreateMs = parseInt(process.env.FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS || '0', 10) -// Task 4 review (M3) provable-rejection knob: die on create like a spawn-time -// provider rejection (EOF before `created`). -const failCreate = process.env.FRESHELL_TEST_CLAUDE_FAIL_CREATE === '1' - -let counter = 0 -// 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() -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - // Log the WHOLE create request (one line per create) so tests can both count - // spawns AND assert what the sidecar received (e.g. the resume boot). - if (spawnLog) { - fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) - } - const answer = () => { - if (failCreate) process.exit(1) - counter += 1 - const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` - liveSessions.add(sessionId) - configuredSettings.set(sessionId, { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd: msg.cwd }) - process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') - if (msg.sessionId) { - // A boot under a server-minted id (a rollback's replacement, a boot - // from a genesis): the CLI runs under that id and, like the real CLI - // (T1), reports it only once a prompt runs — at the first send. - deferredInit.set(sessionId, msg.sessionId) - return - } - // Mirror the real sidecar's post-create init: echo the resume boot's id as the - // durable id when present (resume continuity), else a fixed fake uuid. - const cliSessionId = msg.boot?.kind === 'fork' - ? `fork-${process.pid}-${counter}-0000-4000-8000-000000000000` - : (msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) - console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) - } - if (deferCreateMs > 0) setTimeout(answer, deferCreateMs) - else answer() - } else if (msg.type === 'configure') { - if (respondLog) fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - console.log(JSON.stringify({ type: 'sdk.session.changed', sessionId: msg.sessionId, reason: 'session-commands', commands: [{ name: 'review', description: 'Review changes' }] })) - const settings = configuredSettings.get(msg.sessionId) ?? {} - const ok = msg.settings?.model !== 'unavailable' && msg.settings?.effort !== 'invalid' - if (msg.settings?.model !== 'unavailable') settings.model = msg.settings.model - if (ok) Object.assign(settings, msg.settings) - console.log(JSON.stringify({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok, settings, message: 'Model or effort is unavailable' })) - } else if (msg.type === 'send') { - // Test hook: lets tests kill the sidecar THROUGH the public API to exercise - // the consumer-exit eviction path (ledger A9). - if (msg.text === '__exit__') process.exit(0) - const deferredCliId = deferredInit.get(msg.sessionId) - if (deferredCliId) { - deferredInit.delete(msg.sessionId) - console.log(JSON.stringify({ type: 'sdk.session.init', sessionId: msg.sessionId, cliSessionId: deferredCliId, model: 'fake-model', cwd: '/tmp', tools: [] })) - } - // 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 (!liveSessions.has(msg.sessionId)) { - // 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 - // respond to. The fake parks nothing — Rust's pending fold is the state under test. - } else if (msg.text === '__raise_permission__') { - process.stdout.write(JSON.stringify({ - type: 'sdk.permission.request', - sessionId: msg.sessionId, - requestId: 'req-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'ls' } }, - toolUseID: 'toolu_fake_1', - blockedPath: null, - decisionReason: null, - }) + '\n') - } else if (msg.text.startsWith('__set_status__:')) { - // Reconnect-revive hook: emit a real sdk.status frame so a test can arrange a - // non-default tracked status through the REAL stdout consumer fold (the attach - // ack under test speaks it back). - 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 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 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 - // tests can assert the exact stdin frame shape the compact handler writes. - fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - } - } else if (msg.type === 'permission.respond' || msg.type === 'question.respond') { - if (respondLog) fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - } else if (msg.type === 'interrupt') { - const interruptLog = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG - if (interruptLog) fs.appendFileSync(interruptLog, `${msg.sessionId}\n`) - // 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) - } -}) -"#; - - /// A fresh temp dir holding the fake sidecar script + this test's spawn-count log, - /// with `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE`/ - /// `FRESHELL_TEST_CLAUDE_SPAWN_LOG` pointed at it. Caller must hold - /// [`CLAUDE_ENV_LOCK`] for the lifetime of the returned guard. - struct FakeClaudeSidecarEnv { - dir: PathBuf, - spawn_log: PathBuf, - interrupt_log: PathBuf, - respond_log: PathBuf, - } - impl FakeClaudeSidecarEnv { - fn install() -> Self { - Self::install_with_knobs(None, false) - } - - /// `install` + the fake sidecar's scripted knobs (see the source header): - /// `defer_create_ms` delays every create ANSWER (the request is logged - /// immediately — a test can park the rollback inside its respawn); - /// `fail_create` makes every create a spawn-time provider rejection. - fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-sidecar-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let spawn_log = dir.join("spawn.log"); - std::fs::write(&spawn_log, "").expect("init spawn log"); - let interrupt_log = dir.join("interrupt.log"); - std::fs::write(&interrupt_log, "").expect("init interrupt log"); - let respond_log = dir.join("respond.log"); - std::fs::write(&respond_log, "").expect("init respond log"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); - std::env::set_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG", &respond_log); - match defer_create_ms { - Some(ms) => { - std::env::set_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", ms.to_string()) - } - None => std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"), - } - if fail_create { - std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); - } else { - std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); - } - Self { - dir, - spawn_log, - interrupt_log, - respond_log, - } - } - - /// Path of the spawn log (one full JSON create-request line per spawn) so tests - /// can assert what the sidecar received (e.g. the resume boot). - fn spawn_log_path(&self) -> &std::path::Path { - &self.spawn_log - } - - async fn create_request(&self) -> Value { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - let text = std::fs::read_to_string(self.spawn_log_path()).unwrap_or_default(); - if let Some(line) = text.lines().next() { - return serde_json::from_str(line).expect("logged create JSON"); - } - assert!( - tokio::time::Instant::now() < deadline, - "create never reached sidecar" - ); - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - - /// Number of times the fake sidecar has been spawned so far (one marker line per - /// process start). - fn spawn_count(&self) -> usize { - std::fs::read_to_string(&self.spawn_log) - .map(|s| s.lines().filter(|l| !l.is_empty()).count()) - .unwrap_or(0) - } - - /// Contents of the interrupt log (one `sessionId` per line the fake sidecar - /// received a `{"type":"interrupt",...}` request for). - fn interrupt_log_contents(&self) -> String { - std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() - } - - /// The parsed respond-log lines (one full JSON frame per line the fake sidecar - /// received a `permission.respond` / `question.respond` / non-magic `send` - /// request for). Bounded-waits for at least `min` lines so the assertion reads - /// AFTER the sidecar's append, never racing it. - async fn respond_log_frames(&self, min: usize) -> Vec { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let frames: Vec = std::fs::read_to_string(&self.respond_log) - .unwrap_or_default() - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("respond log line is JSON")) - .collect(); - if frames.len() >= min { - return frames; - } - assert!( - tokio::time::Instant::now() < deadline, - "respond log never reached {min} frame(s)" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - } - impl Drop for FakeClaudeSidecarEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); - let _ = std::fs::remove_dir_all(&self.dir); - } - } + mod test_sidecar_env; + pub(crate) use test_sidecar_env::ClaudeTestRuntime; + use ClaudeTestRuntime as FakeClaudeSidecarEnv; fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { FreshAgentSend { @@ -9723,86 +7956,42 @@ rl.on('line', (line) => { ); } - /// F4 (PR-A review): before the CLI reports a new pane's session the - /// coordinator holds no record for it, and an unfenced (checkpointed) - /// send runs: refusing it would lose every new pane's first message - /// (827d9395d). A send that DOES carry an observed owner pair is still - /// checked: the pair names an owner the coordinator does not hold, so it - /// is stale and refused typed, never run unguarded. - #[tokio::test(flavor = "multi_thread")] - async fn a_fenced_send_before_the_session_report_is_checked_not_skipped() { + /// Map presence never authorizes an unowned runtime to accept a send. + #[tokio::test] + async fn an_unowned_runtime_refuses_sends_with_or_without_an_observed_fence() { let (mut st, mut rx) = state_with_bus(); - let session_id = "claude-send-before-report"; let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - insert_fake_claude_session(&st, session_id).await; - let vacant = registry.observe(PROVIDER, session_id); - assert!( - matches!(vacant.state, freshell_ownership::OwnershipState::Vacant), - "fixture: nothing owns an unreported session" - ); - let checkpointed = |request_id: &str| { - let mut send = send_msg(session_id, "first message"); - send.request_id = Some(request_id.to_string()); - send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { - logical_attempt_id: format!("{request_id}-attempt"), - workspace: None, - }); - send - }; - let drain = |rx: &mut tokio::sync::broadcast::Receiver| { - let mut frames: Vec = Vec::new(); - while let Ok(raw) = rx.try_recv() { - if let Ok(frame) = serde_json::from_str(&raw) { - frames.push(frame); - } + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let vacant = registry.observe(PROVIDER, &id); + for fenced in [true, false] { + let mut send = checkpointed_test_send(&id); + let request_id = format!("unowned-send-{fenced}"); + send.request_id = Some(request_id.clone()); + if fenced { + send.observed_epoch = Some(vacant.epoch); + send.observed_generation = Some(vacant.generation); } - frames - }; - - let mut fenced = checkpointed("fenced-before-report"); - fenced.observed_epoch = Some(vacant.epoch); - fenced.observed_generation = Some(vacant.generation); - st.handle_send(fenced).await; - let frames = drain(&mut rx); - assert!( - frames.iter().any(|frame| { - frame["requestId"] == json!("fenced-before-report") - && frame.to_string().contains("SESSION_RESERVED") - }), - "the supplied fence is honored and refuses typed: {frames:?}" - ); - assert!( - !st.sessions.lock().await[session_id] - .ledger - .lock() - .expect("turn ledger lock") - .busy(), - "the refused send never reaches the sidecar" - ); - - st.handle_send(checkpointed("unfenced-before-report")).await; - let frames = drain(&mut rx); - assert!( - !frames - .iter() - .any(|frame| frame.to_string().contains("SESSION_RESERVED")), - "an unfenced first send of a new pane runs: {frames:?}" - ); - assert!( - st.sessions.lock().await[session_id] - .ledger - .lock() - .expect("turn ledger lock") - .busy(), - "the unfenced first send reaches the sidecar" - ); + assert!(matches!( + st.handle_send(send).await, + ClaudeSendOutcome::Refused { .. } + )); + let frames = drain(&mut rx).await; + assert!( + frames.iter().any(|frame| frame["requestId"] == request_id + && frame["message"] + .as_str() + .is_some_and(|msg| msg.contains("SESSION_RESERVED"))), + "{frames:?}" + ); + assert!(!st.sessions.lock().await[&id].ledger.lock().unwrap().busy()); + } + st.shutdown().await; } - /// The checkpointed send a new pane makes before the CLI reported its - /// session: the browser's send shape (a checkpoint intent, no owner pair), - /// addressed by the lane's placeholder id. - fn checkpointed_send_before_session_report(session_id: &str) -> FreshAgentSend { + /// A browser checkpointed send without an observed owner pair. + fn checkpointed_test_send(session_id: &str) -> FreshAgentSend { let mut send = send_msg(session_id, "first prompt of a new pane"); send.request_id = Some(format!("send-{session_id}")); send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { @@ -9812,59 +8001,14 @@ rl.on('line', (line) => { send } - /// The events named `freshagent.claude.send_before_session_report` that - /// the capture recorded. - fn send_before_session_report_events( - events: &std::sync::Mutex)>>, - ) -> usize { - events - .lock() - .expect("capture lock") - .iter() - .filter(|(_message, fields)| { - fields.get("event").is_some_and(|event| { - event.contains("freshagent.claude.send_before_session_report") - }) - }) - .count() - } - - /// With an ownership coordinator wired, a checkpointed send to a session the - /// CLI has not reported yet (and the coordinator holds no record for) runs - /// unfenced, and says so in `freshagent.claude.send_before_session_report`. - #[tokio::test] - async fn a_send_before_the_session_report_logs_that_it_ran_unfenced() { - let (events, _capture_guard) = info_capture_for_test::capture(); - let (mut st, mut rx) = state_with_bus(); - st.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); - let session_id = "claude-placeholder-wired"; - insert_fake_claude_session(&st, session_id).await; - - st.handle_send(checkpointed_send_before_session_report(session_id)) - .await; - - let frames = drain(&mut rx).await; - assert!( - frames - .iter() - .any(|frame| frame["type"] == "freshAgent.send.accepted"), - "the send runs: {frames:?}" - ); - assert_eq!(send_before_session_report_events(&events), 1); - } - - /// Without an ownership coordinator (the session host's lane) there is no - /// fence to skip: the send runs as before and the before-report event, - /// which says the coordinator holds no record, is not logged. + /// The session host accepts its tracked runtime without a local coordinator. #[tokio::test] - async fn a_send_on_a_lane_without_a_coordinator_does_not_log_a_skipped_fence() { - let (events, _capture_guard) = info_capture_for_test::capture(); + async fn a_send_on_a_lane_without_a_coordinator_runs() { let (st, mut rx) = state_with_bus(); let session_id = "claude-placeholder-unwired"; insert_fake_claude_session(&st, session_id).await; - st.handle_send(checkpointed_send_before_session_report(session_id)) - .await; + st.handle_send(checkpointed_test_send(session_id)).await; let frames = drain(&mut rx).await; assert!( @@ -9873,7 +8017,6 @@ rl.on('line', (line) => { .any(|frame| frame["type"] == "freshAgent.send.accepted"), "the send runs: {frames:?}" ); - assert_eq!(send_before_session_report_events(&events), 0); } /// kata z7j7 (claude scope honesty): a settings-bearing send whose payload @@ -10014,13 +8157,14 @@ rl.on('line', (line) => { /// Unified agent names (Task 4): the shared accepted-input callback — an /// accepted freshclaude send feeds the naming authority ONE activity /// carrying the prompt text (the first-message fallback upgrade plus - /// generation arming happen store-side); the pre-durable handle is the - /// activity target until the init adoption binds it. + /// generation arming happen store-side), addressed to the native identity + /// already bound at creation. #[tokio::test(flavor = "multi_thread")] async fn send_feeds_the_naming_authority_once_with_the_accepted_text() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (state, mut rx) = state_with_bus(); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); let sink = crate::naming::test_support::RecordingSink::new(); state.set_session_naming(sink.clone()); let mut create = dedup_create_msg("naming-send"); @@ -10038,8 +8182,9 @@ rl.on('line', (line) => { assert_eq!(activities.len(), 1, "{activities:?}"); assert_eq!( activities[0].target, - freshell_protocol::session_names::SessionNameRef::Pending { - id: "handle-naming-send".to_string() + freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.clone(), } ); assert_eq!(activities[0].mode, "freshclaude"); @@ -10061,8 +8206,8 @@ rl.on('line', (line) => { /// pane-header rename committed inside that window targeted a record /// the server had accepted on the wire but not yet admitted (404 /// NAME_NOT_FOUND), silently losing the manual pre-durable name to the - /// first-message fallback. Admission is idempotent, so the - /// post-`created` re-ensure answers the same record unchanged. + /// first-message fallback. Establishment then binds that pending name to + /// the new conversation's native identity. #[tokio::test(flavor = "multi_thread")] async fn create_admits_the_pending_record_before_the_sidecar_answers_created() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -10109,197 +8254,6 @@ rl.on('line', (line) => { let _ = joined.await; } - /// Unified agent names (Task 4, review N1): a send RACING the init - /// adoption — the stash entry already consumed by the adoption's - /// bound cleanup, the addressed id still the placeholder (the - /// client has not yet learned the adopted id) — must still feed the - /// authority instead of silently dropping the activity. The feed - /// falls back to the pending ref for the addressed id itself (the - /// store's redirect resolution decides); the next send addresses the - /// adopted id anyway. - #[tokio::test(flavor = "multi_thread")] - async fn a_send_racing_the_init_adoption_still_feeds_the_naming_authority() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (state, mut rx) = state_with_bus(); - let sink = crate::naming::test_support::RecordingSink::new(); - state.set_session_naming(sink.clone()); - let mut create = dedup_create_msg("naming-race"); - create.naming_handle = Some("handle-naming-race".into()); - state.handle_create(create, None).await; - let created = await_claude_created(&mut rx, "naming-race").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // The adoption's bound cleanup consumed the stash entry - // (`bind_naming_handle_at_init` removes it once the handle is - // redirected to the durable record) — this send is still - // addressed to the placeholder and is not a canonical UUID. - // Model the TRUE racing moment: BEFORE the adoption recorded the - // durable id on the session row, neither the stash nor the adopted - // id can answer, and the feed falls back to the pending ref for - // the addressed id itself (the store's redirect resolution - // decides); the next send addresses the adopted id anyway. - // - // DEFLAKE (parallel-starvation family): the fake's ASYNC init - // adoption re-writes `cli_session_id` whenever it lands, so - // hand-clearing it immediately after `created` races the adoption - // — a scheduling delay lets the adoption land between the - // hand-clear and the send, and the feed then targets the fake's - // adopted id instead of the pending fallback (observed in a - // full-crate run). Wait for the adoption to COMPLETE first (the - // fake emits init ONCE), then hand-model the mid-adoption window: - // stash consumed + durable id not yet recorded. - { - let adoption_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let adopted_yet = state - .sessions - .lock() - .await - .get(&session_id) - .and_then(|s| s.cli_session_id.clone()); - if adopted_yet.is_some() { - break; - } - assert!( - tokio::time::Instant::now() < adoption_deadline, - "the fake sidecar's init adoption never landed" - ); - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - state.naming_handles.lock().await.remove(&session_id); - state - .sessions - .lock() - .await - .get_mut(&session_id) - .expect("the created session row") - .cli_session_id = None; - - let legacy_id = "legacy-name-placeholder"; - state - .cli_index - .lock() - .await - .insert(legacy_id.into(), session_id.clone()); - state - .handle_send(send_msg(legacy_id, "Race the adoption")) - .await; - let _ = env.respond_log_frames(1).await; - - let activities = await_activities(&sink, 1).await; - assert_eq!( - activities.len(), - 1, - "the racing send must not silently drop the activity feed: {activities:?}" - ); - assert_eq!( - activities[0].target, - freshell_protocol::session_names::SessionNameRef::Pending { - id: legacy_id.into() - } - ); - assert_eq!(activities[0].mode, "freshclaude"); - assert_eq!( - activities[0].first_user_message.as_deref(), - Some("Race the adoption") - ); - } - - /// Unified agent names (Task 8 acceptance): once the init ADOPTION has - /// consumed the pre-durable stash and recorded the session's durable - /// `cli_session_id`, a send still addressed to the PLACEHOLDER (the - /// client has not yet learned the adopted id) must feed the activity to - /// the ADOPTED durable record — the pending- fallback of - /// the racing-send shape can never resolve in the real store (no naming - /// record is keyed by a placeholder id), and the first-message fallback - /// upgrade was silently lost for exactly these sends. - #[tokio::test(flavor = "multi_thread")] - async fn a_post_adoption_send_targets_the_adopted_durable_record() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (state, mut rx) = state_with_bus(); - let sink = crate::naming::test_support::RecordingSink::new(); - state.set_session_naming(sink.clone()); - let mut create = dedup_create_msg("naming-adopted"); - create.naming_handle = Some("handle-naming-adopted".into()); - state.handle_create(create, None).await; - let created = await_claude_created(&mut rx, "naming-adopted").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // The real post-adoption shape, set directly (the unit lane's fake - // sidecar emits init asynchronously and the racing-send precedent - // models the state the same way): the stash is consumed (the init - // bind's cleanup) and the session row carries the adopted durable - // id — the client is still addressing the placeholder. - // - // DEFLAKE (parallel-starvation family): the fake's ASYNC init - // adoption overwrites `cli_session_id` (with the fake's own id) - // whenever it runs, so hand-setting the adopted id immediately - // after `created` races it — a scheduling delay lets the adoption - // land between the hand-set and the send, and the activity then - // targets the fake's id (observed once in a full-crate run). Wait - // for the adoption to COMPLETE first, then hand-set the modeled - // adopted id — the scenario is the POST-adoption state either way. - let adopted_cli_id = "11111111-2222-4333-8444-555555555555".to_string(); - { - let adoption_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let adopted_yet = state - .sessions - .lock() - .await - .get(&session_id) - .and_then(|s| s.cli_session_id.clone()); - if adopted_yet.is_some() { - break; - } - assert!( - tokio::time::Instant::now() < adoption_deadline, - "the fake sidecar's init adoption never landed" - ); - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let mut sessions = state.sessions.lock().await; - sessions - .get_mut(&session_id) - .expect("the created session row") - .cli_session_id = Some(adopted_cli_id.clone()); - } - state.naming_handles.lock().await.remove(&session_id); - - state - .handle_send(send_msg(&session_id, "Feed the adopted record")) - .await; - let _ = env.respond_log_frames(1).await; - - let activities = await_activities(&sink, 1).await; - assert_eq!( - activities.len(), - 1, - "the post-adoption send must not silently drop the activity feed: {activities:?}" - ); - assert_eq!( - activities[0].target, - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: adopted_cli_id, - }, - "the activity targets the ADOPTED durable record, not the unresolvable placeholder" - ); - assert_eq!( - activities[0].first_user_message.as_deref(), - Some("Feed the adopted record") - ); - } - #[tokio::test(flavor = "multi_thread")] async fn send_applies_changed_settings_before_text_and_persists_them() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -10342,10 +8296,19 @@ rl.on('line', (line) => { snapshot["settings"], json!({ "model": "sonnet", "effort": "max", "permissionMode": "plan" }) ); - assert_eq!( - snapshot["commands"], - json!([{ "name": "review", "description": "Review changes" }]) - ); + let catalog: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/claude-cli-sim/catalog.json" + ))) + .unwrap(); + let commands: Vec = catalog["initializationResult"]["commands"] + .as_array() + .unwrap() + .iter() + .filter(|command| !matches!(command["name"].as_str(), Some("doctor" | "color"))) + .cloned() + .collect(); + assert_eq!(snapshot["commands"], json!(commands)); assert_eq!( snapshot["status"], "running", "accepted input is busy before the first provider output" @@ -10365,6 +8328,7 @@ rl.on('line', (line) => { async fn failed_effort_records_the_model_that_already_took_effect() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_EFFORT", "invalid"); let (state, mut rx) = state_with_bus(); let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); state.set_identity_sink(fake.clone()); @@ -10413,6 +8377,7 @@ rl.on('line', (line) => { async fn send_rejects_failed_settings_without_losing_text_or_changing_saved_settings() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_MODEL", "unavailable"); let (state, mut rx) = state_with_bus(); let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); state.set_identity_sink(fake.clone()); @@ -10537,41 +8502,14 @@ rl.on('line', (line) => { assert_eq!(env.spawn_count(), 2); } - /// Model a session registered by the older host before it knew its - /// native UUID. Only compatibility tests call this; current creates are - /// already durably owned and never take this path. - async fn prepare_legacy_init(st: &FreshClaudeState, map_key: &str) { - let durable = { - let mut sessions = st.sessions.lock().await; - let session = sessions.get_mut(map_key).expect("legacy fixture runtime"); - session.owning_operation = None; - session.owning_generation = None; - session.cli_session_id.take() - }; - if let Some(durable) = durable { - st.cli_index.lock().await.remove(&durable); - crate::ownership_lane::release_retained_stamp( - &st.ownership, - &st.ownership_stamps, - PROVIDER, - &durable, - "test/legacy-init-fixture", - ); - } - } - - async fn adopt_legacy_fixture(st: &FreshClaudeState, map_key: &str, durable: &str) { - prepare_legacy_init(st, map_key).await; - st.adopt_session_init( - durable, - map_key, - "freshclaude", - None, - st.identity_sink(), - None, - ) - .await; - await_alias(st, durable, map_key).await; + async fn inherit_native_fixture(st: &FreshClaudeState, current: &str, prior: &str) { + st.sessions.lock().await[current] + .identity + .inherit([prior.to_string()]); + assert_eq!( + st.resolve_session_key(prior).await.as_deref(), + Some(current) + ); } /// Drain `rx` until the `freshAgent.created` (or `.create.failed`) frame for @@ -10660,6 +8598,7 @@ rl.on('line', (line) => { let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); let durable = "66666666-6666-4666-8666-666666666666"; + write_fake_transcript(&env.dir.join("claude"), durable); let mut msg = dedup_create_msg("req-sref-only-1"); msg.session_ref = Some(freshell_protocol::SessionLocator { provider: "claude".to_string(), @@ -10964,6 +8903,7 @@ rl.on('line', (line) => { ); let durable = "77777777-7777-4777-8777-777777777777"; + write_fake_transcript(&env.dir.join("claude"), durable); let mut resume = dedup_create_msg("req-outcome-resume"); resume.resume_session_id = Some(durable.to_string()); let resumed = st.handle_create(resume, None).await; @@ -10991,8 +8931,8 @@ rl.on('line', (line) => { #[tokio::test(flavor = "multi_thread")] async fn a_fresh_claude_create_spawn_failure_settles_the_durable_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; - // The FAIL knob: the sidecar dies before `created` (a spawn-time - // provider rejection — EOF). + // The SDK initialization rejects before `created`, through the real + // sidecar's typed boot-failure path. let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), true); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); @@ -11037,7 +8977,7 @@ rl.on('line', (line) => { } assert_eq!( failed_code.as_deref(), - Some("CLAUDE_CREATE_FAILED"), + Some("BOOT_FAILED"), "the spawn failure answers typed" ); assert!( @@ -11083,21 +9023,19 @@ rl.on('line', (line) => { } } - /// Wait until the fresh create's adoption has COMMITTED ownership under - /// `durable_id`. The adoption publishes the `cli_index` alias and then - /// writes the identity binding BEFORE its commit (ext r27 F4: a failed - /// binding abandons it pre-commit), and the commit-to-Live plus the - /// retained release stamp are one critical section under the stamps - /// lock (ext r30 F1) — so neither the alias nor `was_recorded` proves - /// the commit: both can be observed while the claim is still Starting - /// and the stamp is absent. The stamp is the real condition. + /// Wait until the fresh create has committed ownership under `durable_id`. + /// Establishment writes the identity binding before committing ownership; + /// a failed binding abandons the claim. The commit to Live and retained + /// release stamp share the stamps lock, so a recorded binding alone does + /// not prove the commit: it can be observed while the claim is Starting. + /// The retained stamp is the condition this helper waits for. async fn await_create_ownership_committed(st: &FreshClaudeState, durable_id: &str) { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while crate::ownership_lane::peek_retained_stamp(&st.ownership_stamps, durable_id).is_none() { assert!( tokio::time::Instant::now() < deadline, - "the create's adoption never committed ownership (no retained stamp)" + "the create never committed ownership (no retained stamp)" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -11194,8 +9132,7 @@ rl.on('line', (line) => { "the durable ledger row was NOT closed on refusal — the retire \ log must not name it" ); - // And the runtime is still live in the map (has_live_session keys - // through cli_index — the DURABLE id is the index's entry). + // And the runtime is still live under its native identity. assert!( st.has_live_session(fresh_create_durable_id).await, "the refused kill left the session live" @@ -11918,10 +9855,9 @@ rl.on('line', (line) => { let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st - .cli_index - .lock() + .resolve_session_key(fresh_create_durable_id) .await - .get(fresh_create_durable_id) + .as_ref() .is_none() { assert!( @@ -12039,6 +9975,280 @@ rl.on('line', (line) => { assert_eq!(frame["sessionId"], "unknown-session"); } + /// A configure handler can hold the turn lock while awaiting a receipt + /// from this same consumer. Native rekey must let that consumer advance. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_conversation_rekey_does_not_wait_on_a_handler_awaiting_its_receipt() { + let (st, _rx) = state_with_bus(); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + let (turn, pending) = { + let sessions = st.sessions.lock().await; + let session = &sessions[&old]; + (session.turn_lock.clone(), session.pending.clone()) + }; + let held = turn.lock().await; + let moved = tokio::time::timeout( + Duration::from_millis(500), + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test-configure"), + ) + .await; + assert!( + moved.is_ok(), + "the consumer cannot wait on a handler that needs its next receipt" + ); + assert_eq!(moved.unwrap().unwrap(), new); + assert!(Arc::ptr_eq( + &st.sessions.lock().await[&new].turn_lock, + &turn + )); + drop(held); + st.shutdown().await; + } + + /// A kill can resolve the old key just before `/clear` rekeys the same + /// live runtime. Its close gate must re-resolve the wire id while holding + /// the alias index and session map together, including in hosted states + /// without an ownership coordinator. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kill_re_resolves_a_conversation_rekey_before_arming_its_close_gate() { + let (mut st, mut rx) = state_with_bus(); + let identity_sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(identity_sink.clone()); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + + let pause = std::sync::Arc::new(AdoptionTestPause { + notify: tokio::sync::Notify::new(), + reached: std::sync::atomic::AtomicBool::new(false), + }); + st.set_kill_after_resolve_test_pause(Some(std::sync::Arc::clone(&pause))); + let kill_state = st.clone(); + let kill_id = old.clone(); + let kill = tokio::spawn(async move { + kill_state.handle_kill(kill_msg(&kill_id)).await; + }); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "kill did not reach the post-resolution pause" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + let pending = Arc::clone(&st.sessions.lock().await[&old].pending); + assert_eq!( + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test") + .await + .expect("the live conversation rekeys"), + new, + ); + pause.notify.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(15), kill) + .await + .expect("the kill settles") + .expect("the kill task completes"); + + assert!( + !st.sessions.lock().await.contains_key(&new), + "kill(old) must remove the runtime after it was rekeyed to new" + ); + let killed = await_specific_frame(&mut rx, "freshAgent.killed", &old).await; + assert_eq!(killed["success"], true, "the kill result is {killed}"); + let retired = identity_sink.retires.lock().unwrap().clone(); + assert!( + retired.contains(&("claude".to_string(), old.clone())) + && retired.contains(&("claude".to_string(), new.clone())), + "kill(old) must close both the addressed alias and current identity: {retired:?}" + ); + } + + async fn link_native_fixture(st: &FreshClaudeState, prior: &str, current: &str) { + st.identity_sink() + .unwrap() + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: current.into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: Some(prior.into()), + name_transition: None, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: Default::default(), + genesis: crate::identity_sink::GenesisUpdate::Keep, + }) + .await + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_persisted_native_close_follows_its_captured_runtime_through_a_concurrent_rekey() { + let (mut st, mut rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let prior = uuid::Uuid::new_v4().to_string(); + let current = uuid::Uuid::new_v4().to_string(); + let next = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, ¤t).await; + link_native_fixture(&st, &prior, ¤t).await; + assert!(!st.sessions.lock().await[¤t].identity.contains(&prior)); + let pause = Arc::new(AdoptionTestPause { + notify: tokio::sync::Notify::new(), + reached: std::sync::atomic::AtomicBool::new(false), + }); + st.set_kill_after_resolve_test_pause(Some(pause.clone())); + let kill = { + let st = st.clone(); + let id = prior.clone(); + tokio::spawn(async move { st.handle_kill(kill_msg(&id)).await }) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "kill never resolved its id" + ); + tokio::task::yield_now().await; + } + let pending = st.sessions.lock().await[¤t].pending.clone(); + st.rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/persisted-close-rekey", + ) + .await + .unwrap(); + pause.notify.notify_one(); + tokio::time::timeout(Duration::from_secs(15), kill) + .await + .unwrap() + .unwrap(); + let answer = await_specific_frame(&mut rx, "freshAgent.killed", &prior).await; + assert_eq!(answer["success"], true, "{answer}"); + assert!( + !st.sessions.lock().await.contains_key(&next), + "the captured runtime survives neither a stale map key nor missing wire history" + ); + let retires = sink.retires.lock().unwrap(); + for id in [&prior, ¤t, &next] { + assert!( + retires.contains(&(PROVIDER.into(), id.clone())), + "missing native close for {id}" + ); + } + } + + #[tokio::test] + async fn a_direct_live_native_id_precedes_a_conflicting_persisted_successor() { + let (st, mut rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let direct = uuid::Uuid::new_v4().to_string(); + let other = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &direct).await; + insert_fake_claude_session(&st, &other).await; + link_native_fixture(&st, &direct, &other).await; + assert_eq!( + st.resolve_session_key(&direct).await.as_deref(), + Some(direct.as_str()) + ); + assert_eq!(st.claude_owner_key(Some(&direct), &direct).await, direct); + st.handle_kill(kill_msg(&direct)).await; + let answer = await_specific_frame(&mut rx, "freshAgent.killed", &direct).await; + assert_eq!(answer["success"], true); + assert!(st.sessions.lock().await.contains_key(&other)); + assert!(!sink + .retires + .lock() + .unwrap() + .contains(&(PROVIDER.into(), other))); + st.shutdown().await; + } + + #[tokio::test] + async fn an_active_coordinator_owner_precedes_a_staged_persisted_successor() { + use freshell_ownership::{BeginOutcome, RuntimeOwnerKind}; + + let (mut st, _rx) = state_with_bus(); + st.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + let original = uuid::Uuid::new_v4().to_string(); + let staged = uuid::Uuid::new_v4().to_string(); + assert!(matches!( + registry.begin_start( + PROVIDER, + &original, + RuntimeOwnerKind::FreshAgent, + "pending-original", + None, + "test", + 1 + ), + BeginOutcome::Granted { .. } + )); + link_native_fixture(&st, &original, &staged).await; + assert_eq!(st.resolve_ownership_key(&original), original); + assert!(matches!( + st.ownership_snapshot(PROVIDER, &original).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + } + + #[tokio::test] + async fn the_kill_sweep_spares_a_reopened_successor_whose_prior_native_id_remains_fenced() { + let (st, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let prior = uuid::Uuid::new_v4().to_string(); + let current = uuid::Uuid::new_v4().to_string(); + link_native_fixture(&st, &prior, ¤t).await; + sink.retire_closed(PROVIDER, &prior).await.unwrap(); + sink.retire_closed(PROVIDER, ¤t).await.unwrap(); + let fence = sink.kill_tombstone_at_ms(PROVIDER, ¤t); + assert!(fence.is_some()); + assert!(st.commit_session_claim(¤t, fence, &[]).await); + insert_fake_claude_session(&st, ¤t).await; + let pid = st.sessions.lock().await[¤t].child.id(); + assert!(!st.sessions.lock().await[¤t].identity.contains(&prior)); + assert!(sink.kill_tombstone_at_ms(PROVIDER, &prior).is_some()); + assert!(sink.kill_tombstone_at_ms(PROVIDER, ¤t).is_none()); + assert_eq!( + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) + ); + + st.sweep_late_claim_orphans(&[prior.clone(), current.clone()]) + .await; + + { + let mut sessions = st.sessions.lock().await; + let session = sessions + .get_mut(¤t) + .expect("a genuine reopen survives the old close's sweep"); + assert_eq!(session.child.id(), pid); + assert!(session.child.try_wait().unwrap().is_none()); + } + assert_eq!( + sink.states + .lock() + .unwrap() + .get(&(PROVIDER.into(), current)) + .copied(), + Some(crate::identity_sink::FakeRowState::Bound) + ); + st.shutdown().await; + } + /// Retire-on-kill (delta-review round 5, restore-open-sessions-only): an /// explicit kill is an intentional session END — the pane-ledger row retires /// `Closed` through the identity sink, so the recovery inventory (Bound-only @@ -12060,22 +10270,14 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-retire-claude").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "3d3d3d3d-3d3d-4d3d-8d3d-3d3d3d3d3d3d"; - // Alias the durable id at it (live: the consumer's sdk.session.init fold - // writes this mapping; here the test writes it directly, same trick as - // attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle). - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); - - // Kill addressed by the DURABLE id must retire the durable-keyed row - // (resolve_session_key walks cli_index to the live map key). + // Retain a prior native id in the runtime's history, as a conversation + // reset does. The close must include this historical durable row. + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); + + // A kill addressed by the historical durable id resolves the same + // runtime and retires that durable row. st.handle_kill(FreshAgentKill { observed_epoch: None, observed_generation: None, @@ -12097,16 +10299,9 @@ rl.on('line', (line) => { .await; let created2 = await_claude_created(&mut rx, "req-retire-claude-2").await; let placeholder2 = created2["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder2.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder2) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder2] + .identity + .inherit([durable.to_string()]); st.handle_kill(FreshAgentKill { observed_epoch: None, observed_generation: None, @@ -12348,14 +10543,13 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-close-before-teardown").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -12434,14 +10628,13 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-close-fails").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -12520,14 +10713,13 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-one-envelope").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -12568,105 +10760,60 @@ rl.on('line', (line) => { assert!(pendings.is_empty(), "claude kills carry no marker ids"); } - /// Focused-episode-6 round 4 (Finding F5) — the mint gate: with the - /// kill's close-pending enumeration armed, an adoption ABANDONS: no - /// cli_index insert, no alias-tombstone write, no binding-row write — - /// nothing the kill's ONE envelope could miss. This retires the - /// delta-r6-r2 completion-diff design (a SECOND close for an id - /// discovered only after the consumer abort, whose own clean failure - /// could never roll the teardown back): discovery completes before the - /// envelope now, by construction. - #[tokio::test(flavor = "multi_thread")] - async fn a_mint_adoption_abandons_once_the_kills_close_gate_is_set() { + /// A close in progress refuses a native reset before its identity or binding moves. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_native_rekey_is_refused_once_the_kills_close_gate_is_set() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - - st.handle_create(dedup_create_msg("req-gate-mint"), None) + st.handle_create(dedup_create_msg("close-gates-native-rekey"), None) .await; - let created = await_claude_created(&mut rx, "req-gate-mint").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - // Park the kill INSIDE its one durable close (mutations recorded; - // the answer stalled): the enumeration + mint gate are provably in - // force — the placeholder rides the envelope. - let stall = fake.arm_retire_stall("claude", &placeholder); - let st2 = st.clone(); - let ph = placeholder.clone(); - let mut kill = tokio::spawn(async move { - st2.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: ph, - session_type: SessionType::Freshclaude, - cwd: None, - }) + let created = await_claude_created(&mut rx, "close-gates-native-rekey").await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let pending = st.sessions.lock().await[¤t].pending.clone(); + let stall = fake.arm_retire_stall(PROVIDER, ¤t); + let kill = { + let st = st.clone(); + let id = current.clone(); + tokio::spawn(async move { st.handle_kill(kill_msg(&id)).await }) + }; + stall.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + let next = uuid::Uuid::new_v4().to_string(); + let refusal = st + .rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/close-in-flight", + ) .await; - }); - stall - .entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the durable close is in flight (the gate is armed)"); - - // The mint that could slip the enumeration→teardown window in the - // completion-diff shape: discard it entirely. - let gated = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - st.adopt_session_init( - gated, - &placeholder, - "freshclaude", - Some(&crate::identity_sink::FreshAgentSettings { - model: Some("gate-model".into()), - ..crate::identity_sink::FreshAgentSettings::default() - }), - Some(fake.clone()), - None, - ) - .await; - assert!( - st.cli_index.lock().await.get(gated).is_none(), - "the gated mint inserts nothing: {:?}", - st.cli_index.lock().await + assert!(refusal.unwrap_err().contains("closing")); + assert_eq!( + st.resolve_session_key(¤t).await.as_deref(), + Some(current.as_str()) ); - - stall.release.send(()).expect("release the stall"); - tokio::time::timeout(std::time::Duration::from_secs(15), &mut kill) + assert!(st.resolve_session_key(&next).await.is_none()); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); + stall.release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(15), kill) .await - .expect("the kill completes") - .expect("kill task completed"); - assert!( - !fake - .retires - .lock() - .unwrap() - .iter() - .any(|(_, id)| id == gated), - "nothing retires an id the envelope never saw: {:?}", - fake.retires.lock().unwrap() - ); - assert!( - !fake - .bindings - .lock() - .unwrap() - .iter() - .any(|b| b.session_id == gated), - "the gated adoption writes no binding row: {:?}", - fake.bindings.lock().unwrap() - ); - assert!( - !fake - .alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(_, _, d)| d == gated), - "the gated adoption writes no alias record: {:?}", - fake.alias_record_writes.lock().unwrap() - ); + .unwrap() + .unwrap(); + assert!(st.sessions.lock().await.is_empty()); + assert!(!fake + .retires + .lock() + .unwrap() + .iter() + .any(|(_, id)| id == &next)); } // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── @@ -12740,288 +10887,18 @@ rl.on('line', (line) => { } } - /// b8ke focused episode-2 round-1 F2: the pre-registration window must - /// not expose a Live owner that cannot be killed. An adoption for a - /// session that is NOT yet registered defers the WHOLE adoption — no - /// claim, no commit, no alias — until the create registers the - /// runtime. Pre-fix: the consumer's init committed Live{FreshAgent} - /// and minted the alias immediately (a handoff in the window saw a - /// killable-owner lie: kill_for_handoff answered AlreadyGone and the - /// create then inserted its still-live sidecar — two writers). - #[tokio::test(flavor = "multi_thread")] - async fn the_init_adoption_defers_until_the_runtime_is_registered() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, _rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - // An adoption for a session that was never registered (the - // create's insert window): nothing commits, nothing publishes. - st.adopt_session_init( - "dur-e2r2-f2", - "placeholder-never-registered", - "freshclaude", - None, - None, - None, - ) - .await; - - // Give the immediate (pre-fix) commit ample time to land, then - // assert the deferral: NO Live owner, NO alias. - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - assert!( - !matches!( - registry.observe("claude", "dur-e2r2-f2").state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the pre-registration window must not expose a Live owner, got {:?}", - registry.observe("claude", "dur-e2r2-f2").state - ); - assert!( - st.cli_index.lock().await.get("dur-e2r2-f2").is_none(), - "the deferred adoption publishes no alias" - ); - } - - /// b8ke focused episode-2 round-1 F3: a REFUSED adoption fails closed. - /// A foreign owner (a live terminal) holds the canonical key — the - /// freshly spawned runtime is TORN DOWN (removed + the standard - /// teardown + the lost-session recovery), the alias is never - /// published, and the foreign owner is never stolen. Pre-fix: the - /// refusal only logged — the sidecar kept running unowned beside the - /// foreign owner while the alias minted. + /// b8ke ext r13 F3: the rollback's replace window holds REAL + /// authority. The rollback is parked INSIDE its respawn (the fake + /// sidecar's deferred create answer) while the Adopt-path attach guard + /// is armed, and a handoff BEGIN is attempted in the window: it answers + /// the typed Blocked outcome — no terminal can start beside the + /// replacement (pre-r13 the Adopt arm held NO claim, so the racing + /// handoff granted and the overlapping writers ran until the rekey's + /// verify rejected). #[tokio::test(flavor = "multi_thread")] - async fn a_refused_init_adoption_tears_the_fresh_runtime_down() { + async fn a_handoff_during_the_rollback_replace_window_is_blocked_typed() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - // The FOREIGN OWNER: a live terminal under the durable id the - // fresh create's sidecar is about to mint. - let foreign = freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::Terminal, - terminal_id: Some("t-foreign".into()), - live_session_key: None, - pid: None, - ownership_id: None, - }; - let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( - "claude", - fresh_create_durable_id, - freshell_ownership::RuntimeOwnerKind::Terminal, - "op-foreign-owner", - None, - "test", - 0, - ) else { - panic!("expected Granted") - }; - assert!(matches!( - registry.commit_live( - "claude", - fresh_create_durable_id, - "op-foreign-owner", - generation, - foreign - ), - freshell_ownership::CommitOutcome::Committed - )); - - // The ordinary fresh create: the consumer's init mints the durable - // id; the adoption claim is REFUSED (OwnedByOtherKind) → teardown. - st.handle_create(dedup_create_msg("req-e2r3-f3"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r3-f3").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - prepare_legacy_init(&st, &placeholder).await; - st.adopt_session_init( - fresh_create_durable_id, - &placeholder, - "freshclaude", - None, - None, - None, - ) - .await; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let removed = !st.sessions.lock().await.contains_key(&placeholder); - if removed { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the refused adoption must tear the fresh runtime down — the \ - session stays registered" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // Nothing published: no alias for the minted durable id. - assert!( - st.cli_index - .lock() - .await - .get(fresh_create_durable_id) - .is_none(), - "the refused adoption publishes nothing" - ); - // The foreign owner is NEVER stolen. - assert!(matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::Terminal - )); - // The lost-session recovery frame reached the bus (bounded: the - // teardown's reap precedes the broadcast). - let mut saw_lost = false; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !saw_lost && tokio::time::Instant::now() < deadline { - while let Ok(raw) = rx.try_recv() { - if raw.contains("freshAgent.error") && raw.contains(&placeholder) { - saw_lost = true; - } - } - if !saw_lost { - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - assert!(saw_lost, "the teardown engages the lost-session recovery"); - } - - /// b8ke focused episode-2 round-1 F4: an in-stream RE-KEY (an init - /// naming a durable id other than the one the session already carries) - /// never adopts ownership under the NEW durable id — the runtime is - /// owned and committed under the OLD durable id, so a second - /// Live{FreshAgent} record under the new id would leave contradictory - /// snapshots (the old key falsely Live after the runtime moves). The - /// re-key bookkeeping (the alias) still lands. - #[tokio::test(flavor = "multi_thread")] - async fn a_rollback_fork_rekey_never_adopts_ownership_under_the_new_id() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - // An ordinary fresh create: the runtime registers + the adoption - // commits Live{FreshAgent} under the minted durable id (the OLD id - // of this scenario's rollback). - st.handle_create(dedup_create_msg("req-e2r4-f4"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r4-f4").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the ordinary create's adoption must commit under the old id" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // The RE-KEY: the session's next sdk.session.init names a NEW durable - // id. The re-key bookkeeping lands — but NO ownership under the new - // id. - st.adopt_session_init( - "new-fork-id-e2r4", - &placeholder, - "freshclaude", - None, - None, - None, - ) - .await; - - assert!( - !matches!( - registry.observe("claude", "new-fork-id-e2r4").state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the fork ADOPTION must NOT claim under the new id — the ROLLBACK's \ - commit owns the one move (the delta-r2 dual-record shape): {:?}", - registry.observe("claude", "new-fork-id-e2r4").state - ); - // The re-key bookkeeping: the alias maps the fork id to the session. - assert_eq!( - st.cli_index.lock().await.get("new-fork-id-e2r4"), - Some(&placeholder), - "the fork re-key alias still lands" - ); - // b8ke focused episode-2 round-2 F1: after the rollback's COMMIT, the - // canonical ownership MOVES to the client-visible new id - // (`commit_lane_claim_rekey_at` — pinned by the registry's - // commit_live_rekey test), and the OLD id becomes a resolvable - // alias: the lane resolves it for every lifecycle operation, and - // the snapshot reports the CANONICAL record for it — never a false - // Vacant that licenses a second writer beside the live runtime. - // The registry-backed alias: the coordinator record (the e2r3 F3 - // single source of truth) — driven through the registry's own - // rekey_live (the same move the rollback's Adopt-path commit - // performs). - assert!(matches!( - registry.rekey_live( - "claude", - fresh_create_durable_id, - "new-fork-id-e2r4", - &placeholder, - freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: Some(placeholder.clone()), - pid: Some(4321), - ownership_id: Some("rekey-op-fork-e2r4".into()), - }, - "test-rekey", - "rekey-op-fork-e2r4", - ), - freshell_ownership::CommitOutcome::Committed - )); - assert_eq!( - st.resolve_ownership_key(fresh_create_durable_id), - "new-fork-id-e2r4", - "the superseded old id resolves to the client-visible canonical id" - ); - assert_eq!( - st.resolve_ownership_key("new-fork-id-e2r4"), - "new-fork-id-e2r4", - "the canonical id resolves to itself" - ); - // The snapshot for the OLD id reports the CANONICAL record. - let snap = st.ownership_snapshot("claude", fresh_create_durable_id); - assert_eq!( - snap.state, - registry.observe("claude", "new-fork-id-e2r4").state, - "a snapshot on the superseded id reports the canonical record — \ - never a false Vacant over the live re-keyed runtime" - ); - } - - /// b8ke ext r13 F3: the rollback's replace window holds REAL - /// authority. The rollback is parked INSIDE its respawn (the fake - /// sidecar's deferred create answer) while the Adopt-path attach guard - /// is armed, and a handoff BEGIN is attempted in the window: it answers - /// the typed Blocked outcome — no terminal can start beside the - /// replacement (pre-r13 the Adopt arm held NO claim, so the racing - /// handoff granted and the overlapping writers ran until the rekey's - /// verify rejected). - #[tokio::test(flavor = "multi_thread")] - async fn a_handoff_during_the_rollback_replace_window_is_blocked_typed() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install_with_knobs(Some(3_000), false); + let _env = FakeClaudeSidecarEnv::install_with_knobs(Some(3_000), false); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); @@ -13820,662 +11697,185 @@ rl.on('line', (line) => { .expect("the retained stamp exists under the durable id") } - /// b8ke focused episode-2 post-cap F3: a STALE/FOREIGN ownership - /// commit tears the runtime down AND the adoption answers ABANDONED — - /// the pre-fix fall-through reported Published for a runtime that no - /// longer exists. The deterministic window: the pre-commit park holds - /// the adoption with its claim granted; the test fences the claimed - /// key (the watchdog's own recovery seizure); the released commit - /// fails, the teardown runs, and the adoption answers ABANDONED. - /// b8ke ext r27 F4 reshape: the durable binding now writes BEFORE the - /// commit (inside the coordinator operation, after the publication), - /// so it HAS legitimately landed by the park point — the stale - /// commit's abandonment no longer implies "nothing durable - /// persists"; the row is the same Bound-after-death shape every - /// crashed session's row has (the ledger's load-bearing normal), and - /// the claim settles typed through the teardown (the key never goes - /// Live). - #[tokio::test(flavor = "multi_thread")] - async fn a_stale_commit_tears_down_and_abandons_after_the_in_operation_binding() { + /// b8ke focused episode-2 round-1 F7: the adoption's claim records the + /// PROVENANCE-derived initiator (the device id) — never the fixed lane + /// string. Captured off the coordinator's `ownership.start.begin` event + /// for the adoption's own claim (operation_id `claude-init-adopt-*`). + #[tokio::test] + async fn the_init_adoption_claim_records_the_provenance_initiator() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink.clone()); - // A live session (the create) whose adoption completes normally. - st.handle_create(dedup_create_msg("req-postcap-f3"), None) + let (sink, _capture_guard) = init_capture::install(); + let provenance = Some(crate::identity_sink::BindProvenance { + client_instance_id: Some("client-e2r7".into()), + device_id: Some("dev-e2r7".into()), + tab_key: None, + asserted_at: 1, + }); + st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) .await; - let created = await_claude_created(&mut rx, "req-postcap-f3").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let _placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The first create's adoption must COMMIT before the pre-commit - // park arms — otherwise it parks there too, and both the `reached` - // flag and the stale-start recovery below see the WRONG claim. - await_create_ownership_committed(&st, fresh_create_durable_id).await; - // The pre-commit park: a SECOND session (fresh map key — the - // ordinary-create shape, no prior durable id) whose adoption - // defers, then claims the fresh durable key, publishes, and parks - // before the commit. - let stale_dur = "abcdefab-abcd-4abc-8abc-abcdefabcde"; - let stale_map_key = "stale-map-key-postcap-f3"; - let pause = std::sync::Arc::new(AdoptionTestPause { - notify: tokio::sync::Notify::new(), - reached: std::sync::atomic::AtomicBool::new(false), - }); - st.set_adoption_commit_test_pause(Some(std::sync::Arc::clone(&pause))); - let (tx, mut rx_done) = tokio::sync::oneshot::channel::>(); - let st2 = st.clone(); - let sink2: Option = Some(sink.clone()); - // Non-default settings: the binding is RECORDABLE, so the pre-fix - // fall-through would have PERSISTED it for the torn-down runtime — - // the `was_recorded` assertion is not vacuous. - let settings = crate::identity_sink::FreshAgentSettings { - model: Some("opus".into()), - ..Default::default() - }; - tokio::spawn(async move { - let _ = st2 - .adopt_session_init_with_completion( - stale_dur, - stale_map_key, - "freshclaude", - Some(&settings), - sink2, - None, - Some(tx), - ) - .await; - }); - // The registration lands (the deferred adoption proceeds to the - // claim + publication and parks before the commit). - insert_fake_claude_session(&st, stale_map_key).await; + let created = await_claude_created(&mut rx, "req-e2r7-f7").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); + // The deferred adoption claims under the minted durable id once the + // create registers the runtime. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + loop { + if matches!( + registry.observe("claude", fresh_create_durable_id).state, + freshell_ownership::OwnershipState::Live { owner, .. } + if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + ) { + break; + } assert!( tokio::time::Instant::now() < deadline, - "the adoption never reached the pre-commit park" + "the provenance-carrying create's adoption must commit" ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; + tokio::time::sleep(std::time::Duration::from_millis(25)).await; } - st.set_adoption_commit_test_pause(None); - - // THE STALE SEIZURE: the watchdog's own recovery path fences the - // claimed key while the adoption is parked. - let recs = registry.recover_stale_starts(0, 0); - assert_eq!(recs.len(), 1); - let rec = recs.into_iter().next().unwrap(); - assert_eq!(rec.session_id, stale_dur); - assert!(matches!( - registry.fence_unconfirmed_stop( - "claude", - stale_dur, - &rec.operation_id, - rec.generation, - freshell_ownership::FenceReason::StaleStart, - ), - freshell_ownership::FenceOutcome::Fenced - )); - - // Release: the commit is stale (the key is Fenced) → teardown + - // ABANDONED, and NOTHING persists for the dead runtime. - pause.notify.notify_one(); - let answer = tokio::time::timeout(std::time::Duration::from_secs(15), &mut rx_done) - .await - .expect("the adoption settles") - .expect("the completion channel survived"); + let events = sink.lock().expect("capture lock").clone(); + let claim = events + .iter() + .find(|(event, _initiator, op)| { + event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" + }) + .expect("the adoption's claim event is captured"); assert_eq!( - answer, None, - "the stale commit answers ABANDONED — never Published for a torn-down runtime" - ); - // r27 F4: the binding wrote INSIDE the operation, BEFORE the commit - // — it legitimately landed for the (now torn-down) runtime, exactly - // like a crashed session's Bound row; the stale commit's - // abandonment is proven by the ABANDONED answer and the claim's - // typed settle, not by row absence. - assert!( - sink.was_recorded("claude", stale_dur), - "the in-operation binding landed before the stale commit was known" + claim.1, "dev-e2r7", + "the adoption claim records the provenance device id, got {:?}", + events ); } - /// b8ke focused episode-2 round-3 F2: a FAILED re-key publication (an - /// in-stream init naming a durable id other than the one the session - /// carries) FAILS the adoption — the completion hook answers `None`, - /// never a live runtime unreachable by its advertised id. The - /// deterministic failure shape: the mint gate armed (a kill's - /// enumeration in flight) → the publication abandons → `Abandoned` - /// + `on_settled` receives None. - #[tokio::test(flavor = "multi_thread")] - async fn a_failed_fork_rekey_publication_fails_the_adoption_through_the_completion() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_kill_whose_envelope_fails_clean_reopens_the_close_gate() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - st.handle_create(dedup_create_msg("req-e2r3-f2"), None) + let (st, mut rx) = state_with_bus(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + st.handle_create(dedup_create_msg("req-gate-abort"), None) .await; - let created = await_claude_created(&mut rx, "req-e2r3-f2").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The first adoption publishes. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st - .cli_index - .lock() - .await - .get(fresh_create_durable_id) - .is_none() - { - assert!( - tokio::time::Instant::now() < deadline, - "the first create's adoption never published" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - // THE FAILED PUBLICATION: the kill's mint gate armed (the session - // enumerated; the gate check alone cannot see the kill's - // completion), then the fork-rekey adoption for the NEW id. - st.close_pending - .lock() - .expect("close-pending lock") - .insert(placeholder.clone(), 1); - let (tx, mut rx_done) = tokio::sync::oneshot::channel::>(); - let outcome = st - .adopt_session_init_with_completion( - "ffeeeeee-ffff-4fff-8fff-ffffffffffff", - &placeholder, - "freshclaude", - None, - None, - None, - Some(tx), + let created = await_claude_created(&mut rx, "req-gate-abort").await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let (pending, pid) = { + let sessions = st.sessions.lock().await; + ( + sessions[¤t].pending.clone(), + sessions[¤t].child.id(), ) - .await; + }; + fake.fail_retires_after(0); + st.handle_kill(kill_msg(¤t)).await; assert!( - matches!(outcome, SessionInitAdoptionOutcome::Abandoned), - "the gated publication abandons (pre-fix: the adoption returned nothing \ - and the handler continued unaliased)" + !await_specific_frame(&mut rx, "freshAgent.killed", ¤t).await["success"] + .as_bool() + .unwrap() ); - let answer = rx_done - .try_recv() - .expect("the completion hook answered synchronously on Abandoned"); + assert!(!st.sessions.lock().await[¤t].identity.is_closing()); + let next = uuid::Uuid::new_v4().to_string(); + st.rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/failed-close", + ) + .await + .unwrap(); assert_eq!( - answer, None, - "the rollback handler receives the FAILED answer" - ); - } - - /// b8ke focused episode-2 round-2 F2: an adoption whose claim is BLOCKED - /// by an UNRELATED operation fails CLOSED. The finding's shape: a fresh - /// create exposes its cliSessionId while the adoption is deferred; - /// another device starts a handoff on the still-vacant canonical key — - /// the deferred adoption then sees Blocked{Handoff} whose op id is NOT - /// this runtime's own owning operation (a plain create's session - /// carries none): the freshly spawned runtime must be TORN DOWN and - /// NOTHING published — never left running unowned while the handoff - /// believes there was no prior owner. The runtime's OWN operation - /// (the session's recorded owning op — the create-resume claim's op id) - /// still proceeds claim-free. - #[tokio::test(flavor = "multi_thread")] - async fn a_foreign_blocked_adoption_tears_down_while_the_own_operation_proceeds() { + st.resolve_session_key(¤t).await.as_deref(), + Some(next.as_str()) + ); + assert_eq!(st.sessions.lock().await[&next].child.id(), pid); + assert!(fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); + assert!(matches!( + st.handle_send(send_msg(&next, "writer remains healthy")) + .await, + ClaudeSendOutcome::Accepted { .. } + )); + st.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refused_kill_releases_the_close_gate_and_native_rekey_proceeds() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - - // (a) The FOREIGN blocker: an ordinary fresh create's session (no - // owning operation) + an UNRELATED handoff holding the key. - st.handle_create(dedup_create_msg("req-e2r2-f2a"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r2-f2a").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder_a = created["sessionId"].as_str().unwrap().to_string(); - // Let the fresh create's OWN adoption commit first (it is the first - // claimant — this part is the pre-existing behavior). - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { .. } - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the fresh create's own adoption must commit" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // A SECOND session registered with NO owning operation (the plain - // create's shape) whose durable id is already blocked by a FOREIGN - // handoff: enter Handoff on the id directly, then adopt for the - // second session under the same id. - let foreign_dur = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - assert!(matches!( - registry.begin_handoff( - "claude", - foreign_dur, - freshell_ownership::RuntimeOwnerKind::Terminal, - "foreign-handoff-op", - None, - "other-device", - 1, - ), - freshell_ownership::BeginOutcome::Granted { .. } - )); - // The second session (a plain create's session: owning op None). - st.sessions - .lock() - .await - .get_mut(&placeholder_a) - .expect("session") - .owning_operation = None; - st.sessions - .lock() - .await - .get_mut(&placeholder_a) - .expect("session") - .cli_session_id = None; - st.adopt_session_init(foreign_dur, &placeholder_a, "freshclaude", None, None, None) - .await; - // The teardown is DETACHED (the adoption's caller is the session's - // own consumer — the standard teardown aborts it, so the sequence - // runs in a spawned task): bounded-poll for the fail-closed result. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st.sessions.lock().await.contains_key(&placeholder_a) { - assert!( - tokio::time::Instant::now() < deadline, - "the foreign-blocked adoption must TEAR the runtime down (pre-fix: \ - published over it and left the sidecar running unowned)" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - assert!( - st.cli_index.lock().await.get(foreign_dur).is_none(), - "the foreign-blocked adoption publishes NOTHING" - ); - assert!( - !matches!( - registry.observe("claude", foreign_dur).state, - freshell_ownership::OwnershipState::Live { - owner, - .. - } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ), - "the foreign handoff's key never becomes FreshAgent-owned by the adoption" - ); - - // (b) The OWN operation: a session whose recorded owning op IS the - // blocking operation — the under-ticket handoff-target shape. The - // adoption proceeds claim-free (the owning operation performs the - // one commit); the runtime stays. - let own_dur = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - assert!(matches!( - registry.begin_handoff( - "claude", - own_dur, - freshell_ownership::RuntimeOwnerKind::FreshAgent, - "own-runner-op", - None, - "runner", - 2, - ), - freshell_ownership::BeginOutcome::Granted { .. } - )); - // A second live session carrying the runner's op id. - st.handle_create(dedup_create_msg("req-e2r2-f2b"), None) - .await; - let created_b = await_claude_created(&mut rx, "req-e2r2-f2b").await; - let placeholder_b = created_b["sessionId"].as_str().unwrap().to_string(); - { - let mut sessions = st.sessions.lock().await; - let session = sessions.get_mut(&placeholder_b).expect("second session"); - session.owning_operation = Some("own-runner-op".to_string()); - session.cli_session_id = None; - } - st.adopt_session_init(own_dur, &placeholder_b, "freshclaude", None, None, None) - .await; - assert!( - st.sessions.lock().await.contains_key(&placeholder_b), - "the runtime's OWN operation proceeds — no teardown" - ); - assert_eq!( - st.cli_index.lock().await.get(own_dur), - Some(&placeholder_b), - "the own-operation adoption publishes the bookkeeping" - ); - } - - /// b8ke focused episode-2 round-2 F3: the publication revalidates the - /// session's INCARNATION. A kill that COMPLETES between the adoption's - /// read and its publication (the session removed AND its close-pending - /// entry gone — the gate check alone cannot see it) must fail the - /// publication: no dangling alias, no Live commit of the killed - /// runtime's captured pid. Pre-fix the publication returned success and - /// committed the dead runtime as the authoritative owner. - #[tokio::test(flavor = "multi_thread")] - async fn a_completed_kill_before_publication_abandons_the_adoption() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - // The deterministic hold: the adoption parks between its phase-1 - // read (session LIVE, pid captured) and the publication. Armed - // BELOW — only AFTER the first create's own adoption has provably - // published, so the spawned adoption is the ONLY parker (the e2r3 - // F10 fix: the reached flag then belongs to the task under test). - let pause = std::sync::Arc::new(AdoptionTestPause { - notify: tokio::sync::Notify::new(), - reached: std::sync::atomic::AtomicBool::new(false), - }); - - st.handle_create(dedup_create_msg("req-e2r2-f3"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r2-f3").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The durable id the sidecar will mint must differ from the FIRST - // create's own adoption. The FIRST create's adoption must PROVABLY - // complete BEFORE the pause arms — otherwise it parks on the same - // pause and the `reached` flag would prove the WRONG task parked - // (the e2r3 F10 fix: exactly one parker, unambiguously the one - // under test). - let first_dur = fresh_create_durable_id; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st.cli_index.lock().await.get(first_dur).is_none() { - assert!( - tokio::time::Instant::now() < deadline, - "the first create's own adoption never published" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - st.set_adoption_test_pause(Some(std::sync::Arc::clone(&pause))); - let killed_dur = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; - - let st2 = st.clone(); - let ph = placeholder.clone(); - let mut adoption = tokio::spawn(async move { - st2.adopt_session_init(killed_dur, &ph, "freshclaude", None, None, None) - .await; - }); - // b8ke focused episode-2 round-3 F10: the test must PROVE the - // adoption reached the park before the kill — the seam's `reached` - // flag flips exactly at the hold, so a park that never happened - // fails here instead of passing vacuously. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { - assert!( - tokio::time::Instant::now() < deadline, - "the adoption never reached the kill-window park — the test's \ - interleaving did not occur" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - // THE COMPLETED KILL between the read and the publication: the - // session REMOVED and its close-pending entry GONE (the kill - // finished; nothing re-arms the mint gate — the gate check alone - // cannot see this). - let removed = st.sessions.lock().await.remove(&placeholder); - assert!(removed.is_some(), "the kill removed the session"); - assert!( - st.close_pending - .lock() - .expect("close-pending probe") - .get(&placeholder) - .is_none(), - "the completed kill left no close-pending entry" - ); - - // Release the adoption: the publication must fail closed on the - // session-incarnation revalidation. The join must COMPLETE (a - // timeout-elapsed adoption means the release never landed — a - // hang, not a pass). - // e2 post-cap F7: notify_one RETAINS a permit for a waiter that - // has not registered yet — the reached→notify gap cannot lose - // the wake. - pause.notify.notify_one(); - match tokio::time::timeout(std::time::Duration::from_secs(15), &mut adoption).await { - Ok(joined) => { - assert!(joined.is_ok(), "the adoption task completed without panic"); - } - Err(_) => { - panic!("the released adoption never completed — the park release did not land") - } - } - - assert!( - st.cli_index.lock().await.get(killed_dur).is_none(), - "the adoption over a killed session publishes NO alias (pre-fix: \ - the dangling alias landed)" - ); - assert!( - !matches!( - registry.observe("claude", killed_dur).state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the adoption never commits the KILLED runtime as the owner \ - (pre-fix: snapshots reported the dead runtime as authoritative)" - ); - } - - /// b8ke focused episode-2 round-1 F7: the adoption's claim records the - /// PROVENANCE-derived initiator (the device id) — never the fixed lane - /// string. Captured off the coordinator's `ownership.start.begin` event - /// for the adoption's own claim (operation_id `claude-init-adopt-*`). - #[tokio::test] - async fn the_init_adoption_claim_records_the_provenance_initiator() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - let (sink, _capture_guard) = init_capture::install(); - let provenance = Some(crate::identity_sink::BindProvenance { - client_instance_id: Some("client-e2r7".into()), - device_id: Some("dev-e2r7".into()), - tab_key: None, - asserted_at: 1, - }); - st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) - .await; - - let created = await_claude_created(&mut rx, "req-e2r7-f7").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - // The deferred adoption claims under the minted durable id once the - // create registers the runtime. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the provenance-carrying create's adoption must commit" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let events = sink.lock().expect("capture lock").clone(); - let claim = events - .iter() - .find(|(event, _initiator, op)| { - event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" - }) - .expect("the adoption's claim event is captured"); - assert_eq!( - claim.1, "dev-e2r7", - "the adoption claim records the provenance device id, got {:?}", - events - ); - } - - /// F5's abort path: a kill whose ONE envelope fails Clean releases the - /// close gate it armed — the session is genuinely untouched (a later - /// adoption mints normally), exactly like the r2 abort the envelope - /// failure rules promised. - #[tokio::test(flavor = "multi_thread")] - async fn a_kill_whose_envelope_fails_clean_reopens_the_close_gate() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - st.handle_create(dedup_create_msg("req-gate-abort"), None) - .await; - let created = await_claude_created(&mut rx, "req-gate-abort").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - fake.fail_retires_after(0); // every close call fails Clean - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - - assert!( - st.sessions.lock().await.contains_key(&placeholder), - "the failed kill leaves the session MAPPED" - ); - assert!( - st.close_pending - .lock() - .expect("close-pending probe") - .get(&placeholder) - .is_none(), - "the aborted kill released the gate it armed" - ); - // The mint channel is open again: an adoption lands exactly like on - // a never-killed session. - st.adopt_session_init( - "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - &placeholder, - "freshclaude", - None, - Some(fake.clone()), - None, - ) - .await; - assert_eq!( - st.cli_index - .lock() - .await - .get("cccccccc-cccc-4ccc-8ccc-cccccccccccc") - .cloned(), - Some(placeholder.clone()), - "the post-abort adoption mints normally" - ); - } - - /// kata b8ke Task 3 review I-1: a typed kill REFUSAL (the coordinator - /// blocked the stop — here: a handoff owns the session's transition) - /// kills NOTHING, so it must also roll back the pre-kill mint gate it - /// armed — otherwise the still-live session's adoptions abandon - /// forever (the held gate makes `adopt_session_init` discard every - /// mint). The refused kill must leave the session fully operational - /// for a subsequent adoption. - #[tokio::test(flavor = "multi_thread")] - async fn a_refused_kill_releases_the_close_gate_and_adoptions_proceed() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - st.handle_create(dedup_create_msg("req-kill-refused"), None) + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + st.handle_create(dedup_create_msg("req-kill-refused"), None) .await; let created = await_claude_created(&mut rx, "req-kill-refused").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - await_create_ownership_committed(&st, &placeholder).await; - // The refusal precondition: a handoff owns the placeholder's - // transition, so the kill's fenced stop is typed-blocked. - let freshell_ownership::BeginOutcome::Granted { .. } = registry.begin_handoff( + let current = created["sessionId"].as_str().unwrap().to_string(); + let (pending, pid) = { + let sessions = st.sessions.lock().await; + ( + sessions[¤t].pending.clone(), + sessions[¤t].child.id(), + ) + }; + let freshell_ownership::BeginOutcome::Granted { generation, .. } = registry.begin_handoff( PROVIDER, - &placeholder, + ¤t, freshell_ownership::RuntimeOwnerKind::Terminal, "ho-refused-1", None, "test", crate::session_lease::now_epoch_ms() + 1, ) else { - panic!("fixture: the handoff begins from the committed Live state") + panic!("the handoff starts from the committed owner") }; - while rx.try_recv().is_ok() {} - - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - - // The refusal killed nothing: the session stays mapped and the - // answer reports failure. + st.handle_kill(kill_msg(¤t)).await; assert!( - st.sessions.lock().await.contains_key(&placeholder), - "a refused kill must leave the session mapped" - ); - let mut killed_frame = None; - while let Ok(raw) = rx.try_recv() { - let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.killed" { - killed_frame = Some(frame); - } - } - let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); - assert_eq!( - killed_frame["success"], false, - "the refused kill reports success:false: {killed_frame}" + !await_specific_frame(&mut rx, "freshAgent.killed", ¤t).await["success"] + .as_bool() + .unwrap() ); - // THE regression: the mint gate is released, so an adoption lands - // exactly like on a never-killed session. - let gated = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; - st.adopt_session_init( - gated, - &placeholder, + assert!(!st.sessions.lock().await[¤t].identity.is_closing()); + assert!(matches!( + registry.fail(PROVIDER, ¤t, "ho-refused-1", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let next = uuid::Uuid::new_v4().to_string(); + st.rekey_conversation( + ¤t, + &next, "freshclaude", - None, - Some(fake.clone()), - None, + &pending, + "test/refused-close", ) - .await; + .await + .unwrap(); assert_eq!( - st.cli_index.lock().await.get(gated).cloned(), - Some(placeholder.clone()), - "the post-refusal adoption mints normally (the gate was released)" - ); - assert!( - fake.alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(_, p, d)| p == &placeholder && d == gated), - "the post-refusal adoption writes its alias record" + st.resolve_session_key(¤t).await.as_deref(), + Some(next.as_str()) ); + assert_eq!(st.sessions.lock().await[&next].child.id(), pid); + assert!(fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); + assert!(matches!( + st.handle_send(send_msg(&next, "writer remains healthy")) + .await, + ClaudeSendOutcome::Accepted { .. } + )); + st.shutdown().await; } /// b8ke focused round-2 review R2-2: an explicit kill whose teardown @@ -14648,16 +12048,15 @@ rl.on('line', (line) => { // The fake sidecar's default durable cliSessionId (its script emits it // on sdk.session.init when no resume boot was supplied). let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; // The REAL consumer fold adopts the alias (poll, bounded) — the kill // must race NOTHING here: the alias exists before the exit. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -14671,15 +12070,22 @@ rl.on('line', (line) => { // Drive the REAL exit-eviction: the sidecar dies ('__exit__'), the // consumer's EOF arm evicts the session and demotes the alias. - st.handle_send(send_msg(&placeholder, "__exit__")).await; + st.sessions + .lock() + .await + .get_mut(&placeholder) + .unwrap() + .child + .start_kill() + .unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { - if st.sessions.lock().await.is_empty() && st.cli_index.lock().await.is_empty() { + if st.sessions.lock().await.is_empty() { break; } assert!( tokio::time::Instant::now() < deadline, - "consumer exit must still evict the dead session and its live cli_index entries" + "consumer exit must still evict the dead native runtime" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } @@ -14717,79 +12123,108 @@ rl.on('line', (line) => { drop(env); } - /// Focused-ep5-r1 Finding 2 (retire-on-kill round 2), the in-flight - /// adoption race with REAL concurrency (no synchronous-install shortcut): - /// the orphan gate models the production spawn_blocking closure - /// faithfully — the binding write's apply is detached and lands only on - /// release, exactly like a closure that survives the consumer task's - /// abort. The kill lands IN BETWEEN. The durable row must never reappear: - /// the kill's tombstone fences the released write. - #[tokio::test(flavor = "multi_thread")] - async fn handle_kill_while_the_adoption_write_is_in_flight_never_rebounds_the_row() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn handle_kill_suppresses_an_orphan_binding_apply_after_its_receipt() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1_500), false); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - // Arm the gate for the durable the fake sidecar will emit, BEFORE the - // create: the consumer's sdk.session.init adoption write detaches. - let gate = fake.arm_orphan_binding_gate("claude", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); + let task = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_create(dedup_create_msg("orphan-create-binding"), None) + .await + }) + }; + let request = env.create_request().await; + let id = request["sessionId"].as_str().unwrap().to_string(); + let gate = fake.arm_orphan_binding_gate(PROVIDER, &id); + gate.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + st.handle_kill(kill_msg(&id)).await; + gate.release.send(()).unwrap(); + gate.applied.recv_timeout(Duration::from_secs(15)).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(15), task) + .await + .unwrap() + .unwrap(); + // This fixture's receipt is immediate; a successful create may precede kill. + let created = matches!(outcome, ClaudeCreateOutcome::Created { .. }); + assert!(st.resolve_session_key(&id).await.is_none()); + assert!(fake + .suppressed + .lock() + .unwrap() + .contains(&(PROVIDER.into(), id.clone()))); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == id)); + let mut saw_close_ack = false; + let mut saw_created = false; + while let Ok(raw) = rx.try_recv() { + let frame: Value = serde_json::from_str(&raw).unwrap(); + if frame["type"] == "freshAgent.killed" { + saw_close_ack = true; + } + if frame["type"] == "freshAgent.created" { + assert!( + !saw_close_ack, + "created must precede the acknowledged close" + ); + saw_created = true; + } + } + assert_eq!(saw_created, created); + assert!(saw_close_ack); + } - let mut msg = dedup_create_msg("req-orphan-kill"); - msg.cwd = Some("/tmp".to_string()); // settings-bearing => the adoption writes - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-orphan-kill").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").await; - - // The adoption write has been INVOKED (production's spawn-launch - // point) — it is in flight, not yet applied. - gate.entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the sdk.session.init adoption entered the binding write"); - - // The user closes the pane NOW (the bare placeholder — the real wire - // shape) while that write is still in flight. - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - assert!( - fake.kill_tombstones.lock().unwrap().contains_key(&( - "claude".to_string(), - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string() - )), - "the kill folded the durable identity's kill tombstone" - ); - - // The in-flight write lands NOW, after both the kill and its retire. - gate.release.send(()).expect("release the orphan write"); - gate.applied - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the detached apply ran"); - + /// A successful write receipt remains awaited while its binding stall is held. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_close_during_the_awaited_create_binding_refuses_created() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1_500), false); + let (st, mut rx) = state_with_bus(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + let task = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_create(dedup_create_msg("awaited-create-close"), None) + .await + }) + }; + let request = env.create_request().await; + let id = request["sessionId"].as_str().unwrap().to_string(); + let stall = fake.arm_binding_stall(PROVIDER, &id); + stall.entered.recv_timeout(Duration::from_secs(15)).unwrap(); assert!( - !fake - .bindings - .lock() - .unwrap() - .iter() - .any(|b| b.session_id == "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), - "a binding write in flight at kill time must never land the row" + !task.is_finished(), + "the binding receipt is still outstanding" ); + st.handle_kill(kill_msg(&id)).await; + stall.release.send(()).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(15), task) + .await + .unwrap() + .unwrap(); assert!( - fake.suppressed.lock().unwrap().contains(&( - "claude".to_string(), - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string() - )), - "the tombstone suppression of the orphan write is positively observable" + matches!(outcome, ClaudeCreateOutcome::Failed { .. }), + "{outcome:?}" ); - drop(env); + assert!(st.resolve_session_key(&id).await.is_none()); + assert_eq!( + fake.states.lock().unwrap().get(&(PROVIDER.into(), id)), + Some(&crate::identity_sink::FakeRowState::Closed) + ); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } } /// Focused-ep5-r1 Finding 2, the tombstone lifecycle's exit: an EXPLICIT @@ -14804,21 +12239,19 @@ rl.on('line', (line) => { let (st, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; // Create, let the alias land, kill (placeholder — the real wire shape). st.handle_create(dedup_create_msg("req-claim-kill"), None) .await; let created = await_claude_created(&mut rx, "req-claim-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; + let durable = created["sessionId"].as_str().unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -14951,119 +12384,65 @@ rl.on('line', (line) => { } } - /// Bounded-wait until `cli_index[durable] == expected` — the same poll the - /// round-1/2 kill tests use to observe the consumer's adoption fold - /// without racing it. - async fn await_alias(st: &FreshClaudeState, durable: &str, expected: &str) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let present = st - .cli_index - .lock() - .await - .get(durable) - .is_some_and(|mapped| mapped == expected); - if present { - return; - } + async fn await_native_route(st: &FreshClaudeState, id: &str, current: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while st.resolve_session_key(id).await.as_deref() != Some(current) { assert!( tokio::time::Instant::now() < deadline, - "the alias {durable} -> {expected} never landed" + "native id {id} never routed to {current}" ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; + tokio::time::sleep(Duration::from_millis(10)).await; } } - /// Focused-ep5-r2 Finding 2 (retire-on-kill round 3), THE pin: reopen the - /// durable session D, then kill via the OLD pane's placeholder P — the - /// live row must SURVIVE. The reopen (the genuine claim) consumes the - /// stale P→D alias tombstone (and clears the ledger fence for every known - /// alias of D), so the late placeholder-addressed kill retires only the - /// placeholder it names. + /// A successful explicit reopen consumes the old persisted placeholder route. #[tokio::test] async fn reopened_session_survives_a_late_kill_naming_the_old_placeholder() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - // Epoch 1: create + adoption, then the close (bare placeholder — the - // real wire shape), which retires D and demotes P1→D to the alias - // tombstones. - st.handle_create(dedup_create_msg("req-reopen-1"), None) + st.handle_create(dedup_create_msg("legacy-reopen-first"), None) .await; - let created = await_claude_created(&mut rx, "req-reopen-1").await; - let placeholder_old = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder_old, durable).await; - await_alias(&st, durable, &placeholder_old).await; - st.handle_kill(kill_msg(&placeholder_old)).await; - assert!( - fake.kill_tombstones - .lock() - .unwrap() - .contains_key(&("claude".to_string(), durable.to_string())), - "epoch 1 close folded D's fence" - ); - - // Epoch 2: the genuine claim — resume the SAME durable id (the fake - // sidecar echoes the resume boot's id as the cliSessionId). - let mut resume = dedup_create_msg("req-reopen-2"); - resume.resume_session_id = Some(durable.to_string()); - resume.cwd = Some("/tmp".to_string()); + let created = await_claude_created(&mut rx, "legacy-reopen-first").await; + let durable = created["sessionId"].as_str().unwrap(); + let placeholder = "placeholder-old-epoch"; + fake.record_alias_tombstone(PROVIDER, placeholder, durable, 1_000) + .await + .unwrap(); + st.handle_kill(kill_msg(durable)).await; + st.handle_kill(kill_msg(placeholder)).await; + assert!(fake.kill_tombstone_at_ms(PROVIDER, placeholder).is_some()); + let mut resume = dedup_create_msg("legacy-reopen-second"); + resume.resume_session_id = Some(durable.into()); st.handle_create(resume, None).await; - let recreated = await_claude_created(&mut rx, "req-reopen-2").await; - assert_eq!(recreated["type"].as_str().unwrap(), "freshAgent.created"); - let placeholder_new = recreated["sessionId"].as_str().unwrap().to_string(); - await_alias(&st, durable, &placeholder_new).await; - - // The late kill naming the OLD placeholder (the finding's shape — a - // stale pane closes against its original session id). - st.handle_kill(kill_msg(&placeholder_old)).await; - - let retires = fake.retires.lock().unwrap().clone(); + let reopened = await_claude_created(&mut rx, "legacy-reopen-second").await; + assert_eq!(reopened["type"], "freshAgent.created", "{reopened}"); + assert_eq!(reopened["sessionId"], durable); + assert!(fake + .alias_tombstone_records(PROVIDER, placeholder) + .is_empty()); + assert!(fake.kill_tombstone_at_ms(PROVIDER, placeholder).is_none()); + let retire_count = fake + .retires + .lock() + .unwrap() + .iter() + .filter(|(_, id)| id == durable) + .count(); + st.handle_kill(kill_msg(placeholder)).await; assert_eq!( - retires - .iter() - .filter(|(p, s)| p == "claude" && s == durable) - .count(), - 1, - "the reopened durable must NOT be retired by the old placeholder's kill: {retires:?}" - ); - assert!( - retires - .iter() - .any(|(p, s)| p == "claude" && s == &placeholder_old), - "the wire id still retires by name: {retires:?}" - ); - // The claim consumed the stale alias record at reopen time. - let now = crate::session_lease::now_epoch_ms() as i64; - assert!( - st.alias_tombstones - .lock() - .unwrap() - .durables_for(&placeholder_old, now, &st.alias_row_bound_probe()) - .is_empty(), - "the genuine claim must consume the old placeholder's alias tombstone" - ); - // ...and the ledger fence of the consumed alias was cleared at the - // same commit (the reopened identity's every known alias reopens). - assert!( - fake.tombstone_clears - .lock() - .unwrap() - .contains(&("claude".to_string(), placeholder_old.clone())), - "the claim also cleared the old placeholder's own ledger fence" - ); - assert!( - fake.claim_commits + fake.retires .lock() .unwrap() - .contains(&("claude".to_string(), durable.to_string())), - "the claim revived the durable row its close had retired" + .iter() + .filter(|(_, id)| id == durable) + .count(), + retire_count ); - drop(env); + assert!(st.has_live_session(durable).await); + st.shutdown().await; } /// Focused-ep5-r2 Finding 2, the defense-in-depth half: even if a demoted @@ -15084,21 +12463,15 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-live-owner").await; let placeholder_live = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder_live, durable).await; - await_alias(&st, durable, &placeholder_live).await; + inherit_native_fixture(&st, &placeholder_live, durable).await; + await_native_route(&st, durable, &placeholder_live).await; // Fabricate the surviving stale record: the OLD epoch's placeholder // demoted to a tombstone that (hypothetically) escaped consumption. let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record( - "placeholder-old-epoch", - durable, - now, - &st.alias_row_bound_probe(), - ); + fake.record_alias_tombstone(PROVIDER, "placeholder-old-epoch", durable, now) + .await + .unwrap(); st.handle_kill(kill_msg("placeholder-old-epoch")).await; @@ -15114,247 +12487,86 @@ rl.on('line', (line) => { drop(env); } - /// Focused-ep5-r2 Finding 3 (retire-on-kill round 3), the exact - /// interleaving from the report, constructed deterministically: the - /// consumer-exit eviction has removed the session entry but has NOT YET - /// demoted its cli_index alias (and no alias tombstone exists yet) when - /// the kill lands. The kill must still resolve the durable identity via - /// the LIVE alias — consulting only the tombstones misses it (the old - /// no-session branch read tombstones alone). + /// The death record owns native lineage as soon as the live map entry leaves. #[tokio::test] - async fn kill_in_the_exit_eviction_gap_retires_the_durable_via_the_live_alias() { + async fn kill_during_exit_reporting_retires_every_retained_native_id() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - st.handle_create(dedup_create_msg("req-evict-gap"), None) + st.handle_create(dedup_create_msg("kill-during-exit-report"), None) .await; - let created = await_claude_created(&mut rx, "req-evict-gap").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - await_alias(&st, durable, &placeholder).await; - - // THE WINDOW: the eviction's first half (sessions-map removal) has - // run; its second half (cli_index demotion) has NOT. - assert!( - st.sessions.lock().await.remove(&placeholder).is_some(), - "fixture: remove the session entry exactly like the eviction's first half" - ); - assert!( - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for( - &placeholder, - crate::session_lease::now_epoch_ms() as i64, - &st.alias_row_bound_probe() - ) - .is_empty(), - "fixture: no alias tombstone exists yet either" - ); - - // The kill lands NOW — inside the eviction gap. - st.handle_kill(kill_msg(&placeholder)).await; - - let retires = fake.retires.lock().unwrap().clone(); - assert!( - retires.contains(&("claude".to_string(), durable.to_string())), - "a kill inside the exit-eviction gap must still resolve the live alias and \ - retire the durable row: {retires:?}" - ); - drop(env); + let created = await_claude_created(&mut rx, "kill-during-exit-report").await; + let current = created["sessionId"].as_str().unwrap(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, current, &prior).await; + let hold = st.hold_exit_reports(); + st.sessions + .lock() + .await + .get_mut(current) + .unwrap() + .child + .start_kill() + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), hold.parked()) + .await + .unwrap(); + assert!(st.sessions.lock().await.is_empty()); + let dead = st + .dead_sessions + .find(&prior) + .expect("lineage already retained"); + assert!(dead.ids.contains(¤t.to_string())); + st.handle_kill(kill_msg(&prior)).await; + hold.release(); + let retired = fake.retires.lock().unwrap().clone(); + assert!(retired.contains(&(PROVIDER.into(), prior))); + assert!(retired.contains(&(PROVIDER.into(), current.into()))); } - /// Focused-ep5-r3 Finding 2 (retire-on-kill round 4), the eviction-gap - /// interleaving from the report — pinned as a STRUCTURAL property (the - /// exact schedule the finding names is otherwise a microseconds-wide - /// race): the eviction's demote+remove must be ONE critical section - /// holding `cli_index` across the tombstone record, matching the kill - /// consult's `cli_index → sessions → alias_tombstones` lock order, so a - /// kill consult can NEVER observe "neither the live alias nor the - /// tombstone". In the round-3 shape (index released before the tombstone - /// lock) the gap was real: the alias was gone from `cli_index` while the - /// eviction parked on a held tombstones lock, with `cli_index` FREE the - /// whole window. - #[tokio::test(flavor = "multi_thread")] - // The test deliberately widens the eviction's window by holding the - // tombstone lock across awaits (the deterministic staging of the - // finding's interleaving) — never a production shape. - #[allow(clippy::await_holding_lock)] - async fn the_eviction_holds_the_index_across_the_demote_and_remove() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_resume_create_that_fails_midway_keeps_the_fence_and_no_orphan_rebounds_the_row() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - st.handle_create(dedup_create_msg("req-evict-cs"), None) + st.handle_create(dedup_create_msg("resume-failure-first"), None) .await; - let created = await_claude_created(&mut rx, "req-evict-cs").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - await_alias(&st, durable, &placeholder).await; - - // The consumer-exit shape per the report: the session entry is GONE - // (removed) while the alias demotion is mid-flight. - assert!( - st.sessions.lock().await.remove(&placeholder).is_some(), - "fixture: the exit removed the session entry" - ); - - // Widen the eviction's window to a deterministic hold: park the - // tombstones lock BEFORE the eviction can take it, then start the - // eviction. A single-critical-section eviction blocks on the - // tombstones lock STILL HOLDING `cli_index`; the two-phase shape - // releases `cli_index` for the whole window. - let tombstones_hold = st.alias_tombstones.lock().expect("alias tombstones lock"); - let st2 = st.clone(); - let placeholder_for_evict = placeholder.clone(); - let evict = - tokio::spawn(async move { st2.evict_cli_index_aliases(&placeholder_for_evict).await }); - - // Wait for engagement: the eviction has TAKEN `cli_index` (try_lock - // fails while it works). Then the pin: for the entire rest of the - // hold window, `cli_index` must NEVER become free — free once means - // the demote+remove is split and a kill in that window sees neither - // map. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - let mut engaged = false; - while tokio::time::Instant::now() < deadline { - match st.cli_index.try_lock() { - Err(_) => { - engaged = true; - break; - } - Ok(guard) => { - drop(guard); - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } - } - } - assert!(engaged, "the eviction never engaged cli_index"); - for _ in 0..50 { - assert!( - st.cli_index.try_lock().is_err(), - "the eviction must hold cli_index across the demote+remove: a kill in \ - the gap (index free, tombstones not yet recorded) sees neither map" - ); - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - } - - // Release the hold: the eviction completes as ONE transition, and a - // kill naming the bare placeholder now resolves the durable through - // the freshly-recorded tombstone. - let demoted = { - drop(tombstones_hold); - evict.await.expect("the eviction completed") + let created = await_claude_created(&mut rx, "resume-failure-first").await; + let id = created["sessionId"].as_str().unwrap().to_string(); + let old_binding = fake.bindings.lock().unwrap().last().unwrap().clone(); + let gate = fake.arm_orphan_binding_gate(PROVIDER, &id); + let orphan = { + let fake = fake.clone(); + tokio::spawn(async move { fake.record_binding(old_binding).await }) }; - assert_eq!(demoted, vec![durable.to_string()]); - st.handle_kill(kill_msg(&placeholder)).await; - let retires = fake.retires.lock().unwrap().clone(); - assert!( - retires.contains(&("claude".to_string(), durable.to_string())), - "post-eviction the bare-placeholder kill retires the durable via the tombstone: {retires:?}" - ); - drop(env); - } - - /// Focused-ep5-r2 Finding 5 (retire-on-kill round 3): the durable kill - /// fence is cleared only once the replacement session is ESTABLISHED - /// (sidecar created + answered). A resume whose create fails mid-way must - /// KEEP the fence — and then an older in-flight binding write (the - /// orphan-gate shape, released AFTER the failure) is still fenced instead - /// of resurrecting Bound. - #[tokio::test(flavor = "multi_thread")] - async fn a_resume_create_that_fails_midway_keeps_the_fence_and_no_orphan_rebounds_the_row() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - // Arm the orphan gate for D BEFORE the create: the epoch-1 adoption - // write detaches (in-flight when the kill lands). - let gate = fake.arm_orphan_binding_gate("claude", durable); - st.set_identity_sink(fake.clone()); - - let mut msg = dedup_create_msg("req-fence-orphan"); - msg.cwd = Some("/tmp".to_string()); // settings-bearing => the adoption writes - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-fence-orphan").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - gate.entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the adoption write entered (in flight)"); - - st.handle_kill(kill_msg(&placeholder)).await; - assert!( - fake.kill_tombstones - .lock() - .unwrap() - .contains_key(&("claude".to_string(), durable.to_string())), - "the kill folded D's fence" - ); - - // The resume attempt fails MID-WAY: the provider's create dies before - // `created` (the fake sidecar's fail knob — the replacement session - // is never established). + gate.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + st.handle_kill(kill_msg(&id)).await; + let close = fake.kill_tombstone_at_ms(PROVIDER, &id).unwrap(); std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); - let mut resume = dedup_create_msg("req-fence-resume"); - resume.resume_session_id = Some(durable.to_string()); - st.handle_create(resume, None).await; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let raw = tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()) - .await - .expect("a frame arrives") - .expect("bus open"); - let frame: Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.create.failed" - && frame["requestId"] == "req-fence-resume" - { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the failed resume never answered create.failed: {frame}" - ); - } - - // The orphaned epoch-1 write lands NOW — against whatever fence state - // the failed resume left behind. - gate.release.send(()).expect("release the orphan write"); - gate.applied - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the detached apply ran"); - + let mut resume = dedup_create_msg("resume-failure-second"); + resume.resume_session_id = Some(id.clone()); + let outcome = st.handle_create(resume, None).await; assert!( - fake.suppressed - .lock() - .unwrap() - .contains(&("claude".to_string(), durable.to_string())), - "the fence must OUTLIVE the failed resume — the orphan stays fenced; \ - clears={:?}", - fake.tombstone_clears.lock().unwrap() - ); - // The pin is "no Bound resurrection": the gated epoch-1 write is the - // ONLY binding attempt this lane ever ran, so the durable's row never - // reached Bound on the fake's state map at all — the fence held from - // kill through failed resume through orphan release. - assert_ne!( - fake.states - .lock() - .unwrap() - .get(&("claude".to_string(), durable.to_string())) - .copied(), - Some(crate::identity_sink::FakeRowState::Bound), - "no Bound resurrection after a mid-way-failed resume" + matches!(outcome, ClaudeCreateOutcome::Failed { .. }), + "{outcome:?}" + ); + assert_eq!(fake.kill_tombstone_at_ms(PROVIDER, &id), Some(close)); + gate.release.send(()).unwrap(); + gate.applied.recv_timeout(Duration::from_secs(15)).unwrap(); + orphan.await.unwrap().unwrap(); + assert!(fake + .suppressed + .lock() + .unwrap() + .contains(&(PROVIDER.into(), id.clone()))); + assert_eq!( + fake.states.lock().unwrap().get(&(PROVIDER.into(), id)), + Some(&crate::identity_sink::FakeRowState::Closed) ); - drop(env); } /// Focused-ep5-r2 Finding 4 (retire-on-kill round 3), the claude attach @@ -15557,7 +12769,7 @@ rl.on('line', (line) => { "the orphan session was torn down, never registered" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "the durable never aliased into a live session" ); drop(env); @@ -15588,10 +12800,9 @@ rl.on('line', (line) => { ); st.handle_kill(kill_msg(durable)).await; let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record("placeholder-old", durable, now, &st.alias_row_bound_probe()); + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, now) + .await + .unwrap(); assert_eq!( fake.states .lock() @@ -15626,10 +12837,7 @@ rl.on('line', (line) => { "the consumed alias's ledger fence is cleared too" ); assert!( - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for("placeholder-old", now, &st.alias_row_bound_probe()) + fake.alias_tombstone_records(PROVIDER, "placeholder-old") .is_empty(), "the alias tombstone is consumed" ); @@ -15689,10 +12897,9 @@ rl.on('line', (line) => { st.handle_kill(kill_msg(durable)).await; st.handle_kill(kill_msg("placeholder-old")).await; // the alias's own fence let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record("placeholder-old", durable, now, &st.alias_row_bound_probe()); + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, now) + .await + .unwrap(); let stale_snapshot = fake.kill_tombstone_at_ms("claude", durable); // THE KILL DURING THE CLAIM: a newer close advances the durable @@ -15726,10 +12933,8 @@ rl.on('line', (line) => { "the row is never revived" ); assert!( - !st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for("placeholder-old", now, &st.alias_row_bound_probe()) + !fake + .alias_tombstone_records(PROVIDER, "placeholder-old") .is_empty(), "the alias tombstone is never consumed" ); @@ -15756,73 +12961,13 @@ rl.on('line', (line) => { // ── retire-on-kill round 5 (focused-ep5-r4 Findings 1+2+5) ───────────────── - /// Finding 2, the RETENTION rule at the store level: an alias record's - /// lifetime is the ROW lifetime its durable resolves to — TTL expiry and - /// capacity eviction may only discard a record whose row is already - /// Retired-or-GC'd. With a still-Bound row, the record survives ANY age; - /// with the row gone, the 6h TTL applies as before. (The probe parameter - /// is the identity sink's raw `row_is_bound` answer; `|_| true` / - /// `|_| false` stand in for the two row states here.) - #[test] - fn the_alias_record_lives_as_long_as_the_row_it_resolves_to() { - let bound = |_: &str| true; - let gone = |_: &str| false; - let over_ttl = ALIAS_TOMBSTONE_TTL_MS + 60_000; - - let mut kept = AliasTombstones::default(); - kept.record("p", "d", 0, &bound); - assert_eq!( - kept.durables_for("p", over_ttl, &bound), - vec!["d".to_string()], - "a still-Bound row retains its alias mapping past the TTL — the pane's close \ - (days later, by the bare placeholder) must still reach the durable row" - ); - - let mut aged = AliasTombstones::default(); - aged.record("p", "d", 0, &gone); - assert!( - aged.durables_for("p", over_ttl, &gone).is_empty(), - "a Retired-or-GC'd row's alias record ages out on schedule" - ); - } - - /// Finding 2's cap half: capacity eviction consults ROW STATE, not age - /// alone — the oldest record is NOT evicted while its durable row is - /// Bound (a long-lived pane is ordinary); the eviction proceeds against - /// the oldest UNBOUND placeholder, and a wholly bound-set may exceed the - /// cap (correctness over the bound, loudly). - #[test] - fn the_alias_cap_never_evicts_a_record_whose_row_is_still_bound() { - // "d-p0" is Bound; every other durable's row is gone. - let probe = |durable: &str| durable == "d-p0"; - let mut t = AliasTombstones::default(); - for i in 0..(ALIAS_TOMBSTONE_CAP + 4) { - t.record(&format!("p{i}"), &format!("d-p{i}"), i as i64, &probe); - } - // Cap+4 records: exactly 4 placeholders evicted, and the eviction - // order is oldest-UNBOUND-first — p0 (the oldest, but bound) survives - // while p1..p4 (older unbound) go first. - assert_eq!( - t.durables_for("p0", ALIAS_TOMBSTONE_CAP as i64 + 4, &probe), - vec!["d-p0".to_string()], - "the bound-row record survives the cap — age alone never evicts it" - ); - for gone in ["p1", "p2", "p3", "p4"] { - assert!( - t.durables_for(gone, ALIAS_TOMBSTONE_CAP as i64 + 4, &probe) - .is_empty(), - "oldest unbound placeholders are the eviction candidates ({gone} must go)" - ); - } - } - /// Finding 2 at the kill boundary (the report's verbatim failure): the /// pane lived past the alias TTL (long-lived panes are ordinary), the /// sidecar exit-demoted the alias days later, and the pane's close names /// the bare placeholder. The mapping must still resolve to the durable /// row — the kill retires it. #[tokio::test] - async fn handle_kill_after_the_alias_ttl_with_a_still_bound_row_still_retires_the_durable() { + async fn handle_kill_resolves_an_old_persisted_alias_while_its_row_stays_bound() { let (st, _rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); @@ -15837,16 +12982,10 @@ rl.on('line', (line) => { ); // The demoted alias, stamped 7h ago (past the 6h TTL). let now = crate::session_lease::now_epoch_ms() as i64; - let stale_at = now - (ALIAS_TOMBSTONE_TTL_MS + 60 * 60 * 1000); - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record( - "placeholder-old", - durable, - stale_at, - &st.alias_row_bound_probe(), - ); + let stale_at = now - 7 * 60 * 60 * 1000; + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, stale_at) + .await + .unwrap(); st.handle_kill(kill_msg("placeholder-old")).await; @@ -15951,7 +13090,7 @@ rl.on('line', (line) => { "the late orphan session was torn down, never kept" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "the durable's registration alias is gone too" ); assert_eq!( @@ -15971,16 +13110,19 @@ rl.on('line', (line) => { } /// The CREATE-resume lane twin: the post-commit kill lands while the lane - /// is between commit and the sessions/cli_index inserts — the create + /// is between commit and registration in the sessions map — the create /// answers failed and nothing registers. #[tokio::test(flavor = "multi_thread")] async fn a_kill_landing_between_the_commit_and_the_registration_still_closes_the_resumed_create( ) { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); let (state, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); let durable = "cdcdcdcd-cdcd-4cdc-8dcd-cdcdcdcdcdcd"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); fake.seed( "claude", durable, @@ -16039,7 +13181,7 @@ rl.on('line', (line) => { "nothing registers behind a kill the commit pre-dated" ); assert!( - state.cli_index.lock().await.get(durable).is_none(), + state.resolve_session_key(durable).await.as_ref().is_none(), "no registration alias survives" ); assert_eq!( @@ -16078,16 +13220,9 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-sweep-1").await; assert_eq!(created["type"].as_str().unwrap(), "freshAgent.created"); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); // The close the sweep answers for: the fence stands, the row retired. fake.retire_closed("claude", durable) @@ -16124,16 +13259,9 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-sweep-2").await; assert_eq!(created["type"].as_str().unwrap(), "freshAgent.created"); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); // A kill stamped the fence, then a NEWER claim committed against that // stamp and cleared it — the live session is the claim's, not an @@ -16192,10 +13320,9 @@ rl.on('line', (line) => { .kill_tombstone_at_ms("claude", durable) .expect("fixture: the fence stands"); insert_fake_claude_session(&st, "placeholder-late").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "placeholder-late".to_string()); + st.sessions.lock().await["placeholder-late"] + .identity + .inherit([durable.to_string()]); // Hold the sessions map: the sweep passes its fence check (the fence // still stands) and parks AT its removal — so the commit below @@ -16283,10 +13410,9 @@ rl.on('line', (line) => { crate::identity_sink::FakeRowState::Bound, ); insert_fake_claude_session(&st, "placeholder-orphan").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "placeholder-orphan".to_string()); + st.sessions.lock().await["placeholder-orphan"] + .identity + .inherit([durable.to_string()]); st.sweep_late_claim_orphans(&[durable.to_string()]).await; @@ -16312,9 +13438,8 @@ rl.on('line', (line) => { // ── retire-on-kill round 6 (focused-ep5-r5 Finding 2): durable alias tombstones ── /// Finding 2's headline: the kill's alias consult reaches the DURABLE - /// record — the post-restart shape directly: this process's in-memory - /// store, `cli_index`, and sessions map are ALL empty (a restarted server - /// is exactly that), yet the placeholder→durable mapping survives in the + /// record after restart: this process's in-memory sessions map is empty, + /// yet the placeholder→durable mapping survives in the /// persisted alias tombstones, so the close names the bare placeholder /// and still fences + retires the durable row. #[tokio::test] @@ -16338,7 +13463,7 @@ rl.on('line', (line) => { .await .expect("the persisted record"); assert!( - st.cli_index.lock().await.is_empty() && st.sessions.lock().await.is_empty(), + st.sessions.lock().await.is_empty(), "fixture: no process-local mapping anywhere (the restart)" ); @@ -16511,7 +13636,7 @@ rl.on('line', (line) => { ); assert!(state.sessions.lock().await.is_empty(), "nothing registers"); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "no registration alias survives" ); assert!( @@ -16544,11 +13669,15 @@ rl.on('line', (line) => { async fn a_create_resume_uses_its_durable_id_without_writing_a_self_alias() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"resumed"}}])); + let home = tempfile::tempdir().unwrap(); let (st, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); let durable = "e4e4e4e4-e4e4-4e48-8e48-e4e4e4e4e4e4"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let mut msg = dedup_create_msg("req-alias-persist"); msg.session_ref = Some(freshell_protocol::SessionLocator { provider: "claude".to_string(), @@ -16559,7 +13688,7 @@ rl.on('line', (line) => { let placeholder = created["sessionId"].as_str().unwrap().to_string(); assert_eq!(placeholder, durable); - st.handle_send(send_msg(durable, "__emit_assistant__")) + st.handle_send(send_msg(durable, "first resumed turn")) .await; await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; assert!(fake.alias_tombstone_records("claude", durable).is_empty()); @@ -16695,7 +13824,7 @@ rl.on('line', (line) => { "nothing registers when the commit could not run" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "no registration alias survives the failed commit" ); assert_eq!( @@ -16765,7 +13894,7 @@ rl.on('line', (line) => { "nothing registers when the commit could not run" ); assert!( - state.cli_index.lock().await.get(durable).is_none(), + state.resolve_session_key(durable).await.as_ref().is_none(), "no registration alias survives the failed commit" ); assert_eq!( @@ -16851,19 +13980,28 @@ rl.on('line', (line) => { ) } - /// Bounded-wait until the consumer has folded a pending `requestId` permission for + fn permission_runtime() -> FakeClaudeSidecarEnv { + let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"approval", "data":{"tool":"Bash", "input":{"command":"ls"}}}, + {"kind":"hang"}, + ])); + env + } + + /// Bounded-wait until the consumer has folded the SDK's pending permission for /// the session (the respond handlers refuse ids outside the pending set, so the /// fold MUST be observed before responding). - async fn await_pending_permission(st: &FreshClaudeState, session_id: &str, request_id: &str) { + async fn await_pending_permission(st: &FreshClaudeState, session_id: &str) -> String { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let (permissions, _) = pending_request_ids(st, session_id).await; - if permissions.iter().any(|id| id == request_id) { - return; + if let Some(request_id) = permissions.into_iter().next() { + return request_id; } assert!( tokio::time::Instant::now() < deadline, - "pending permission {request_id} never folded into the session" + "the pending permission never folded into the session" ); tokio::time::sleep(std::time::Duration::from_millis(25)).await; } @@ -16927,10 +14065,9 @@ rl.on('line', (line) => { ownership_id: format!("test-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, + owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(pending)), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), @@ -16947,29 +14084,29 @@ rl.on('line', (line) => { #[tokio::test] async fn approval_respond_writes_the_frame_and_removes_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-respond"), None) .await; let created = await_claude_created(&mut rx, "req-approval-respond").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; let decision = json!({ "behavior": "allow", "updatedInput": { "command": "ls -la" } }); st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -16979,14 +14116,14 @@ rl.on('line', (line) => { json!(session_id), "sidecar-keyed sessionId" ); - assert_eq!(respond["requestId"], "req-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["decision"], decision, "the decision payload is a VERBATIM passthrough" ); let (permissions, _) = pending_request_ids(&st, &session_id).await; assert!( - !permissions.iter().any(|id| id == "req-1"), + !permissions.iter().any(|id| id == &request_id), "the resolved entry leaves the pending set" ); drop(env); @@ -17042,23 +14179,23 @@ rl.on('line', (line) => { #[tokio::test] async fn approval_respond_with_a_non_object_decision_is_refused_and_keeps_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-null-decision"), None) .await; let created = await_claude_created(&mut rx, "req-approval-null-decision").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!(null), )) .await; @@ -17069,11 +14206,13 @@ rl.on('line', (line) => { let (permissions, _) = pending_request_ids(&st, &session_id).await; assert_eq!( permissions, - vec!["req-1".to_string()], + vec![request_id.clone()], "a refused non-object decision leaves the entry pending (never hide-then-wedge)" ); assert!( - env.respond_log_frames(0).await.is_empty(), + env.request_frames_of_type("permission.respond", 0) + .await + .is_empty(), "a refused non-object decision is never forwarded to the sidecar" ); drop(env); @@ -17086,20 +14225,20 @@ rl.on('line', (line) => { #[tokio::test] async fn approval_respond_refuses_malformed_updated_permissions_until_a_valid_retry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-bad-updates"), None) .await; let created = await_claude_created(&mut rx, "req-approval-bad-updates").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!({ "behavior": "allow", "updatedPermissions": [ { "type": "grantEverything", "destination": "session" }, ] }), @@ -17112,11 +14251,13 @@ rl.on('line', (line) => { let (permissions, _) = pending_request_ids(&st, &session_id).await; assert_eq!( permissions, - vec!["req-1".to_string()], + vec![request_id.clone()], "a refused decision leaves the request pending" ); assert!( - env.respond_log_frames(0).await.is_empty(), + env.request_frames_of_type("permission.respond", 0) + .await + .is_empty(), "a refused decision never reaches the sidecar" ); @@ -17128,11 +14269,11 @@ rl.on('line', (line) => { st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -17159,21 +14300,21 @@ rl.on('line', (line) => { // cache once this thread's capture is installed. let (events, _capture_guard) = info_capture_for_test::capture(); tracing::callsite::rebuild_interest_cache(); - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-refusal-log"), None) .await; let created = await_claude_created(&mut rx, "req-approval-refusal-log").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; const SECRET: &str = "deploy --token s3cr3t-value"; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!({ "behavior": "allow", "updatedPermissions": [ { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": SECRET }], @@ -17257,43 +14398,40 @@ rl.on('line', (line) => { async fn question_respond_writes_the_frame_with_the_answers_object() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"question", "data":{"questions":[ + {"question":"choice", "header":"Choice", "options":[ + {"label":"yes", "description":"Continue"}, {"label":"no", "description":"Stop"}]}, + {"question":"note", "header":"Note", "options":[ + {"label":"ship it", "description":"Proceed"}, {"label":"wait", "description":"Wait"}]}, + ]}}, + {"kind":"hang"}, + ])); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-question-respond"), None) .await; let created = await_claude_created(&mut rx, "req-question-respond").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Seed the pending question directly (the consumer fold itself is pinned by - // consumer_folds_permission_and_question_frames_into_the_pending_set below). - { - let guard = st.sessions.lock().await; - guard - .get(&session_id) - .unwrap() - .pending - .lock() - .unwrap() - .questions - .push(PendingQuestionEntry { - request_id: "q-1".to_string(), - questions: json!([{ "question": "Continue?" }]), - }); - } + st.handle_send(send_msg(&session_id, "ask the release questions")) + .await; + let question = await_frame_of_inner_type(&mut rx, "freshAgent.question.request").await; + let request_id = question["event"]["requestId"].as_str().unwrap().to_string(); st.handle_question_respond(question_respond_msg( &session_id, - "q-1", + &request_id, &[("choice", "yes"), ("note", "ship it")], )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("question.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "question.respond") .expect("question.respond frame written to the sidecar"); assert_eq!(respond["sessionId"], json!(session_id)); - assert_eq!(respond["requestId"], "q-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["answers"], json!({ "choice": "yes", "note": "ship it" }), @@ -17304,6 +14442,13 @@ rl.on('line', (line) => { questions.is_empty(), "the answered question leaves the pending set" ); + let answer = env.simulator_row("can_use_tool").await; + assert_eq!(answer["toolName"], "AskUserQuestion"); + assert_eq!( + answer["decision"]["updatedInput"]["answers"], + json!({ "choice": "yes", "note": "ship it" }), + "the production sidecar resolves the SDK's parked question" + ); drop(env); } @@ -17403,9 +14548,26 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-compact").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_compact(compact_msg(&session_id, None)).await; - st.handle_compact(compact_msg(&session_id, Some(" focus the diff "))) - .await; + for instructions in [None, Some(" focus the diff ")] { + st.handle_compact(compact_msg(&session_id, instructions)) + .await; + // Each real command owns its turn until the CLI's result. Observe + // all frames through that boundary so an erroneous send ack fails. + loop { + let raw = tokio::time::timeout(Duration::from_secs(15), rx.recv()) + .await + .expect("compaction result in time") + .expect("bus open"); + let frame: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!( + frame["type"], "freshAgent.send.accepted", + "compact emits no send ack: {frame}" + ); + if frame["event"]["type"] == "freshAgent.result" { + break; + } + } + } let frames = env.respond_log_frames(2).await; let sends: Vec<&Value> = frames.iter().filter(|f| f["type"] == "send").collect(); @@ -17418,7 +14580,6 @@ rl.on('line', (line) => { ); // No ack: compact must not reuse handle_send's send.accepted broadcast. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; while let Ok(frame) = rx.try_recv() { let frame: Value = serde_json::from_str(&frame).unwrap(); assert_ne!( @@ -17486,7 +14647,7 @@ rl.on('line', (line) => { #[tokio::test] async fn kilroy_approval_respond_lands_and_removes_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); let mut create = dedup_create_msg("req-kilroy-approval-lands"); create.session_type = SessionType::Kilroy; @@ -17498,23 +14659,23 @@ rl.on('line', (line) => { ); let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the kilroy session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; let decision = json!({ "behavior": "deny", "message": "Denied by user", "interrupt": false }); st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Kilroy, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -17524,14 +14685,14 @@ rl.on('line', (line) => { json!(session_id), "sidecar-keyed sessionId" ); - assert_eq!(respond["requestId"], "req-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["decision"], decision, "the decision payload is a VERBATIM passthrough" ); let (permissions, _) = pending_request_ids(&st, &session_id).await; assert!( - !permissions.iter().any(|id| id == "req-1"), + !permissions.iter().any(|id| id == &request_id), "the resolved entry leaves the pending set" ); drop(env); @@ -17591,14 +14752,11 @@ rl.on('line', (line) => { "test-fold-session".to_string(), "freshclaude".to_string(), "fold-session".to_string(), - None, - Arc::new(std::sync::Mutex::new("fold-session".to_string())), + RuntimeIdentity::new("fold-session", Vec::new()), Arc::clone(&pending), crate::claude_turns::SharedLedger::default(), quiet, crate::attention::AttentionClock::default(), - None, // provenance: test lane is conn-less (D8) - false, ); // The replace-resend is the LAST scripted line: observing its input proves the @@ -17742,60 +14900,27 @@ rl.on('line', (line) => { // ── cliSessionId recording (restart-parity plan §2.8 item 2) ────────────────── - /// The stdout consumer must record `sdk.session.init`'s durable `cliSessionId` in - /// [`FreshClaudeState::cli_index`] (durable id → sessions-map key), and an explicit - /// kill must evict the index entry along with the session. + /// The first native init confirms the create UUID; close removes its live route. #[tokio::test] - async fn session_init_records_cli_session_id_in_the_index() { + async fn session_init_preserves_the_native_create_identity_until_close() { 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-cli-idx-1"), None) + st.handle_create(dedup_create_msg("native-init-route"), None) .await; - let created_frame = await_claude_created(&mut rx, "req-cli-idx-1").await; - let created = created_frame["sessionId"].as_str().unwrap().to_string(); - - st.handle_send(send_msg(&created, "__emit_assistant__")) - .await; - // The fake emits sdk.session.init with the durable uuid; poll until the - // consumer has recorded it (bounded). - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let idx = st.cli_index.lock().await; - if idx.get(created.as_str()) == Some(&created) { - break; - } - } - assert!( - tokio::time::Instant::now() < deadline, - "cli_index never recorded the durable id" - ); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - // The session carries the best-effort copy of the durable id. - assert_eq!( - st.sessions - .lock() - .await - .get(&created) - .unwrap() - .cli_session_id - .as_deref(), - Some(created.as_str()) - ); - // Kill evicts the index entry. - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: created.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - assert!(st.cli_index.lock().await.is_empty()); - drop(env); + let frame = await_claude_created(&mut rx, "native-init-route").await; + let id = frame["sessionId"].as_str().unwrap(); + assert!(matches!( + st.handle_send(send_msg(id, "first turn")).await, + ClaudeSendOutcome::Accepted { .. } + )); + env.simulator_row("prompt").await; + let init = await_frame_of_inner_type(&mut rx, "freshAgent.session.init").await; + assert_eq!(init["event"]["cliSessionId"], id); + assert_eq!(st.sessions.lock().await[id].identity.current(), id); + assert_eq!(st.resolve_session_key(id).await.as_deref(), Some(id)); + st.handle_kill(kill_msg(id)).await; + assert!(st.resolve_session_key(id).await.is_none()); } /// P1.13 (Task 9): the `sdk.session.init` arm must record ONE fresh-agent binding @@ -17825,11 +14950,21 @@ rl.on('line', (line) => { .collect(); assert_eq!(creates.len(), 2); assert_eq!( - creates[0]["plugins"], - json!(["/workspace/.claude/plugins/local"]) + creates[0]["options"]["plugins"], + json!([{"type":"local", "path":"/workspace/.claude/plugins/local"}]) + ); + assert_eq!(creates[1]["options"]["plugins"], Value::Null); + assert!(creates[1]["options"].get("resume").is_none()); + assert!(creates[1]["options"].get("forkSession").is_none()); + for create in &creates { + assert!( + uuid::Uuid::parse_str(create["options"]["sessionId"].as_str().unwrap()).is_ok() + ); + } + assert_ne!( + creates[0]["options"]["sessionId"], + creates[1]["options"]["sessionId"] ); - assert_eq!(creates[1]["plugins"], Value::Null); - assert_eq!(creates[1]["boot"], json!({ "kind": "fresh" })); } #[tokio::test(flavor = "multi_thread")] @@ -17848,10 +14983,7 @@ rl.on('line', (line) => { msg.effort = Some("high".to_string()); msg.cwd = Some(env.dir.to_string_lossy().to_string()); state.handle_create(msg, None).await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-init").await; let b = await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) @@ -17935,10 +15067,7 @@ rl.on('line', (line) => { state .handle_create(dedup_create_msg("req-binding-blank"), None) .await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-blank").await; let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let b = @@ -17967,184 +15096,6 @@ rl.on('line', (line) => { drop(env); } - /// b8ke ext r27 F4: the session-init adoption's durable binding write - /// runs BEFORE the commit-Live (after the alias publication, which a - /// concurrent kill's tombstone fold resolves through) — while the - /// write is parked (the sink's binding stall), the coordinator - /// operation still COVERS the session: the key holds the adoption's - /// `Starting` claim, so a competing handoff BEGIN is refused typed - /// (Blocked). Pre-r27 the commit had already released the operation at - /// this point — the key read Live and a handoff could begin while the - /// binding was blocked. - #[tokio::test(flavor = "multi_thread")] - async fn the_session_init_binding_write_precedes_the_commit_and_holds_the_operation() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(Arc::clone(®istry)); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - let mut msg = dedup_create_msg("req-r27-f4-order"); - msg.model = Some("opus-x".to_string()); - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-r27-f4-order").await; - let map_key = created["sessionId"].as_str().unwrap().to_string(); - prepare_legacy_init(&st, &map_key).await; - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - let stall = fake.arm_binding_stall("claude", fresh_create_durable_id); - let st2 = st.clone(); - let identity = st.identity_sink(); - let create_task = tokio::spawn(async move { - st2.adopt_session_init( - fresh_create_durable_id, - &map_key, - "freshclaude", - None, - identity, - None, - ) - .await; - }); - stall - .entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the adoption reached its binding write"); - - // THE r27 F4 CONTRACT: the write is parked BEFORE the commit — the - // coordinator operation still covers the session. Pre-r27 the key - // read Live here (the commit preceded the write) and a handoff - // could BEGIN while the binding was blocked. - assert!( - matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Starting { .. } - ), - "the binding write runs INSIDE the coordinator operation (the \ - pre-commit Starting claim) — got {:?}", - registry.observe("claude", fresh_create_durable_id).state - ); - assert!( - matches!( - registry.begin_handoff( - "claude", - fresh_create_durable_id, - freshell_ownership::RuntimeOwnerKind::Terminal, - "competing-mid-write", - None, - "test", - crate::session_lease::now_epoch_ms(), - ), - freshell_ownership::BeginOutcome::Blocked { .. } - ), - "a competing handoff cannot begin while the binding write is \ - still inside the coordinator operation" - ); - // Restore the key for the release (the probe handoff never entered). - let _ = registry.fail( - "claude", - fresh_create_durable_id, - "competing-mid-write", - u64::MAX, - false, - ); - - // Release: the publish + commit complete, the create answers green. - let _ = stall.release.send(()); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { .. } - ) { - assert!( - tokio::time::Instant::now() < deadline, - "the adoption never committed after the release" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - let _ = create_task.await; - drop(env); - } - - /// b8ke ext r27 F4: a session-init adoption whose binding write FAILS - /// tears the freshly adopted runtime down and lands the TYPED - /// recoverable state — BEFORE the commit-Live (no owner broadcast, no - /// Live; the ticket's RAII drop settles the claim typed). Pre-r27 the - /// commit had already made the owner authoritative (and broadcast it) - /// before the failed write tore the runtime down underneath it. - #[tokio::test(flavor = "multi_thread")] - async fn a_failed_session_init_binding_write_abandons_before_the_commit() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(Arc::clone(®istry)); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - // The ledger refuses every write (disk-full/permission shape). - let mut msg = dedup_create_msg("req-r27-f4-fail"); - msg.model = Some("opus-x".to_string()); - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-r27-f4-fail").await; - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - assert_eq!(created["type"], "freshAgent.created", "{created}"); - - prepare_legacy_init(&st, created["sessionId"].as_str().unwrap()).await; - while rx.try_recv().is_ok() {} - fake.set_fail_writes(true); - st.adopt_session_init( - fresh_create_durable_id, - created["sessionId"].as_str().unwrap(), - "freshclaude", - None, - st.identity_sink(), - None, - ) - .await; - - // The adoption runs on the sidecar's init event: the binding write - // fails → the runtime is torn down and the adoption ABANDONS - // BEFORE the publish/commit. Bounded-poll for the settled state. - let created_session_id = created["sessionId"] - .as_str() - .unwrap_or_default() - .to_string(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let settled = matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Vacant - ); - let session_gone = !st.has_live_session(&created_session_id).await; - if settled && session_gone { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the failed binding write never settled (state {:?})", - registry.observe("claude", fresh_create_durable_id).state - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - // NO owner broadcast was ever made (the commit never ran). - let mut saw_owner = false; - while let Ok(raw) = rx.try_recv() { - let frame: Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "session.runtimeOwner" - && frame["sessionId"] == json!(fresh_create_durable_id) - { - saw_owner = true; - } - } - assert!( - !saw_owner, - "the failed binding write never broadcast a committed owner" - ); - drop(env); - } - /// D8 lane-reach (restore-open-sessions-only, review round 3): the /// WS-dispatched connection provenance threaded into `handle_create` must /// reach the identity-sink write at `sdk.session.init`. The ledger schema @@ -18170,10 +15121,7 @@ rl.on('line', (line) => { 7_777, ); state.handle_create(msg, Some(provenance)).await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-prov").await; let b = await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) @@ -18235,6 +15183,7 @@ rl.on('line', (line) => { let create_req: serde_json::Value = serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) .unwrap(); + let create_req = &create_req["options"]; assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); assert_eq!(create_req["effort"], "high"); @@ -18316,10 +15265,8 @@ rl.on('line', (line) => { let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); let create_req: serde_json::Value = serde_json::from_str(log.lines().next().unwrap()).unwrap(); - assert_eq!( - create_req["boot"], - json!({ "kind": "resume", "resume": DURABLE }) - ); + let create_req = &create_req["options"]; + assert_eq!(create_req["resume"], json!(DURABLE)); assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -18371,8 +15318,9 @@ rl.on('line', (line) => { let create_req: serde_json::Value = serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) .unwrap(); + let create_req = &create_req["options"]; assert_eq!( - create_req["boot"]["resume"] + create_req["resume"] .as_str() .expect("the resume on the wire"), DURABLE, @@ -18508,8 +15456,8 @@ rl.on('line', (line) => { drop(env); } - /// Ledger A9: consumer exit (== sidecar death) must evict the dead session AND its - /// `cli_index` entries — kill/shutdown are NOT the only eviction paths. Without this, + /// Ledger A9: consumer exit (sidecar death) must evict the dead runtime; + /// kill and shutdown are not the only eviction paths. Without this, /// a dead-but-tracked session makes the tracked⇒no-op attach row strand panes forever. #[tokio::test] async fn consumer_exit_evicts_dead_session_and_index() { @@ -18520,17 +15468,24 @@ rl.on('line', (line) => { .await; let created_frame = await_claude_created(&mut rx, "req-evict-1").await; let created = created_frame["sessionId"].as_str().unwrap().to_string(); - // Kill the sidecar through the public API (fake exits on text "__exit__"), - // then poll (bounded) until the consumer-exit eviction clears BOTH maps. - st.handle_send(send_msg(&created, "__exit__")).await; + // Kill this fixture's sidecar child, then wait for consumer-exit + // eviction to remove the native runtime. + st.sessions + .lock() + .await + .get_mut(&created) + .unwrap() + .child + .start_kill() + .unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { - if st.sessions.lock().await.is_empty() && st.cli_index.lock().await.is_empty() { + if st.sessions.lock().await.is_empty() { break; } assert!( tokio::time::Instant::now() < deadline, - "consumer exit must evict the dead session and its cli_index entries" + "consumer exit must evict the dead native runtime" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } @@ -18563,7 +15518,7 @@ rl.on('line', (line) => { /// A known session's interrupt is forwarded to the sidecar (the Rust-side half of /// the parity fix -- mirrors `adapters/claude/adapter.ts:163-168`'s /// `sdkBridge.interrupt(sessionId)` -> `sp.query.interrupt()`), observed via the - /// fake sidecar's interrupt log since a successful interrupt broadcasts NOTHING + /// production sidecar's captured stdin since a successful interrupt broadcasts NOTHING /// (fire-and-forget, matching legacy exactly -- there is no confirmation frame to /// assert on instead). #[tokio::test] @@ -18577,11 +15532,12 @@ 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. + // C18: a Stop reaches the sidecar only while something runs. The + // simulated model request stays pending until the SDK interrupt. st.handle_send(send_msg(&session_id, "a turn to stop")) .await; await_ledger_busy(&st, &session_id, true).await; + let _ = env.simulator_row("prompt").await; st.handle_interrupt(FreshAgentInterrupt { request_id: None, @@ -18592,7 +15548,7 @@ rl.on('line', (line) => { }) .await; - // Bounded poll for the fake sidecar's interrupt log (it's an OS-level pipe + // Bounded poll for the production sidecar's captured input (an OS-level pipe // write, not synchronous with `handle_interrupt`'s `write_line` await). let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { @@ -18640,6 +15596,7 @@ rl.on('line', (line) => { async fn handle_interrupt_of_an_idle_session_sends_nothing() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"the next turn completes"}}])); 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)); @@ -18657,7 +15614,6 @@ rl.on('line', (line) => { cwd: None, }) .await; - tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( env.interrupt_log_contents(), "", @@ -18674,9 +15630,13 @@ rl.on('line', (line) => { 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}"); + assert_eq!( + env.interrupt_log_contents(), + "", + "the idle Stop never reaches the sidecar, even after the next real turn" + ); } /// Adapter-asymmetry fix (bug-hunt pbh-20260807): an UNREQUESTED sidecar death @@ -19031,10 +15991,10 @@ rl.on('line', (line) => { /// u1/a1/u2/a2 chain (uuid + parentUuid linked): the rollback fixture corpus. fn two_turn_transcript() -> String { [ - json!({"type":"user","uuid":"u1","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), - json!({"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), - json!({"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"t3","message":{"role":"user","content":[{"type":"text","text":"prompt two"}]}}), - json!({"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"t4","message":{"role":"assistant","content":[{"type":"text","text":"answer two"}]}}), + json!({"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-1111-4111-8111-111111111111","parentUuid":"11111111-1111-4111-8111-111111111111","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), + json!({"type":"user","uuid":"22222222-2222-4222-8222-222222222222","parentUuid":"aaaaaaaa-1111-4111-8111-111111111111","timestamp":"t3","message":{"role":"user","content":[{"type":"text","text":"prompt two"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-2222-4222-8222-222222222222","parentUuid":"22222222-2222-4222-8222-222222222222","timestamp":"t4","message":{"role":"assistant","content":[{"type":"text","text":"answer two"}]}}), ] .iter() .map(|v| v.to_string()) @@ -19071,8 +16031,8 @@ rl.on('line', (line) => { /// A rollback-fixture live session: a `tee ` child stands in for the /// sidecar — every byte the handler writes to its stdin lands in stdin_log (the /// "no sidecar churn" oracle for the refusal legs) — registered with - /// `cli_session_id` = durable and the cli_index alias, exactly like a - /// fully-initialized live session. + /// its current native identity set to `durable`, retaining `map_key` in + /// its history like a runtime that has changed conversations. async fn insert_rollback_fixture_session( st: &FreshClaudeState, map_key: &str, @@ -19122,24 +16082,21 @@ rl.on('line', (line) => { let clock = crate::attention::AttentionClock::default(); let turn_lock = Arc::new(TokioMutex::new(())); let quiet = deadman::QuietWatch::default(); - let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.to_string())); + let identity = RuntimeIdentity::new(durable, [map_key.to_string()]); let consumer = st.spawn_consumer( reader, - map_key.to_string(), + durable.to_string(), format!("test-{map_key}"), "freshclaude".to_string(), - map_key.to_string(), - None, - Arc::clone(&broadcast_id), + durable.to_string(), + identity.clone(), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), clock.clone(), - None, // provenance: test lane is conn-less (D8) - false, ); st.sessions.lock().await.insert( - map_key.to_string(), + durable.to_string(), ClaudeSession { session_type: "freshclaude".into(), quiet, @@ -19149,11 +16106,10 @@ rl.on('line', (line) => { 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, + sidecar_session_id: durable.to_string(), + owning_generation: None, - broadcast_id, + identity, pending, ledger, clock, @@ -19161,10 +16117,6 @@ rl.on('line', (line) => { launch_genesis: None, }, ); - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); stdin_log } @@ -19182,7 +16134,7 @@ rl.on('line', (line) => { .lock() .await .get(&key) - .and_then(|s| s.cli_session_id.clone()), + .map(|s| s.identity.current()), None => None, }; if let Some(current) = current { @@ -19199,10 +16151,14 @@ rl.on('line', (line) => { } async fn ledger_busy_of(st: &FreshClaudeState, map_key: &str) -> bool { + let key = st + .resolve_session_key(map_key) + .await + .unwrap_or_else(|| map_key.to_string()); st.sessions .lock() .await - .get(map_key) + .get(&key) .map(|s| s.ledger.lock().expect("turn ledger lock").busy()) .unwrap_or(false) } @@ -19222,27 +16178,17 @@ rl.on('line', (line) => { } } - /// 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 - /// freshCode-side set edge, emulating the provider's asynchronous - /// turn-terminal frames (the busy flag was set when the turn was - /// SUBMITTED, not when its result arrives). - async fn inject_raw_send(st: &FreshClaudeState, map_key: &str, text: &str) { - let mut guard = st.sessions.lock().await; - let session = guard.get_mut(map_key).expect("tracked session"); - let frame = - json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": text }); - session.stdin.write(&frame).await.expect("raw stdin write"); - } - /// 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 key = st + .resolve_session_key(map_key) + .await + .expect("the native runtime exists"); let guard = st.sessions.lock().await; let mut ledger = guard - .get(map_key) + .get(&key) .expect("tracked session") .ledger .lock() @@ -19297,7 +16243,7 @@ rl.on('line', (line) => { "no sidecar was spawned/killed for a refused attempt" ); assert!( - st.sessions.lock().await.contains_key("rb-busy"), + st.resolve_session_key("rb-busy").await.is_some(), "the session was never torn down" ); drop(env); @@ -19312,13 +16258,15 @@ rl.on('line', (line) => { // BUSY_TURN with zero teardown/spawn traffic; AFTER it, the gate reopens. let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_CLAUDE_SIM_TIME_SCALE", "10"); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-compact-rb"), None) .await; let created = await_claude_created(&mut rx, "req-compact-rb").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_compact(compact_msg(&session_id, None)).await; + st.handle_compact(compact_msg(&session_id, Some("APIERR:500"))) + .await; assert!( ledger_busy_of(&st, &session_id).await, "a compact arms the ledger BEFORE the sidecar write" @@ -19345,14 +16293,8 @@ rl.on('line', (line) => { "no teardown" ); - // 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; + // Even failed compaction settles its own command before rollback reopens. + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; await_ledger_busy(&st, &session_id, false).await; let (sink2, captured2) = capturing_sink(); st.handle_rollback( @@ -19362,8 +16304,13 @@ rl.on('line', (line) => { .await; let frames2 = captured_json(&captured2); assert_eq!( - frames2[0]["event"]["code"], "NOTHING_TO_UNDO", - "the busy gate reopened once the compact turn's sdk.result landed: {frames2:?}" + frames2[0]["event"]["type"], "freshAgent.rolledBack", + "{frames2:?}" + ); + let replacement = frames2[0]["event"]["newSessionId"].as_str().unwrap(); + assert_eq!( + st.resolve_session_key(&session_id).await.as_deref(), + Some(replacement) ); drop(env); } @@ -19386,7 +16333,7 @@ rl.on('line', (line) => { assert_eq!(frames[0]["event"]["rollback"], json!(true)); assert_eq!(frames[0]["event"]["requestId"], "rb-r-1"); assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); - assert!(st.sessions.lock().await.contains_key("rb-redo-none")); + assert!(st.resolve_session_key("rb-redo-none").await.is_some()); } #[tokio::test] @@ -19418,7 +16365,7 @@ rl.on('line', (line) => { // is NEVER mutated: no stdin bytes, no sidecar spawn/kill, session intact. assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); assert_eq!(env.spawn_count(), 0); - assert!(st.sessions.lock().await.contains_key("rb-nowrite")); + assert!(st.resolve_session_key("rb-nowrite").await.is_some()); assert!( sink_impl.rollbacks.lock().unwrap().is_empty(), "no durable row landed" @@ -19436,7 +16383,7 @@ rl.on('line', (line) => { let moved = format!( "{}\n{}", two_turn_transcript(), - json!({"type":"user","uuid":"u3","parentUuid":"a2","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt three"}]}}) + json!({"type":"user","uuid":"33333333-3333-4333-8333-333333333333","parentUuid":"aaaaaaaa-2222-4222-8222-222222222222","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt three"}]}}) ); write_rollback_transcript(home.path(), "orig-moved", &moved); // The current (post-undo) transcript is the prefix only. @@ -19455,7 +16402,7 @@ rl.on('line', (line) => { .record_rollback("claude", "dur-moved-tip", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("orig-moved".to_string()); - r.original_tip_uuid = Some("a2".to_string()); // the tip observed at undo time + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); // the tip observed at undo time r.set_can_redo(true, 100); r }) @@ -19489,7 +16436,7 @@ rl.on('line', (line) => { let (st, mut rx) = state_with_bus(); insert_fake_claude_session_with_pending( &st, - "rb-pending", + "dur-pending", Some("dur-pending"), &[ json!({"type":"sdk.permission.request","sessionId":"rb-pending","requestId":"req-1","tool":{"name":"Bash","input":{"command":"ls"}},"toolUseID":"toolu_1"}), @@ -19497,13 +16444,13 @@ rl.on('line', (line) => { ], ) .await; - st.emit_pending_cancellations("rb-pending", "dur-pending", "freshclaude") + st.emit_pending_cancellations("dur-pending", "dur-pending", "freshclaude") .await; let perm = await_frame_of_inner_type(&mut rx, "freshAgent.permission.cancelled").await; assert_eq!(perm["event"]["requestId"], json!("req-1"), "{perm}"); let question = await_frame_of_inner_type(&mut rx, "freshAgent.question.cancelled").await; assert_eq!(question["event"]["requestId"], json!("q-1"), "{question}"); - let (permissions, questions) = pending_request_ids(&st, "rb-pending").await; + let (permissions, questions) = pending_request_ids(&st, "dur-pending").await; assert!( permissions.is_empty() && questions.is_empty(), "decision 6: cancelled means cancelled — pending cards are never silently resolved" @@ -19524,10 +16471,10 @@ rl.on('line', (line) => { // S' (the CURRENT live session): a fork of O (u1/a1 prefix) whose user // RESENT an edited prompt — its own u-prime/a-prime turn. let s_prime_transcript = [ - json!({"type":"user","uuid":"u1","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), - json!({"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), - json!({"type":"user","uuid":"uq","parentUuid":"a1","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt two edited"}]}}), - json!({"type":"assistant","uuid":"aq","parentUuid":"uq","timestamp":"t6","message":{"role":"assistant","content":[{"type":"text","text":"answer two edited"}]}}), + json!({"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-1111-4111-8111-111111111111","parentUuid":"11111111-1111-4111-8111-111111111111","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), + json!({"type":"user","uuid":"44444444-4444-4444-8444-444444444444","parentUuid":"aaaaaaaa-1111-4111-8111-111111111111","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt two edited"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-4444-4444-8444-444444444444","parentUuid":"44444444-4444-4444-8444-444444444444","timestamp":"t6","message":{"role":"assistant","content":[{"type":"text","text":"answer two edited"}]}}), ] .iter() .map(|v| v.to_string()) @@ -19542,12 +16489,12 @@ rl.on('line', (line) => { .record_rollback("claude", "s-prime", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("orig-epoch".to_string()); - r.original_tip_uuid = Some("a2".to_string()); + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); r.push_entry( RollbackEntry { removed_turns: vec![ - json!({"id":"u2","turnId":"u2","role":"user","summary":"prompt two","items":[]}), - json!({"id":"a2","turnId":"a2","role":"assistant","summary":"answer two","items":[]}), + json!({"id":"22222222-2222-4222-8222-222222222222","turnId":"22222222-2222-4222-8222-222222222222","role":"user","summary":"prompt two","items":[]}), + json!({"id":"aaaaaaaa-2222-4222-8222-222222222222","turnId":"aaaaaaaa-2222-4222-8222-222222222222","role":"assistant","summary":"answer two","items":[]}), ], prompt_text: "prompt two".to_string(), at_ms: 100, @@ -19597,12 +16544,15 @@ rl.on('line', (line) => { 1, "exactly the rollback's fork create: {creates:?}" ); - assert_eq!(creates[0]["boot"]["resume"], json!("s-prime")); - assert_eq!(creates[0]["boot"]["resumeSessionAt"], json!("a1")); - assert_eq!(creates[0]["boot"]["kind"], json!("fork")); + assert_eq!(creates[0]["options"]["resume"], json!("s-prime")); + assert_eq!( + creates[0]["options"]["resumeSessionAt"], + json!("aaaaaaaa-1111-4111-8111-111111111111") + ); + assert_eq!(creates[0]["options"]["forkSession"], json!(true)); assert_eq!( - creates[0]["boot"]["dropsTurn"], - json!("uq"), + creates[0]["options"]["resumeDropsTurn"], + json!("44444444-4444-4444-8444-444444444444"), "the guard is the first-to-discard turn's prompt uuid (brief/SDK semantics)" ); @@ -19617,7 +16567,7 @@ rl.on('line', (line) => { ); assert_eq!( record.original_tip_uuid.as_deref(), - Some("aq"), + Some("aaaaaaaa-4444-4444-8444-444444444444"), "S' chain tip" ); assert!( @@ -19640,7 +16590,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( first_entry_ids, - vec!["u2", "a2"], + vec![ + "22222222-2222-4222-8222-222222222222", + "aaaaaaaa-2222-4222-8222-222222222222" + ], "the frozen prior-epoch markers stay first (decision 6; r3)" ); let second_entry_ids: Vec<&str> = record.entries[1] @@ -19650,7 +16603,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( second_entry_ids, - vec!["uq", "aq"], + vec![ + "44444444-4444-4444-8444-444444444444", + "aaaaaaaa-4444-4444-8444-444444444444" + ], "the newest epoch's removed slice" ); // Delta-r1 F8 case (a): the destroy bit at load opened a NEW epoch — the @@ -19675,25 +16631,37 @@ rl.on('line', (line) => { .expect("snapshot builds over the staged s-prime transcript"); let bucket = snap["rolledBackTurns"].as_array().expect("bucket"); assert_eq!(bucket.len(), 4); - assert_eq!(bucket[0]["turnId"], json!("u2")); + assert_eq!( + bucket[0]["turnId"], + json!("22222222-2222-4222-8222-222222222222") + ); assert_eq!( bucket[0]["restorable"], json!(false), "frozen prior-epoch USER marker" ); - assert_eq!(bucket[1]["turnId"], json!("a2")); + assert_eq!( + bucket[1]["turnId"], + json!("aaaaaaaa-2222-4222-8222-222222222222") + ); assert_eq!( bucket[1]["restorable"], json!(false), "frozen prior-epoch ASSISTANT marker — ALL roles are stamped" ); - assert_eq!(bucket[2]["turnId"], json!("uq")); + assert_eq!( + bucket[2]["turnId"], + json!("44444444-4444-4444-8444-444444444444") + ); assert_eq!( bucket[2]["restorable"], json!(true), "current-epoch USER marker" ); - assert_eq!(bucket[3]["turnId"], json!("aq")); + assert_eq!( + bucket[3]["turnId"], + json!("aaaaaaaa-4444-4444-8444-444444444444") + ); assert_eq!( bucket[3]["restorable"], json!(true), @@ -19725,7 +16693,7 @@ rl.on('line', (line) => { assert_eq!(frames2[0]["event"]["type"], "freshAgent.redone"); assert_eq!( frames2[0]["event"]["restoredThroughTurnId"], - json!("aq"), + json!("aaaaaaaa-4444-4444-8444-444444444444"), "redone restores through the restored step's OWN last uuid (r3 boundary rule)" ); assert_eq!( @@ -19741,14 +16709,17 @@ rl.on('line', (line) => { .collect(); assert_eq!(creates.len(), 2, "{creates:?}"); assert_eq!( - creates[1]["boot"]["resume"], + creates[1]["options"]["resume"], json!("s-prime"), "redo re-forks the re-rooted chain root" ); - assert_eq!(creates[1]["boot"]["resumeSessionAt"], json!("aq")); - assert_eq!(creates[1]["boot"]["kind"], json!("fork")); + assert_eq!( + creates[1]["options"]["resumeSessionAt"], + json!("aaaaaaaa-4444-4444-8444-444444444444") + ); + assert_eq!(creates[1]["options"]["forkSession"], json!(true)); assert!( - creates[1]["boot"].get("dropsTurn").is_none(), + creates[1]["options"].get("resumeDropsTurn").is_none(), "the guard is omitted when the discard range is empty (redo to the tip) — never fabricated" ); let record2 = sink_impl @@ -19766,7 +16737,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( remaining_ids, - vec!["u2", "a2"], + vec![ + "22222222-2222-4222-8222-222222222222", + "aaaaaaaa-2222-4222-8222-222222222222" + ], "only the frozen prior-epoch markers remain" ); assert!(!record2.can_redo()); @@ -19930,7 +16904,7 @@ rl.on('line', (line) => { .record_rollback("claude", "dur-rf", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("dur-rf-orig".to_string()); - r.original_tip_uuid = Some("a2".to_string()); + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); r.set_can_redo(true, 100); r }) @@ -19962,7 +16936,7 @@ rl.on('line', (line) => { assert_eq!(env.spawn_count(), 0, "a read failure forks nothing"); assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); assert!( - st.sessions.lock().await.contains_key("rb-rf"), + st.resolve_session_key("rb-rf").await.is_some(), "the session was never torn down" ); drop(env); @@ -20008,7 +16982,7 @@ rl.on('line', (line) => { let stops: Vec<_> = super::teardown_tests::capture::teardown_events(&events) .into_iter() .filter(|(_, fields)| { - fields.get("session_id").map(String::as_str) == Some("rb-graceful") + fields.get("session_id").map(String::as_str) == Some("dur-graceful") }) .collect(); assert_eq!( @@ -20103,11 +17077,11 @@ rl.on('line', (line) => { out } - /// A configure applies the settings to the LIVE sidecar session (the fake - /// mirrors the real configure lane: setModel + the sdk.configured receipt) + /// A configure applies the settings through the production sidecar's SDK + /// controls (setModel and the sdk.configured receipt) /// and broadcasts `freshAgent.session.metadata` so every device's model /// surfaces converge. The ledger row's settings re-snapshot through the - /// existing adopt_session_init leg configure_for_send performs. + /// existing configure_for_send persistence path. #[tokio::test] async fn configure_applies_settings_and_broadcasts_session_metadata() { let _env_guard = CLAUDE_ENV_LOCK.lock().await; @@ -20161,13 +17135,14 @@ rl.on('line', (line) => { assert_eq!(metadata["event"]["effort"], json!("high")); } - /// The fake sidecar's scripted refusal (model 'unavailable') surfaces on + /// An SDK setModel refusal injected at the SDK method boundary surfaces on /// the pane-banner error channel with the session's flavour, and NO /// metadata converges a value the live session never took. #[tokio::test] async fn a_refused_configure_surfaces_the_banner_error_and_converges_nothing() { let _env_guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_MODEL", "unavailable"); let (state, rx) = state_with_bus(); let mut rx = rx; state diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs index b0e55abde..64b3950f0 100644 --- a/crates/freshell-freshagent/src/claude/attach.rs +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -54,23 +54,37 @@ impl FreshClaudeState { msg.resume_session_id = None; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) | Err(()) => return, - Ok(false) => {} + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) + .await; + return; + } + Err(()) => return, + Ok(None) => {} } // A competing attach may already be booting this conversation. // Wait for it, then inspect the runtime again before launching. let _flight = self.wait_resume_flight(&durable).await; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) | Err(()) => return, - Ok(false) => {} + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) + .await; + return; + } + Err(()) => return, + Ok(None) => {} } match self.resume_untracked(&msg, &durable, fence, true).await { Ok(()) => { // This operation advanced its own claim. The incoming // pre-launch fence no longer names its committed owner. match self.reply_live_attach(&msg, &previous, None, &reply).await { - Ok(true) | Err(()) => {} - Ok(false) => attach_error( + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) + .await; + } + Err(()) => {} + Ok(None) => attach_error( &reply, &msg, &previous, @@ -105,16 +119,25 @@ impl FreshClaudeState { previous: &str, fence: Option, reply: &FrameSink, - ) -> Result { + ) -> Result, ()> { let Some(key) = self.resolve_session_key(&msg.session_id).await else { - return Ok(false); + return Ok(None); + }; + let Some(native_id) = self + .sessions + .lock() + .await + .get(&key) + .map(|runtime| runtime.identity.current()) + else { + return Ok(None); }; let operation = format!("claude-attach-{}", uuid::Uuid::new_v4()); let guard = match fence { Some(fence) => crate::ownership_lane::arm_adopt_guard( &self.ownership, PROVIDER, - &msg.session_id, + &native_id, &operation, &freshell_ownership::OwnerIdentity { kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, @@ -129,7 +152,7 @@ impl FreshClaudeState { None => crate::ownership_lane::arm_attach_guard( &self.ownership, PROVIDER, - &msg.session_id, + &native_id, &operation, None, "freshclaude/attach", @@ -150,28 +173,25 @@ impl FreshClaudeState { .lock() .await .get(&key) + .filter(|runtime| runtime.identity.current() == native_id) .map(ClaudeSession::current_status); let Some(status) = status else { - return Ok(false); + return Ok(None); }; - if previous != msg.session_id { + if previous != native_id { reply( - self.materialized_message( - previous, - &msg.session_id, - session_type_str(msg.session_type), - ) - .await, + self.materialized_message(previous, &native_id, session_type_str(msg.session_type)) + .await, ); } reply(status_snapshot_frame( - &msg.session_id, - &msg.session_id, + &native_id, + &native_id, &status, session_type_str(msg.session_type), )); drop(guard); - Ok(true) + Ok(Some(native_id)) } } diff --git a/crates/freshell-freshagent/src/claude/binding.rs b/crates/freshell-freshagent/src/claude/binding.rs new file mode 100644 index 000000000..e27e8de84 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/binding.rs @@ -0,0 +1,204 @@ +//! Establishment binds recoverable identity before its acknowledgement. +//! Later settings updates preserve that identity without claiming or adopting +//! another conversation. Fresh genesis covers the zero-turn transcript window. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn record_established_binding( + &self, + session_id: &str, + session_type: &str, + create_request_id: Option<&str>, + provenance: Option<&crate::BindProvenance>, + ticket: &Option, + ) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (settings, genesis, owning_generation) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + std::io::Error::other("Claude stopped while recording its create identity") + })?; + ( + session.configuration.settings.clone(), + session.launch_genesis.clone(), + session.owning_generation, + ) + }; + let generation = ticket + .as_ref() + .map(|ticket| ticket.generation()) + .or(owning_generation); + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.into(), + mode: session_type.into(), + create_request_id: create_request_id.map(str::to_string), + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { + crate::identity_sink::GenesisUpdate::Set(recorded.genesis) + }), + provenance: provenance.cloned().into(), + observed_epoch: generation.and( + self.ownership + .as_ref() + .map(|registry| registry.boot_epoch()), + ), + observed_generation: generation, + authoritative: ticket.is_none() && owning_generation.is_some(), + settings, + }) + .await + } + + /// The persisted binding/genesis verifies durable routing independently of + /// native transcript flush. Naming can bind to this identity at create. + pub(super) async fn bind_create_name( + &self, + session_id: &str, + session_type: &str, + naming_handle: Option<&str>, + ) -> Option<( + freshell_protocol::session_names::SessionNameRef, + freshell_protocol::session_names::SessionNameRecord, + )> { + if session_type == "kilroy" + || !self.identity_sink().is_some_and(|sink| { + sink.load_genesis(PROVIDER, session_id).is_some() + || sink.was_recorded(PROVIDER, session_id) + }) + { + return None; + } + let handle = naming_handle?.trim(); + if handle.is_empty() { + return None; + } + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.into(), + }; + let transition = crate::naming::NameTransition::binding( + crate::naming::NameTransitionReason::InitialMaterialization, + freshell_protocol::native_location::NativeAcquisition { + location: freshell_protocol::native_location::NativeLocation::Claude { + config_root: self.transcript_roots().first()?.display().to_string(), + transcript_path: None, + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, + persistence: freshell_protocol::native_location::NativePersistence::Verified, + }, + handle.to_string(), + ); + let update = + crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) + .await?; + Some((update.record.name_ref.clone(), update.record)) + } + + pub(super) async fn persist_runtime_settings(&self, ownership_id: &str) -> Result<(), String> { + if let Err(error) = self.write_runtime_settings(ownership_id).await { + // The SDK may already have applied some setters. Retain that + // actual state and retry persistence before a later prompt. + if let Some(session) = self + .sessions + .lock() + .await + .values_mut() + .find(|session| session.ownership_id == ownership_id) + { + session.configuration.needs_configure = true; + } + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.settings_persist_failed", + provider = PROVIDER, ownership_id, cause = %error, + "the runtime retained its settings; persistence must be retried"); + return Err("Claude could not save the session settings. Try again.".into()); + } + Ok(()) + } + + async fn write_runtime_settings(&self, ownership_id: &str) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (session_id, session_type, settings, stamp, _guard) = { + let sessions = self.sessions.lock().await; + // The receipt's process tag survives a native reset. Resolve the + // current UUID from that exact runtime after the control await. + let (id, session) = sessions + .iter() + .find(|(_, session)| session.ownership_id == ownership_id) + .ok_or_else(|| std::io::Error::other("the configured runtime ended"))?; + let stamp = match &self.ownership { + Some(_) => Some( + crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, id) + .filter(|stamp| { + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() == Some(id.as_str()) + && stamp.owner.pid == session.child.id() + }) + .ok_or_else(|| { + std::io::Error::other("the configured runtime's ownership changed") + })?, + ), + None => None, + }; + let observed = stamp + .as_ref() + .map(|stamp| freshell_ownership::ObservedFence { + epoch: stamp.epoch, + generation: stamp.generation, + }); + let guard = match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + id, + &format!("claude-settings-{}", uuid::Uuid::new_v4()), + observed, + "claude/settings-persist", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { + return Err(std::io::Error::other(message)); + } + }; + ( + id.clone(), + session.session_type.clone(), + session.configuration.settings.clone(), + stamp, + guard, + ) + }; + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.clone(), + mode: session_type.clone(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: crate::identity_sink::GenesisUpdate::Keep, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: stamp.as_ref().map(|stamp| stamp.epoch), + observed_generation: stamp.as_ref().map(|stamp| stamp.generation), + authoritative: false, + settings, + }) + .await?; + tracing::debug!(target: "freshell_freshagent::claude", + event = "freshagent.claude.settings_persisted", provider = PROVIDER, + session_id, session_type, ownership_id, + "the established conversation's actual settings were saved"); + Ok(()) + } +} diff --git a/crates/freshell-freshagent/src/claude/create_binding.rs b/crates/freshell-freshagent/src/claude/create_binding.rs deleted file mode 100644 index 3835023e7..000000000 --- a/crates/freshell-freshagent/src/claude/create_binding.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! A create establishes recoverable identity before its acknowledgement. The -//! native transcript may not exist until the first prompt; Fresh genesis and -//! the create-request lineage make that zero-turn identity durable meanwhile. - -use super::*; - -impl FreshClaudeState { - pub(super) async fn record_create_binding( - &self, - session_id: &str, - msg: &FreshAgentCreate, - provenance: Option<&crate::BindProvenance>, - ticket: &Option, - ) -> std::io::Result<()> { - let Some(sink) = self.identity_sink() else { - return Ok(()); - }; - let (settings, genesis) = { - let sessions = self.sessions.lock().await; - let session = sessions.get(session_id).ok_or_else(|| { - std::io::Error::other("Claude stopped while recording its create identity") - })?; - ( - session.configuration.settings.clone(), - session.launch_genesis.clone(), - ) - }; - let generation = ticket.as_ref().map(|ticket| ticket.generation()); - sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { - provider: PROVIDER.into(), - session_id: session_id.into(), - mode: session_type_str(msg.session_type).into(), - create_request_id: Some(msg.request_id.clone()), - resolves_pending: None, - supersedes: None, - name_transition: None, - genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { - crate::identity_sink::GenesisUpdate::Set(recorded.genesis) - }), - provenance: provenance.cloned().into(), - observed_epoch: generation.and( - self.ownership - .as_ref() - .map(|registry| registry.boot_epoch()), - ), - observed_generation: generation, - authoritative: false, - settings, - }) - .await - } - - /// The persisted binding/genesis verifies durable routing independently of - /// native transcript flush. Naming can bind to this identity at create. - pub(super) async fn bind_create_name( - &self, - session_id: &str, - session_type: &str, - ) -> Option<( - freshell_protocol::session_names::SessionNameRef, - freshell_protocol::session_names::SessionNameRecord, - )> { - if session_type == "kilroy" - || !self.identity_sink().is_some_and(|sink| { - sink.load_genesis(PROVIDER, session_id).is_some() - || sink.was_recorded(PROVIDER, session_id) - }) - { - return None; - } - let handle = self.naming_handles.lock().await.get(session_id).cloned()?; - let target = freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: session_id.into(), - }; - let transition = crate::naming::NameTransition::binding( - crate::naming::NameTransitionReason::InitialMaterialization, - freshell_protocol::native_location::NativeAcquisition { - location: freshell_protocol::native_location::NativeLocation::Claude { - config_root: self.transcript_roots().first()?.display().to_string(), - transcript_path: None, - project_directory_key: None, - transcript_cwd: None, - effective_project_key_override: None, - }, - evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, - persistence: freshell_protocol::native_location::NativePersistence::Verified, - }, - handle, - ); - let update = - crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) - .await?; - if update.record.name_ref == target { - self.naming_handles.lock().await.remove(session_id); - } - Some((update.record.name_ref.clone(), update.record)) - } -} diff --git a/crates/freshell-freshagent/src/claude/deadman.rs b/crates/freshell-freshagent/src/claude/deadman.rs index 889bd4223..9520aac59 100644 --- a/crates/freshell-freshagent/src/claude/deadman.rs +++ b/crates/freshell-freshagent/src/claude/deadman.rs @@ -123,11 +123,7 @@ impl FreshClaudeState { .lock() .expect("quiet deadman lock") .feed(busy && !pending, window); - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); if let Some(waiter) = session.quiet.waiter.take() { waiter.abort(); if busy && pending { @@ -188,8 +184,11 @@ impl FreshClaudeState { return; } let sessions = self.sessions.lock().await; + let Some(current_key) = Self::session_key_in(&sessions, &map_key) else { + return; + }; let Some(session) = sessions - .get(&map_key) + .get(¤t_key) .filter(|session| session.ownership_id == ownership_id) else { return; @@ -202,16 +201,12 @@ impl FreshClaudeState { if !ledger.busy() || session.pending.lock().expect("pending lock").len() > 0 { return; } - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); self.publish_locked_status(&mut ledger, &stamp, &session.session_type, false, true); tracing::warn!(provider = PROVIDER, session_id = %stamp, ownership_id = %ownership_id, phase = "fired", generation, "freshagent.claude.quiet_deadman"); self.edges.quiet_deadman(&crate::claude_edges::EdgeRecord { - session_key: &map_key, + session_key: ¤t_key, stamp: &stamp, sidecar_session_id: &session.sidecar_session_id, session_type: &session.session_type, 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 c9ad2244a..bfe9da175 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -50,7 +50,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', (l if (msg.type === 'create') { const sessionId = 'ending-sidecar-session' out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) + out({ type: 'sdk.session.init', sessionId, cliSessionId: msg.sessionId || DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) out({ type: 'sdk.status', sessionId, status: 'idle' }) } else if (msg.type === 'send') { if (MODE === 'stream-ends') { @@ -610,7 +610,7 @@ async fn a_requested_stop_kills_the_same_tree_and_reports_nothing() { assert_eq!(session.count(is_exit_report), 0, "{:?}", session.frames); assert_eq!(session.count(is_turn_complete), 0, "{:?}", session.frames); assert!(session.st.dead_sessions.find(&session.pane).is_none()); - assert!(session.st.dead_sessions.find(DURABLE).is_none()); + assert!(session.st.dead_sessions.find(&session.pane).is_none()); assert!(records(&events, "freshagent.claude.sidecar_exit").is_empty()); let teardowns = capture::teardown_events(&events); assert_eq!(teardowns.len(), 1, "{teardowns:?}"); diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index fc44446fb..953e6e1c8 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -7,17 +7,15 @@ //! commit the booted sidecar through [`FreshClaudeState::establish_runtime`]: //! //! ```text -//! boot → naming admission (create) → kill-fence claim commit (resumes) -//! → provisional-key rekey (fresh create) → register the session -//! → alias records (resumes) → post-registration dead-state recheck -//! → lease completion → coordinator commit (resumes) +//! boot native UUID → close-fence claim commit (resumes) +//! → create binding and ownership → register runtime and consumer +//! → post-registration dead-state recheck → lease completion +//! → coordinator commit (resumes) //! ``` //! -//! The guards are the ones the create and attach handlers used to run inline, -//! moved here unchanged, so the kill/claim/commit race tests pin this one path. //! An undo or redo establishes its replacement runtime here too (C13): it boots -//! the replacement under the id the rollback minted, registered as already -//! adopted, while the original is still live; the rollback then moves +//! the replacement under its new native UUID while the original is still live; +//! the rollback then moves //! ownership and stops the original (`claude/rollback.rs`). A fork establishes //! its child the same way, beside the parent, which it never touches //! (`claude/fork.rs`, C14). @@ -49,17 +47,6 @@ impl EstablishLane { } } -/// The map key a new session registers under. -pub(super) enum RegisterUnder { - /// The attaching pane's id: an attach-resume (the client routes the pane's - /// frames by it). - Pane(String), - /// A durable id the server minted and the CLI boots under (a rollback's - /// replacement): registered as already adopted, so it is the map key, the - /// envelope stamp and the session's durable id at once. - Adopted(String), -} - /// 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 turn ledger and the attention clock. Only the process changes. @@ -123,20 +110,14 @@ pub(super) struct DurableClaim { pub(super) struct EstablishPlan<'a> { pub(super) lane: EstablishLane, pub(super) spec: ClaudeLaunchSpec, - pub(super) register_under: RegisterUnder, - /// The durable session this launch claims (every resume). `None` for a fresh - /// create: its durable id appears only with its first turn. + pub(super) native_id: String, + /// A resumed conversation's close-state fence. Fresh UUIDs have no prior + /// close to consume. pub(super) claim: Option, - /// A fresh create's coordinator claim is held under its pane-scoped - /// provisional key; the minted session id takes it over once `created` - /// names it (b8ke ext r22 F1). - pub(super) rekey_provisional_claim: bool, /// The settings the session records at `sdk.session.init` (`None`: record /// nothing, the resume of a session that never recorded any, V7/A10). The /// session runs with them, or with the defaults. pub(super) recorded_settings: Option, - /// The lifecycle operation that owns this runtime's coordinator transition. - pub(super) owning_operation: Option, pub(super) owning_generation: Option, pub(super) provenance: Option, /// A create's request: its pending naming handle is admitted under the @@ -194,8 +175,6 @@ pub(super) enum EstablishError { LeaseRevoked, /// The ownership coordinator moved on while the launch registered. OwnershipChanged, - /// Another owner claimed the minted session id during a fresh create. - MintedIdClaimed, } impl EstablishError { @@ -214,10 +193,6 @@ impl EstablishError { "session ownership changed during {}; torn down", lane.name() ), - Self::MintedIdClaimed => { - "the minted session id was claimed by another owner during the create; torn down" - .to_string() - } } } @@ -228,10 +203,9 @@ impl EstablishError { Self::Write(_) => "CLAUDE_SIDECAR_WRITE_FAILED", Self::Binding(_) => "LEDGER_WRITE_FAILED", Self::Boot(failure) => failure.code(), - Self::ClosedInFlight - | Self::LeaseRevoked - | Self::OwnershipChanged - | Self::MintedIdClaimed => "FRESH_AGENT_CREATE_FAILED", + Self::ClosedInFlight | Self::LeaseRevoked | Self::OwnershipChanged => { + "FRESH_AGENT_CREATE_FAILED" + } } } @@ -252,7 +226,6 @@ impl EstablishError { Self::ClosedInFlight => "closed_in_flight", Self::LeaseRevoked => "lease_revoked", Self::OwnershipChanged => "ownership_changed", - Self::MintedIdClaimed => "minted_id_claimed", } } } @@ -593,11 +566,9 @@ impl FreshClaudeState { let EstablishPlan { lane, mut spec, - register_under, + native_id: map_key, claim, - rekey_provisional_claim, recorded_settings, - owning_operation, owning_generation, provenance, naming, @@ -653,32 +624,10 @@ impl FreshClaudeState { // into the session record). let sidecar_pid = child.id(); let minted = created.session_id; - let (map_key, adopted) = match register_under { - RegisterUnder::Pane(pane) => (pane, None), - RegisterUnder::Adopted(id) => (id.clone(), Some(id)), - }; + let mut naming_projection = None; - // Unified agent names (Task 2): a create's pending handle is admitted with - // the minted id BEFORE any failure arm, so no failure strands a handle the - // pane already carries (retries re-ensure the same handle idempotently). - let mut naming_projection = match naming { - Some(msg) => { - self.admit_create_handle(msg, &spec.session_type, &map_key) - .await - } - None => None, - }; - - // Retire-on-kill rounds 2-6: a resume GENUINELY CLAIMS the durable id, and - // the claim commits here, once the replacement is established (`created` - // answered) and strictly before the consumer starts, so its - // `sdk.session.init` adoption write never consults a fence whose clear it - // raced. The commit is conditional on the claim-start dead state: a close - // that landed while the sidecar booted refuses it with no side effects, - // and the just-spawned sidecar is an orphan of a pane the user closed. A - // close fence under the registration seat (the minted id, or the - // attaching pane's id) blocks the commit exactly like one under the - // durable id (round 6). + // A close that landed while this resume booted invalidates its + // captured fence. Refuse it before starting the consumer. if let Some(claim) = claim.as_ref() { if !self .commit_session_claim( @@ -697,63 +646,22 @@ impl FreshClaudeState { } } - // b8ke ext r22 F1: a fresh create's in-flight coordinator record moves - // from its pane-scoped provisional key to the minted id in one atomic - // step (`rekey_starting`; the provisional key becomes the resolution - // alias an idempotent re-drive adopts through). A record already under - // the minted id (a competitor claimed it inside the window) refuses - // typed, and the minted runtime is torn down: never two writers. - if rekey_provisional_claim { - if let Some(ticket) = own_ticket.as_mut() { - let Some(registry) = self.ownership.as_ref() else { - unreachable!("a live ticket implies a wired coordinator"); - }; - match registry.rekey_starting( - PROVIDER, - ticket.session_id(), - &minted, - ticket.operation_id(), - ticket.generation(), - ) { - freshell_ownership::CommitOutcome::Committed => { - ticket.rekey_session_id(&minted); - } - outcome => { - tracing::error!(target: "invariant", - provider = PROVIDER, session_id = %minted, - request_id = %spec.request_id, outcome = ?outcome, - "freshagent.claude.create_rekey_refused: the minted session id \ - was claimed by another owner during the create window — the \ - minted runtime is torn down (kata b8ke ext r22 F1)" - ); - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(EstablishError::MintedIdClaimed); - } - } - } - } - // Start the stdout consumer and register the session under the sessions // map lock (review PRB-C10): the consumer reaches its session only // through that lock, so no line it reads (the boot's command catalog, a // CLI death right after the boot) can find the session missing. - let durable = claim - .as_ref() - .map(|claim| claim.durable.clone()) - .or(adopted); + let durable = map_key.clone(); let configuration_settings = recorded_settings.clone().unwrap_or_default(); - let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.clone())); - log_labels.bind_session(&broadcast_id); + let identity = RuntimeIdentity::new(map_key.clone(), Vec::new()); + let log_identity = identity.clone(); + log_labels.bind_session(move || log_identity.current()); 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_runtime = || { - [Some(map_key.as_str()), durable.as_deref()] - .into_iter() - .flatten() - .find_map(|id| self.dead_sessions.find(id)) - }; + let resumed_runtime = || self.dead_sessions.find(&map_key); + if let Some(dead) = resumed_runtime() { + identity.inherit(dead.ids); + } let InheritedRuntime { turn_lock, ledger, @@ -785,14 +693,11 @@ impl FreshClaudeState { ownership_id.clone(), spec.session_type.clone(), minted.clone(), - recorded_settings, - Arc::clone(&broadcast_id), + identity.clone(), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), clock.clone(), - provenance.clone(), - naming.is_some(), ); self.test_holds.registration().await; sessions.insert( @@ -811,15 +716,8 @@ impl FreshClaudeState { ownership_id: ownership_id.clone(), consumer, sidecar_session_id: minted, - cli_session_id: durable.clone(), - // b8ke focused episode-2 round-2 F2: the lifecycle op that owns - // this runtime's coordinator transition, so the session-init - // adoption's Blocked arm can correlate its own in-flight claim. - owning_operation, - // b8ke ext r27 F2: the handoff's generation (the under-ticket - // target resume) stamps the adoption's binding write. owning_generation, - broadcast_id, + identity, pending, ledger, clock, @@ -834,19 +732,7 @@ impl FreshClaudeState { // 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. - if let Some(durable) = durable.as_deref() { - self.dead_sessions - .forget(&[map_key.as_str(), durable], "resumed"); - } - } - // V5 interleaving 2 (Task 12): the durable id resolves to the session at - // registration, not lazily at `sdk.session.init` (hundreds of ms later, - // exactly when a 1 s loser retry arrives). - if let Some(durable) = durable.as_deref() { - self.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.clone()); + self.dead_sessions.forget(&[map_key.as_str()], "resumed"); } // Test seam (Task 6 round-3 review I-1): park with the target session // registered but the resume unreturned (the handoff abort-window tests). @@ -855,28 +741,6 @@ impl FreshClaudeState { let _ = pause.notified().await; } } - // Round 6 (Finding 2): the seat→durable mapping is durable at registration. - if let Some(durable) = claim - .as_ref() - .map(|claim| claim.durable.as_str()) - .filter(|durable| *durable != map_key) - { - if let Some(sink) = self.identity_sink() { - if let Err(e) = sink - .record_alias_tombstone( - PROVIDER, - &map_key, - durable, - crate::session_lease::now_epoch_ms() as i64, - ) - .await - { - tracing::warn!(error = %e, session = %durable, placeholder = %map_key, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - } - // Round 5 (focused-ep5-r4 Finding 1): the claim's POST-REGISTRATION // dead-state recheck. The commit and a kill's retire serialize on the // ledger guard, so a kill queued behind the commit lands between the @@ -900,7 +764,7 @@ impl FreshClaudeState { } // Demote, never drop: a later kill naming the seat still // resolves the durable. - self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { guard.fail(); } @@ -911,16 +775,22 @@ impl FreshClaudeState { // A created session is already a durable identity, including before // its first prompt. Write lineage/genesis while the ownership claim // still fences the registration, before Live or the acknowledgement. - if let Some(create) = naming { + if naming.is_some() || lane == EstablishLane::AttachResume { if let Err(error) = self - .record_create_binding(&map_key, create, provenance.as_ref(), own_ticket) + .record_established_binding( + &map_key, + &spec.session_type, + naming.map(|create| create.request_id.as_str()), + provenance.as_ref(), + own_ticket, + ) .await { if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { guard.fail(); } @@ -935,7 +805,7 @@ impl FreshClaudeState { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { guard.fail(); } @@ -943,7 +813,11 @@ impl FreshClaudeState { } } naming_projection = self - .bind_create_name(&map_key, &spec.session_type) + .bind_create_name( + &map_key, + &spec.session_type, + naming.and_then(|msg| msg.naming_handle.as_deref()), + ) .await .or(naming_projection); } @@ -965,17 +839,14 @@ impl FreshClaudeState { // durable again (fence re-raised, the revived row re-retired). self.rollback_session_claim(&claim.durable).await; // Finding 1: demote, never drop. - self.evict_cli_index_aliases(&map_key).await; } guard.fail(); // own tree torn down: reopen the key return Err(EstablishError::LeaseRevoked); } } - // kata b8ke Task 3: the registration survived every teardown gate; commit - // `Live{FreshAgent}` under the claimed durable id. A fresh create claims - // no durable id: its canonical commit is the consumer's - // `sdk.session.init` adoption. + // Registration survived every teardown gate. Resumed targets now + // commit their lane claim; fresh creates committed before their ACK. if claim.is_some() { if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &map_key, sidecar_pid) { let event = match lane { @@ -994,7 +865,7 @@ impl FreshClaudeState { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + return Err(EstablishError::OwnershipChanged); } } diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index c434f6966..f02ebc24d 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -20,9 +20,7 @@ use crate::claude_handshake::RESUME_POINT_NOT_FOUND_CODE; use crate::claude_launch::{ClaudeBoot, ClaudeGenesis, ClaudeLaunchSpec}; use crate::claude_snapshot as snap; -use super::establish::{ - EstablishError, EstablishLane, EstablishPlan, GenesisBinding, RegisterUnder, -}; +use super::establish::{EstablishError, EstablishLane, EstablishPlan, GenesisBinding}; use super::history::EffectiveTranscriptError; /// A fork while the parent runs a turn (`BUSY_TURN`). @@ -164,13 +162,7 @@ impl FreshClaudeState { let (turn_lock, durable_before) = { let sessions = self.sessions.lock().await; match sessions.get(&map_key) { - Some(session) => ( - Arc::clone(&session.turn_lock), - session - .cli_session_id - .clone() - .unwrap_or_else(|| map_key.clone()), - ), + Some(session) => (Arc::clone(&session.turn_lock), session.identity.current()), None => { drop(sessions); reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); @@ -379,11 +371,9 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Fork, spec, - register_under: RegisterUnder::Adopted(child.clone()), + native_id: child.clone(), claim: None, - rekey_provisional_claim: false, recorded_settings: Some(launch_settings.clone()), - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), owning_generation: None, provenance: fork_provenance.clone(), naming: None, @@ -517,7 +507,7 @@ impl FreshClaudeState { TurnLockedRequest::Fork, map_key, wire_id, - Some(durable), + durable, turn_lock, ) .await?; @@ -532,10 +522,7 @@ impl FreshClaudeState { } else { live_key.clone() }, - durable: session - .cli_session_id - .clone() - .unwrap_or_else(|| live_key.clone()), + durable: session.identity.current(), ledger: Arc::clone(&session.ledger), settings: session.configuration.settings.clone(), freshell_context: session.configuration.freshell_context.clone(), @@ -547,17 +534,8 @@ impl FreshClaudeState { /// ending on its own, which leaves a dead-session record instead). A parent /// whose sidecar merely died is still the pane's, so its fork goes on. async fn fork_parent_closed(&self, parent_key: &str) -> bool { - let closing = self - .close_pending - .lock() - .expect("close-pending lock") - .get(parent_key) - .is_some_and(|pending| *pending > 0); - if closing { - return true; - } - if self.sessions.lock().await.contains_key(parent_key) { - return false; + if let Some(session) = self.sessions.lock().await.get(parent_key) { + return session.identity.is_closing(); } self.dead_sessions.find(parent_key).is_none() } @@ -592,7 +570,7 @@ impl FreshClaudeState { if let Some(session) = self.sessions.lock().await.remove(child) { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(child).await; + self.leases.clear_binding(PROVIDER, child); if row_written { if let Some(sink) = self.identity_sink() { diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index f5bf19fdc..03c8420b1 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -140,7 +140,7 @@ fn log_genesis_clear(session_id: &str, genesis: &str, trigger: &str, outcome: &G impl FreshClaudeState { /// The durable session id `any_id` names: a live session's adopted claude /// id (or its map key until it has one), resolved like every handler here - /// (map key or durable UUID through `cli_index`); an untracked id (a + /// (map key or durable UUID through native runtime history); an untracked id (a /// disk-only read after a restart) is itself. pub(crate) async fn durable_session_id(&self, any_id: &str) -> String { match self.resolve_session_key(any_id).await { @@ -149,7 +149,7 @@ impl FreshClaudeState { .lock() .await .get(&map_key) - .and_then(|session| session.cli_session_id.clone()) + .map(|session| session.identity.current()) .unwrap_or(map_key), None => any_id.to_string(), } @@ -369,27 +369,14 @@ impl FreshClaudeState { sessions .get(&session_key) .filter(|session| session.ownership_id == ownership_id) - .map(|session| { - ( - session - .cli_session_id - .clone() - .unwrap_or_else(|| session_key.clone()), - session.launch_genesis.clone(), - ) - }) + .map(|session| (session.identity.current(), session.launch_genesis.clone())) } .or_else(|| { state .dead_sessions .find(&session_key) .filter(|dead| dead.ownership_id == ownership_id) - .map(|dead| { - ( - dead.cli_session_id.unwrap_or_else(|| session_key.clone()), - dead.launch_genesis, - ) - }) + .map(|dead| (dead.session_id, dead.launch_genesis)) }); let Some((durable, runtime_genesis)) = captured else { return; @@ -412,8 +399,7 @@ impl FreshClaudeState { if matches!(outcome, GenesisClear::Cleared { .. }) { let mut sessions = state.sessions.lock().await; if let Some(session) = sessions.get_mut(&session_key).filter(|session| { - session.ownership_id == ownership_id - && session.cli_session_id.as_deref().unwrap_or(&session_key) == durable + session.ownership_id == ownership_id && session.identity.current() == durable }) { session.launch_genesis = None; } diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index c2ff7d261..8d759281d 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -1,83 +1,43 @@ -//! The live frames a claude sidecar sends mid-turn (transcript-live plan §4.2, -//! Phase C): API retries, notices, per-message transcript announcements and the -//! CLI's own permission-mode changes, as every client receives them. -//! -//! The sidecar is a small Node fake started through the production create -//! (`handle_create`), so the stdout consumer, its folds and the broadcast are -//! the real ones. A send's text is the list of `sdk.*` frames the fake writes -//! back (as a JSON array), each stamped with the sidecar's session id; a -//! configure is logged and answered the way index.mjs answers it. - -use super::*; +//! Live retry, notice, transcript and permission-mode behavior through the +//! production sidecar, SDK adapter and contract-tested CLI simulator. use super::teardown_tests::capture; +use super::tests::ClaudeTestRuntime; +use super::*; -/// The durable id the fake's session reports. -const DURABLE: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - -/// The fake sidecar; `CONFIGURE_LOG` and `DURABLE` are prepended. -const ECHO_SIDECAR: &str = r#" -const fs = require('node:fs') -const out = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') -require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { - let msg - try { msg = JSON.parse(line) } catch { return } - if (msg.type === 'create') { - const sessionId = 'live-frames-session' - out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) - } else if (msg.type === 'send') { - for (const frame of JSON.parse(msg.text)) out({ ...frame, sessionId: msg.sessionId }) - } else if (msg.type === 'configure') { - fs.appendFileSync(CONFIGURE_LOG, JSON.stringify(msg) + '\n') - out({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: msg.settings }) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// One session of the echo fake, created through the production create. -struct EchoSession { +struct LiveSession { st: FreshClaudeState, rx: tokio::sync::broadcast::Receiver, - /// The pane's id for the session (its map key and envelope id). pane: String, - configure_log: std::path::PathBuf, - /// Every frame read off the bus so far, in order. frames: Vec, - _dir: tempfile::TempDir, + runtime: ClaudeTestRuntime, _env: tokio::sync::MutexGuard<'static, ()>, } -impl EchoSession { +impl LiveSession { async fn start(permission_mode: Option<&str>) -> Self { + Self::start_program(permission_mode, json!([{"kind":"hang"}]), None).await + } + + async fn start_program( + permission_mode: Option<&str>, + steps: Value, + sdk_messages: Option, + ) -> Self { let env = super::tests::CLAUDE_ENV_LOCK.lock().await; - let dir = tempfile::tempdir().expect("fake sidecar directory"); - let script = dir.path().join("echo-sidecar.cjs"); - let configure_log = dir.path().join("configure.log"); - std::fs::write(&configure_log, "").expect("init the configure log"); - let quoted = |text: &str| serde_json::to_string(text).expect("a JS string"); - std::fs::write( - &script, - format!( - "const CONFIGURE_LOG = {}\nconst DURABLE = {}\n{ECHO_SIDECAR}", - quoted(&configure_log.to_string_lossy()), - quoted(DURABLE), - ), - ) - .expect("write the fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().join("claude")); - let (tx, rx) = tokio::sync::broadcast::channel::(256); + let runtime = ClaudeTestRuntime::install(); + runtime.program(steps); + if let Some(messages) = sdk_messages { + std::env::set_var("FRESHELL_TEST_CLAUDE_SDK_MESSAGES", messages.to_string()); + } + let (tx, rx) = tokio::sync::broadcast::channel::(512); let st = FreshClaudeState::new(Arc::new(tx)); let mut session = Self { st, rx, pane: String::new(), - configure_log, frames: Vec::new(), - _dir: dir, + runtime, _env: env, }; let mut create = create_msg("live-frames"); @@ -89,21 +49,17 @@ impl EchoSession { }) .await; assert_eq!(created["type"], "freshAgent.created", "{created}"); - session.pane = created["sessionId"] - .as_str() - .expect("the created session id") - .to_string(); + session.pane = created["sessionId"].as_str().unwrap().to_string(); session } - /// The fake writes `frames` back as the CLI side of a send. - async fn sidecar_writes(&mut self, frames: Value) { - self.st - .handle_send(send_msg(&self.pane, &frames.to_string())) - .await; + async fn send(&self, text: &str) { + assert!(matches!( + self.st.handle_send(send_msg(&self.pane, text)).await, + ClaudeSendOutcome::Accepted { .. } + )); } - /// Read frames until one matches `want` (returned) or 20 s pass. async fn await_frame(&mut self, what: &str, want: impl Fn(&Value) -> bool) -> Value { if let Some(hit) = self.frames.iter().find(|frame| want(frame)) { return hit.clone(); @@ -125,26 +81,29 @@ impl EchoSession { found.unwrap_or_else(|_| panic!("no {what}; frames: {:?}", self.frames)) } - /// The first `freshAgent.event` whose inner event has this type. async fn await_event(&mut self, event_type: &str) -> Value { - let what = format!("a {event_type} event"); - self.await_frame(&what, |frame| { + self.await_frame(event_type, |frame| { frame["type"] == "freshAgent.event" && frame["event"]["type"] == event_type }) .await } - /// The configure lines the fake received. - fn configures(&self) -> Vec { - std::fs::read_to_string(&self.configure_log) + fn rows(&self, file: &str) -> Vec { + std::fs::read_to_string(self.runtime.dir.join(file)) .unwrap_or_default() .lines() .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).expect("a configure line")) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + fn configures(&self) -> Vec { + self.rows("requests.jsonl") + .into_iter() + .filter(|frame| frame["type"] == "configure") .collect() } - /// The status the snapshot overlay states for the session. async fn overlay_status(&self) -> Value { let mut snapshot = json!({}); self.st @@ -154,26 +113,14 @@ impl EchoSession { } } -impl Drop for EchoSession { - fn drop(&mut self) { - // The script and store die with `_dir`: no later test in this binary - // may inherit paths to them (the env lock is still held here). - remove_claude_env(); - } -} - -/// Removes the process-wide variables these tests point at their tempdirs. -fn remove_claude_env() { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); -} - -/// Calls [`remove_claude_env`] when dropped, so a failed assertion cannot leak them. -struct ClaudeEnvReset; - +/// Restores the process store in the snapshot-only environment test. +struct ClaudeEnvReset(Option); impl Drop for ClaudeEnvReset { fn drop(&mut self) { - remove_claude_env(); + match &self.0 { + Some(previous) => std::env::set_var("CLAUDE_CONFIG_DIR", previous), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } } } @@ -253,13 +200,17 @@ fn records( #[tokio::test] async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_turn_running() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(None).await; - session - .sidecar_writes(json!([ - { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, - { "type": "sdk.retry", "attempt": 1, "maxRetries": 10, "delayMs": 512, "errorStatus": 529, "error": "overloaded" }, - ])) - .await; + let mut session = LiveSession::start_program( + None, + json!([ + {"kind":"text", "data":{"text":"working"}}, + {"kind":"api_retry", "data":{"status":529}}, + {"kind":"hang"}, + ]), + None, + ) + .await; + session.send("retry this model request").await; let retry = session.await_event("freshAgent.retry").await; assert_eq!(retry["sessionId"], json!(session.pane), "{retry}"); @@ -267,7 +218,7 @@ async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_tur assert_eq!(retry["sessionType"], "freshclaude"); assert_eq!(retry["event"]["attempt"], 1); assert_eq!(retry["event"]["maxRetries"], 10); - assert_eq!(retry["event"]["delayMs"], 512); + assert_eq!(retry["event"]["delayMs"], 500); assert_eq!(retry["event"]["errorStatus"], 529); assert_eq!(retry["event"]["error"], "overloaded"); @@ -289,16 +240,15 @@ async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_tur #[tokio::test] async fn a_notice_reaches_every_client_as_freshagent_notice_and_keeps_the_turn_running() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(None).await; let rate_limit = - json!({ "status": "rejected", "resetsAt": 1_792_656_000, "rateLimitType": "five_hour" }); - session - .sidecar_writes(json!([ - { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, - { "type": "sdk.notice", "level": "error", "code": "RATE_LIMITED", "message": "You have reached your Claude usage limit.", "rateLimit": rate_limit }, - { "type": "sdk.notice", "level": "info", "code": "INFORMATIONAL", "message": "a private hook said hello" }, - ])) - .await; + json!({"status":"rejected", "resetsAt":1_792_656_000, "rateLimitType":"five_hour"}); + let mut session = LiveSession::start_program(None, json!([ + {"kind":"text", "data":{"text":"working"}}, {"kind":"hang"}, + ]), Some(json!([ + {"type":"rate_limit_event", "uuid":"11111111-1111-4111-8111-111111111111", "rate_limit_info":rate_limit}, + {"type":"system", "subtype":"informational", "uuid":"22222222-2222-4222-8222-222222222222", "level":"suggestion", "content":"a private hook said hello"}, + ]))).await; + session.send("show provider notices").await; let notice = session.await_event("freshAgent.notice").await; assert_eq!(notice["sessionId"], json!(session.pane), "{notice}"); @@ -352,40 +302,46 @@ async fn a_notice_reaches_every_client_as_freshagent_notice_and_keeps_the_turn_r /// unchanged (only the `session-commands` reason is folded server-side). #[tokio::test] async fn a_transcript_announcement_reaches_every_client_as_session_changed() { - let mut session = EchoSession::start(None).await; + let mut session = LiveSession::start_program( + None, + json!([ + {"kind":"replay", "data":{"recording":"tools-sync", "pace":0}}, + ]), + None, + ) + .await; session - .sidecar_writes(json!([ - { "type": "sdk.session.changed", "reason": "transcript", "uuid": "11111111-1111-4111-8111-111111111111", "parentToolUseId": null }, - { "type": "sdk.session.changed", "reason": "transcript", "uuid": "22222222-2222-4222-8222-222222222222", "parentToolUseId": "toolu_agent1" }, - ])) + .send("replay the recorded CLI tool and subagent turn") .await; - - let announced = session - .await_frame("the subagent's announcement", |frame| { - frame["event"]["type"] == "freshAgent.session.changed" - && frame["event"]["parentToolUseId"] == "toolu_agent1" - }) - .await; - assert_eq!(announced["sessionId"], json!(session.pane)); - assert_eq!(announced["event"]["reason"], "transcript"); - assert_eq!( - announced["event"]["uuid"], - "22222222-2222-4222-8222-222222222222" - ); - let main_thread: Vec<&Value> = session - .frames - .iter() - .filter(|frame| { - frame["event"]["type"] == "freshAgent.session.changed" - && frame["event"]["reason"] == "transcript" + session.await_event("freshAgent.result").await; + let expected: Vec = session + .rows("sdk.jsonl") + .into_iter() + .filter(|msg| { + (msg["type"] == "assistant" || msg["type"] == "user") && msg["uuid"].is_string() }) + .map(|msg| json!({"uuid":msg["uuid"], "parentToolUseId":msg["parent_tool_use_id"]})) .collect(); - assert_eq!(main_thread.len(), 2, "{main_thread:?}"); + assert!( + expected + .iter() + .any(|row| row["parentToolUseId"].is_string()), + "the real recording includes a subagent" + ); + assert!( + expected.iter().any(|row| row["parentToolUseId"].is_null()), + "the main conversation also writes messages" + ); + let actual: Vec = session.frames.iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.session.changed" && frame["event"]["reason"] == "transcript") + .map(|frame| { + assert_eq!(frame["sessionId"], session.pane); + json!({"uuid":frame["event"]["uuid"], "parentToolUseId":frame["event"]["parentToolUseId"]}) + }).collect(); assert_eq!( - main_thread[0]["event"]["uuid"], - "11111111-1111-4111-8111-111111111111" + actual, expected, + "every actual SDK transcript message is announced once in order" ); - assert_eq!(main_thread[0]["event"]["parentToolUseId"], Value::Null); } /// The CLI changed its own permission mode (an approved ExitPlanMode): the @@ -396,12 +352,29 @@ async fn a_transcript_announcement_reaches_every_client_as_session_changed() { #[tokio::test] async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(Some("plan")).await; + let mut session = LiveSession::start_program( + Some("plan"), + json!([ + {"kind":"exit_plan", "data":{"plan":"# Plan\n\nContinue the work"}}, + {"kind":"text", "data":{"text":"plan approved"}}, + ]), + None, + ) + .await; + session.send("request approval to exit plan mode").await; + let permission = session.await_event("freshAgent.permission.request").await; session - .sidecar_writes(json!([ - { "type": "sdk.session.metadata", "permissionMode": "default" }, - { "type": "sdk.result", "result": "success", "durationMs": 1, "costUsd": 0, "usage": {} }, - ])) + .st + .handle_approval_respond(FreshAgentApprovalRespond { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session.pane.clone(), + session_type: SessionType::Freshclaude, + request_id: freshell_protocol::StringOrNumber::Str( + permission["event"]["requestId"].as_str().unwrap().into(), + ), + decision: json!({"behavior":"allow"}), + cwd: None, + }) .await; let metadata = session.await_event("freshAgent.session.metadata").await; assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); @@ -447,7 +420,7 @@ async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { /// model and effort pair, as every metadata frame does). #[tokio::test] async fn a_configure_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { - let mut session = EchoSession::start(None).await; + let mut session = LiveSession::start(None).await; session .st .handle_configure(configure_msg(&session.pane, "plan")) @@ -465,7 +438,7 @@ async fn a_configure_that_changes_the_permission_mode_broadcasts_metadata_statin /// that missed the configure still lands here). #[tokio::test] async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { - let mut session = EchoSession::start(Some("plan")).await; + let mut session = LiveSession::start(Some("plan")).await; let mut send = send_msg(&session.pane, "[]"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { cwd: None, @@ -488,7 +461,7 @@ async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it( #[tokio::test] async fn snapshots_read_the_store_the_lanes_sidecars_write() { let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; - let _reset = ClaudeEnvReset; + let _reset = ClaudeEnvReset(std::env::var_os("CLAUDE_CONFIG_DIR")); let process_store = tempfile::tempdir().expect("this process's store"); std::env::set_var("CLAUDE_CONFIG_DIR", process_store.path()); let sidecar_store = tempfile::tempdir().expect("the sidecars' store"); @@ -526,11 +499,21 @@ async fn snapshots_read_the_store_the_lanes_sidecars_write() { } #[tokio::test] -async fn an_echo_session_leaves_the_process_environment_as_it_found_it() { - // The fake's script and store live in a tempdir that dies with the session: - // a later test in this binary must not inherit paths to them. - drop(EchoSession::start(None).await); +async fn a_live_session_restores_the_process_environment() { + let before = { + let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; + ( + std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), + std::env::var_os("CLAUDE_CONFIG_DIR"), + ) + }; + drop(LiveSession::start(None).await); let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; - assert_eq!(std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), None); - assert_eq!(std::env::var_os("CLAUDE_CONFIG_DIR"), None); + assert_eq!( + ( + std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), + std::env::var_os("CLAUDE_CONFIG_DIR") + ), + before + ); } diff --git a/crates/freshell-freshagent/src/claude/pending_runtime.rs b/crates/freshell-freshagent/src/claude/pending_runtime.rs index 185b8a3cf..c6c0b2704 100644 --- a/crates/freshell-freshagent/src/claude/pending_runtime.rs +++ b/crates/freshell-freshagent/src/claude/pending_runtime.rs @@ -42,11 +42,7 @@ impl FreshClaudeState { 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(); + let stamp = session.identity.current(); self.edges.pending_drained(&stamp); } drop(pending); @@ -67,11 +63,7 @@ impl FreshClaudeState { else { return; }; - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); self.publish_claude_status( &session.ledger, &session.quiet.state, diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index e4693ec43..b3d0e578a 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -126,9 +126,6 @@ impl FreshClaudeState { }; } }; - let owning_operation = ticket - .as_ref() - .map(|ticket| ticket.operation_id().to_string()); let outcome = self .resume_for_attach( msg, @@ -138,7 +135,6 @@ impl FreshClaudeState { }, &mut lease, &mut ticket, - owning_operation, None, ) .await; @@ -282,11 +278,7 @@ impl FreshClaudeState { let candidate = sink .as_ref() .and_then(|sink| sink.chain_terminus(PROVIDER, wire_id)) - .or_else(|| { - self.dead_sessions - .find(wire_id) - .and_then(|dead| dead.cli_session_id) - }) + .or_else(|| self.dead_sessions.find(wire_id).map(|dead| dead.session_id)) .or_else(|| is_canonical_claude_uuid(wire_id).then(|| wire_id.to_string()))?; Some( sink.and_then(|sink| sink.chain_terminus(PROVIDER, &candidate)) diff --git a/crates/freshell-freshagent/src/claude/recovery_stop.rs b/crates/freshell-freshagent/src/claude/recovery_stop.rs index 34cce56a1..f89c9af01 100644 --- a/crates/freshell-freshagent/src/claude/recovery_stop.rs +++ b/crates/freshell-freshagent/src/claude/recovery_stop.rs @@ -128,7 +128,6 @@ impl FreshClaudeState { }; // Match the established alias->map lock order. No process I/O or await // follows the claim until the removal, pending drain and end are visible. - let index = self.cli_index.lock().await; let mut sessions = self.sessions.lock().await; let Some(session) = sessions.get(&map_key) else { return Err(( @@ -136,30 +135,16 @@ impl FreshClaudeState { "Claude changed before recovery stopped it. Try again.".into(), )); }; - let durable = session - .cli_session_id - .clone() - .unwrap_or_else(|| map_key.clone()); + let durable = session.identity.current(); let operation = self.claim_recovery_stop(session, &map_key, &durable, fence)?; - let mut aliases: Vec = index - .iter() - .filter(|(_, mapped)| **mapped == map_key) - .map(|(id, _)| id.clone()) - .collect(); - if !aliases.contains(&durable) { - aliases.push(durable.clone()); - } + let aliases = session.identity.ids(); let mut session = sessions .remove(&map_key) .expect("validated recovery runtime"); session.consumer.abort(); session.quiet.cancel(); session.ledger.lock().expect("turn ledger lock").eof(); - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); { let mut pending = session.pending.lock().expect("pending lock"); pending.permissions.clear(); @@ -172,7 +157,6 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session.session_type, durables: &aliases, - cli_session_id: session.cli_session_id.as_deref(), settings: &session.configuration.settings, ownership_id: &session.ownership_id, launch_genesis: session.launch_genesis.as_ref(), @@ -328,7 +312,7 @@ impl FreshClaudeState { record.session_id == map_key || aliases.contains(&record.session_id) }) .await; - self.evict_cli_index_aliases(&map_key).await; + let teardown = self.teardown_removed_session_logged(session).await; let teardown = self.release_leases_after_teardown( teardown, diff --git a/crates/freshell-freshagent/src/claude/rekey.rs b/crates/freshell-freshagent/src/claude/rekey.rs new file mode 100644 index 000000000..d3e30a279 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/rekey.rs @@ -0,0 +1,157 @@ +//! A CLI conversation reset moves one live runtime; it never starts another. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn rekey_conversation( + &self, + old: &str, + new: &str, + session_type: &str, + expected_pending: &Arc>, + reason: &str, + ) -> Result { + if uuid::Uuid::parse_str(new).is_err() { + return Err("Claude reported an invalid conversation id".into()); + } + if old == new { + return Ok(old.into()); + } + // This is a fact from the consumer. A handler can hold turn_lock + // while awaiting this consumer's next receipt, so taking that lock + // here would stop both tasks. The incarnation check and map/owner + // guards below protect the move while its turn state stays intact. + let guard_at = self.ownership.as_ref().map(|registry| { + let observed = registry.observe(PROVIDER, old); + freshell_ownership::ObservedFence { + epoch: observed.epoch, + generation: observed.generation, + } + }); + let old_guard = match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + old, + &format!("claude-reset-{}", uuid::Uuid::new_v4()), + guard_at, + "claude/conversation-reset", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => return Err(message), + }; + let now = crate::session_lease::now_epoch_ms() as i64; + let settings = { + // The map lock serializes close guards and identity moves. + // Ownership and event identity move before metadata IO starts. + let mut sessions = self.sessions.lock().await; + let session = sessions + .get(old) + .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) + .ok_or_else(|| "the conversation runtime was replaced".to_string())?; + if sessions.contains_key(new) { + return Err("the new conversation already has a live runtime".into()); + } + if session.identity.is_closing() { + return Err("the conversation is closing".into()); + } + self.commit_lane_claim_rekey_at( + old, + &mut None, + new, + old, + new, + session.child.id(), + "claude/conversation-reset", + ) + .map_err(|outcome| format!("conversation ownership could not move: {outcome:?}"))?; + let mut session = sessions.remove(old).expect("the validated runtime exists"); + if !self + .leases + .rekey_binding(PROVIDER, old, new, old, new, &session.ownership_id) + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_lease_conflict", provider = PROVIDER, + previous_session_id = old, session_id = new, + "the runtime moved but its lease binding could not follow"); + } + session + .identity + .rekey(new) + .map_err(|error| error.to_string())?; + session.launch_genesis = Some(crate::identity_sink::RecordedGenesis { + genesis: crate::identity_sink::ClaudeGenesis::Fresh, + updated_at_ms: now, + }); + let settings = session.configuration.settings.clone(); + sessions.insert(new.into(), session); + self.ownership_stamps + .lock() + .expect("ownership stamps lock") + .remove(old); + settings + }; + // rekey_live moves the coordinator to a new generation. Protect the + // new key during persistence; a concurrent transition may already own + // it, in which case its authoritative metadata wins. + drop(old_guard); + let new_at = self.ownership.as_ref().map(|registry| { + let observed = registry.observe(PROVIDER, new); + freshell_ownership::ObservedFence { + epoch: observed.epoch, + generation: observed.generation, + } + }); + let new_guard = crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + new, + &format!("claude-reset-binding-{}", uuid::Uuid::new_v4()), + new_at, + "claude/conversation-reset-binding", + ); + if !matches!( + new_guard, + crate::ownership_lane::LaneOpGuard::Refused { .. } + ) { + if let Some(sink) = self.identity_sink() { + if let Err(error) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: new.into(), + mode: session_type.into(), + create_request_id: None, + resolves_pending: None, + supersedes: Some(old.into()), + name_transition: None, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: new_at.map(|at| at.epoch), + observed_generation: new_at.map(|at| at.generation), + authoritative: false, + settings, + genesis: crate::identity_sink::GenesisUpdate::Set( + crate::identity_sink::ClaudeGenesis::Fresh, + ), + }) + .await + { + // The CLI already changed its file. Keep following the + // healthy runtime and report failed metadata persistence. + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_binding_failed", provider = PROVIDER, + previous_session_id = old, session_id = new, cause = %error, + "the conversation reset but its recovery settings could not be saved"); + } + } + } + self.create_dedup + .clear_for_session(|record| record.session_id == old) + .await; + self.broadcast_materialized(old, new, session_type).await; + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.session_rekeyed", provider = PROVIDER, + previous_session_id = old, session_id = new, session_type, reason, + "the live conversation follows its new durable id"); + Ok(new.into()) + } +} diff --git a/crates/freshell-freshagent/src/claude/restart_notice.rs b/crates/freshell-freshagent/src/claude/restart_notice.rs new file mode 100644 index 000000000..a9cc1eec9 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/restart_notice.rs @@ -0,0 +1,93 @@ +//! Durable one-shot notice for a freshclaude turn stopped by server restart. + +use super::{FreshClaudeState, PROVIDER}; +use std::time::Duration; + +const SHUTDOWN_MARK_BUDGET: Duration = Duration::from_millis(500); +const RESTART_INTERRUPTED_CODE: &str = "TURN_INTERRUPTED_BY_RESTART"; +const RESTART_INTERRUPTED_MESSAGE: &str = + "The server restarted while this turn was running. The turn was stopped. Send a message to continue."; + +impl FreshClaudeState { + /// Persist an owed turn before its live sidecar is torn down. Shutdown + /// cannot wait indefinitely on disk I/O, so a slow or failed marker write + /// is logged and the server continues stopping. + pub(super) async fn mark_turn_interrupted_for_shutdown(&self, session_id: &str) { + let Some(sink) = self.identity_sink() else { + return; + }; + let at_ms = crate::rollback_record::now_ms(); + match tokio::time::timeout( + SHUTDOWN_MARK_BUDGET, + sink.mark_turn_interrupted(PROVIDER, session_id, at_ms), + ) + .await + { + Ok(Ok(true)) => tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persisted", + session_id, + at_ms, + "an owed turn was recorded for the next attach" + ), + Ok(Ok(false)) => tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_row_missing", + session_id, + "an owed turn had no bound fresh-agent row for its restart marker" + ), + Ok(Err(error)) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persist_failed", + session_id, + error = %error, + "the restart interruption marker could not be persisted" + ), + Err(_) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persist_timed_out", + session_id, + budget_ms = SHUTDOWN_MARK_BUDGET.as_millis() as u64, + "the restart interruption marker exceeded its shutdown budget" + ), + } + } + + /// Consume and report the marker only after the create/attach acknowledgement + /// has been sent. The ledger serializes the consume, so concurrent + /// acknowledgements can produce at most one notice. + pub(super) async fn notify_turn_interrupted_after_ack( + &self, + session_id: &str, + session_type: &str, + ) { + let Some(sink) = self.identity_sink() else { + return; + }; + match sink.take_turn_interrupted(PROVIDER, session_id).await { + Ok(Some(at_ms)) => { + self.emit_fresh_agent_error( + session_id, + session_type, + RESTART_INTERRUPTED_CODE, + RESTART_INTERRUPTED_MESSAGE, + ); + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_notified", + session_id, + interrupted_at_ms = at_ms, + "the interrupted-turn notice followed a successful acknowledgement" + ); + } + Ok(None) => {} + Err(error) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_consume_failed", + session_id, + error = %error, + "the restart interruption marker was retained for a later acknowledgement" + ), + } + } +} diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 3a1982f14..d752c6c65 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -15,7 +15,7 @@ use crate::claude_snapshot as snap; use crate::rollback_record::*; use super::establish::{ - EstablishError, EstablishLane, EstablishPlan, GenesisBinding, InheritedRuntime, RegisterUnder, + EstablishError, EstablishLane, EstablishPlan, GenesisBinding, InheritedRuntime, }; use super::history::EffectiveTranscriptError; @@ -209,7 +209,7 @@ impl FreshClaudeState { 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.identity.current(), s.ledger.clone(), s.turn_lock.clone(), session_type_str(op.session_type), @@ -921,14 +921,11 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Rollback, spec, - register_under: RegisterUnder::Adopted(replacement.clone()), + native_id: replacement.clone(), claim: None, - rekey_provisional_claim: false, recorded_settings: Some(launch_settings.clone()), // b8ke focused episode-2 round-2 F2: the ROLLBACK's claim // owns this runtime's coordinator transition — the - // adoption's Blocked arm correlates through the op id. - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), owning_generation: None, // Conn-less lane (D8): the ledger merge inherits the // superseded parent's stamps. @@ -1042,10 +1039,7 @@ impl FreshClaudeState { // and the original's sidecar is stopped as a requested teardown (its // record is gone first, so its end of output stays silent; #859's // graceful stop). - if let Some(original) = self - .replace_runtime(&map_key, &durable_id, &replacement) - .await - { + if let Some(original) = self.replace_runtime(&map_key, &replacement).await { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(original).await; } if !lease_held { @@ -1095,38 +1089,23 @@ impl FreshClaudeState { )); } - /// The replacement takes the original's place (design §4.6, the rollback - /// re-key): the original's record leaves the sessions map, and every id - /// that resolved to it (its map key, its durable id, their aliases) - /// resolves to the replacement, as does its pending naming handle. The - /// original's record is returned for its teardown. Lock order `cli_index` - /// then `sessions`, as the adoption's publication takes them. + /// Move the original's native history to the replacement under the map + /// lock. Its close counter belongs to the new process and starts empty. async fn replace_runtime( &self, original_key: &str, - original_durable: &str, replacement: &str, ) -> Option { let original = { - let mut index = self.cli_index.lock().await; let mut sessions = self.sessions.lock().await; let original = sessions.remove(original_key); - for mapped in index.values_mut() { - if mapped == original_key { - *mapped = replacement.to_string(); - } + if let (Some(original), Some(replacement)) = + (original.as_ref(), sessions.get(replacement)) + { + replacement.identity.inherit(original.identity.ids()); } - index.insert(original_key.to_string(), replacement.to_string()); - index.insert(original_durable.to_string(), replacement.to_string()); original }; - let handle = self.naming_handles.lock().await.remove(original_key); - if let Some(handle) = handle { - self.naming_handles - .lock() - .await - .insert(replacement.to_string(), handle); - } // A replayed create requestId of the original must not answer with // its retired map key. self.create_dedup @@ -1147,7 +1126,7 @@ impl FreshClaudeState { if let Some(session) = self.sessions.lock().await.remove(replacement) { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(replacement).await; + if lease_held { self.leases.clear_binding(PROVIDER, original_durable); } diff --git a/crates/freshell-freshagent/src/claude/runtime_identity.rs b/crates/freshell-freshagent/src/claude/runtime_identity.rs new file mode 100644 index 000000000..b3729eeb7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/runtime_identity.rs @@ -0,0 +1,198 @@ +//! One process incarnation's native conversation identity and close guard. +//! Callers take the sessions-map lock before mutating this short mutex. A +//! rekey shares this handle; a replacement process copies only its lineage. + +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Debug)] +pub(super) struct RuntimeIdentity(Arc>); + +#[derive(Debug)] +struct IdentityState { + current: String, + prior: Vec, + close_count: usize, +} + +impl IdentityState { + fn remember_prior(&mut self, id: String) { + if id != self.current && !self.prior.contains(&id) { + self.prior.push(id); + } + } +} + +/// The native conversation cannot move while an operation is closing its +/// runtime. The guard, rather than the session id, owns that operation's count. +#[must_use = "keep this guard until the close operation has settled"] +#[derive(Debug)] +pub(super) struct RuntimeCloseGuard(Arc>); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct RuntimeClosing; + +impl std::fmt::Display for RuntimeClosing { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the runtime is closing") + } +} + +impl std::error::Error for RuntimeClosing {} + +impl RuntimeIdentity { + pub(super) fn new( + current: impl Into, + prior_ids: impl IntoIterator, + ) -> Self { + let mut state = IdentityState { + current: current.into(), + prior: Vec::new(), + close_count: 0, + }; + for id in prior_ids { + state.remember_prior(id); + } + Self(Arc::new(Mutex::new(state))) + } + + pub(super) fn current(&self) -> String { + self.0 + .lock() + .expect("runtime identity lock") + .current + .clone() + } + + pub(super) fn contains(&self, id: &str) -> bool { + let state = self.0.lock().expect("runtime identity lock"); + state.current == id || state.prior.iter().any(|known| known == id) + } + + /// Prior ids in discovery order, followed by the current id once. + pub(super) fn ids(&self) -> Vec { + let state = self.0.lock().expect("runtime identity lock"); + let mut ids = state.prior.clone(); + ids.push(state.current.clone()); + ids + } + + pub(super) fn inherit(&self, ids: impl IntoIterator) { + let mut state = self.0.lock().expect("runtime identity lock"); + for id in ids { + state.remember_prior(id); + } + } + + pub(super) fn begin_close(&self) -> RuntimeCloseGuard { + self.0.lock().expect("runtime identity lock").close_count += 1; + RuntimeCloseGuard(Arc::clone(&self.0)) + } + + pub(super) fn is_closing(&self) -> bool { + self.0.lock().expect("runtime identity lock").close_count > 0 + } + + pub(super) fn rekey(&self, next: &str) -> Result<(), RuntimeClosing> { + let mut state = self.0.lock().expect("runtime identity lock"); + if state.close_count > 0 { + return Err(RuntimeClosing); + } + if state.current != next { + state.prior.retain(|id| id != next); + let previous = std::mem::replace(&mut state.current, next.to_string()); + state.remember_prior(previous); + } + Ok(()) + } +} + +impl Drop for RuntimeCloseGuard { + fn drop(&mut self) { + self.0.lock().expect("runtime identity lock").close_count -= 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + + #[test] + fn rekeys_keep_every_native_id_once_and_inherit_prior_lineage() { + let identity = RuntimeIdentity::new("A", Vec::new()); + identity.rekey("B").unwrap(); + identity.rekey("C").unwrap(); + assert_eq!(identity.current(), "C"); + assert_eq!(identity.ids(), ["A", "B", "C"]); + for id in ["A", "B", "C"] { + assert!(identity.contains(id)); + } + assert!(!identity.contains("unknown")); + identity.inherit(["A", "B", "C", "D", "D"].map(str::to_string)); + let mut ids = identity.ids(); + ids.sort(); + assert_eq!(ids, ["A", "B", "C", "D"]); + identity.rekey("A").unwrap(); + let mut ids = identity.ids(); + ids.sort(); + assert_eq!(identity.current(), "A"); + assert_eq!(ids, ["A", "B", "C", "D"]); + } + + #[test] + fn recovery_copies_lineage_without_sharing_the_old_close_counter() { + let old = RuntimeIdentity::new("A", Vec::new()); + old.rekey("B").unwrap(); + let close = old.begin_close(); + let recovered = RuntimeIdentity::new(old.current(), old.ids()); + assert_eq!(recovered.ids(), ["A", "B"]); + assert!(old.is_closing()); + assert!(!recovered.is_closing()); + recovered.rekey("C").unwrap(); + assert_eq!(recovered.ids(), ["A", "B", "C"]); + assert_eq!(old.current(), "B"); + drop(close); + assert!(!old.is_closing()); + } + + #[test] + fn overlapping_close_guards_refuse_rekey_until_the_last_guard_drops() { + let identity = RuntimeIdentity::new("A", ["A", "older", "older"].map(str::to_string)); + assert_eq!(identity.ids(), ["older", "A"]); + let first = identity.begin_close(); + let second = identity.clone().begin_close(); + let error = identity.rekey("B").unwrap_err(); + assert!(!error.to_string().is_empty()); + drop(first); + assert!(identity.is_closing()); + assert!(identity.rekey("B").is_err()); + assert_eq!(identity.current(), "A"); + drop(second); + assert!(!identity.is_closing()); + identity.rekey("B").unwrap(); + assert_eq!(identity.current(), "B"); + } + + #[test] + fn canceling_an_actually_polled_future_releases_its_close_guard() { + let identity = RuntimeIdentity::new("A", Vec::new()); + let mut operation = Box::pin(async { + let _closing = identity.begin_close(); + std::future::pending::<()>().await; + }); + assert!(!identity.is_closing()); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + operation.as_mut().poll(&mut context), + Poll::Pending + )); + assert!(identity.is_closing()); + assert!(identity.rekey("B").is_err()); + drop(operation); + assert!(!identity.is_closing()); + identity.rekey("B").unwrap(); + } +} diff --git a/crates/freshell-freshagent/src/claude/stop.rs b/crates/freshell-freshagent/src/claude/stop.rs index ef4cc0dba..1f072e654 100644 --- a/crates/freshell-freshagent/src/claude/stop.rs +++ b/crates/freshell-freshagent/src/claude/stop.rs @@ -28,11 +28,7 @@ impl FreshClaudeState { let Some(session) = sessions.get_mut(map_key) else { return false; }; - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); 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. diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 56567aada..7d6135ab3 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -108,10 +108,8 @@ async fn fake_session( ownership_id: format!("test-teardown-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), @@ -181,7 +179,9 @@ pub(super) mod capture { ) { let events = Arc::new(StdMutex::new(Vec::new())); let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&events))); - (events, tracing::subscriber::set_default(subscriber)) + let guard = tracing::subscriber::set_default(subscriber); + tracing::callsite::rebuild_interest_cache(); + (events, guard) } } diff --git a/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs new file mode 100644 index 000000000..5e79fc400 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs @@ -0,0 +1,128 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abandoned_create_cannot_close_a_replacement_with_the_same_session_id() { + let (mut state, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(sink.clone()); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + let pause = Arc::new(AdoptionTestPause { + reached: std::sync::atomic::AtomicBool::new(false), + notify: tokio::sync::Notify::new(), + }); + state.kill_after_resolve_pause = Some(pause.clone()); + let task = { + let state = state.clone(); + let id = id.clone(); + tokio::spawn(async move { + state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await + .unwrap(); + }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let old = state.sessions.lock().await.remove(&id).unwrap(); + insert_fake_claude_session(&state, &id).await; + state + .sessions + .lock() + .await + .get_mut(&id) + .unwrap() + .ownership_id = "replacement".into(); + pause.notify.notify_one(); + task.await.unwrap(); + let replacement_alive = state + .sessions + .lock() + .await + .get_mut(&id) + .is_some_and(|session| { + session.ownership_id == "replacement" && session.child.try_wait().unwrap().is_none() + }); + let retired = sink.retires.lock().unwrap().clone(); + state.teardown_removed_session_logged(old).await; + state.shutdown().await; + assert!( + replacement_alive, + "a stale create receipt must leave the replacement alive" + ); + assert!( + retired.is_empty(), + "a stale receipt must not close durable rows: {retired:?}" + ); +} + +#[tokio::test] +async fn abandoned_create_closes_only_its_allocated_runtime() { + let (state, _rx) = state_with_bus(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await + .unwrap(); + assert!(!state.sessions.lock().await.contains_key(&id)); +} + +#[tokio::test] +async fn abandoned_create_reports_a_failed_close_without_claiming_cleanup() { + let (state, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(sink.clone()); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + sink.set_fail_writes(true); + let outcome = state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await; + let still_live = state.sessions.lock().await.contains_key(&id); + state.shutdown().await; + assert!( + still_live, + "a failed durable close leaves its runtime intact" + ); + assert!(outcome.is_err(), "cleanup must report the retained runtime"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abandoned_create_uses_the_allocated_process_tag_with_its_coordinator_stamp() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (mut state, _rx) = state_with_bus(); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let ClaudeCreateOutcome::Created { + session_id, + cleanup_ownership_id: Some(ownership_id), + .. + } = state + .handle_create(dedup_create_msg("owned-create-cleanup"), None) + .await + else { + panic!("create must return its allocated runtime's cleanup receipt"); + }; + let outcome = state + .abandon_created_runtime(&session_id, SessionType::Freshclaude, &ownership_id) + .await; + let remains = state.sessions.lock().await.contains_key(&session_id); + let owner_state = state.ownership_snapshot(PROVIDER, &session_id).state; + state.shutdown().await; + assert!(outcome.is_ok(), "{outcome:?}"); + assert!(!remains, "the allocated process was not released"); + assert!(matches!( + owner_state, + freshell_ownership::OwnershipState::Vacant + )); +} diff --git a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs index 3e92d56a7..5f8891ef1 100644 --- a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs @@ -60,7 +60,15 @@ async fn create_binding_and_name_are_durable_before_live_and_created() { session_id: id.clone() } ); - assert!(state.naming_handles.lock().await.get(&id).is_none()); + let mut replay = dedup_create_msg("create-bound-before-live"); + replay.naming_handle = Some("pre-create-name".into()); + state.handle_create(replay, None).await; + let replayed = await_claude_created(&mut rx, "create-bound-before-live").await; + assert_eq!( + replayed["nameRef"], created["nameRef"], + "a replay adopts the durable name identity" + ); + assert_eq!(replayed["sessionName"], created["sessionName"]); assert!(identity .geneses .lock() diff --git a/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs index 409b64d22..b9bfc5046 100644 --- a/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs @@ -159,3 +159,46 @@ async fn an_idle_runtime_never_arms_a_quiet_timeout() { tokio::time::resume(); st.shutdown().await; } + +#[tokio::test(start_paused = true)] +async fn an_armed_quiet_deadline_follows_the_same_runtime_across_a_native_reset() { + let (st, mut rx) = state_with_bus(); + let st = st + .with_sidecar_environment([("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "100".into())]); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + let (owner, pending) = { + let sessions = st.sessions.lock().await; + let runtime = &sessions[&old]; + runtime.ledger.lock().unwrap().arm( + "reset-turn".into(), + None, + crate::claude_turns::SendKind::Turn, + ); + (runtime.ownership_id.clone(), runtime.pending.clone()) + }; + st.note_claude_stdout(&old, &owner, None).await; + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test-reset") + .await + .unwrap(); + clock_forward(150).await; + let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + let completed: Vec<_> = frames + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.turn.complete") + .collect(); + assert_eq!( + completed.len(), + 1, + "the old deadline must still publish its stuck edge: {frames:?}" + ); + assert_eq!(completed[0]["sessionId"], new); + assert!(frames + .iter() + .any(|frame| frame["sessionId"] == new && frame["event"]["status"] == "stuck")); + tokio::time::resume(); + st.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs b/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs new file mode 100644 index 000000000..cf93c22c8 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs @@ -0,0 +1,64 @@ +use super::*; + +#[tokio::test] +async fn the_rust_runtime_fixture_completes_a_real_sidecar_turn() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({"rules":[{"on":"msg:prompt", "match":{"text":"prove the real sidecar path"}, + "emit":[{"kind":"text", "data":{"text":"the simulator answered through index.mjs"}}]}]}) + .to_string(), + ); + let (state, mut rx) = state_with_bus(); + state + .handle_create(dedup_create_msg("real-fixture-smoke"), None) + .await; + let created = await_claude_created(&mut rx, "real-fixture-smoke").await; + let session_id = created["sessionId"].as_str().unwrap(); + state + .handle_send(send_msg(session_id, "prove the real sidecar path")) + .await; + let assistant = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + assert_eq!( + assistant["event"]["content"][0]["text"], + "the simulator answered through index.mjs" + ); + let result = await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + assert_eq!(result["sessionId"], session_id); + let rows: Vec = std::fs::read_to_string(env.dir.join("sim-ledger.jsonl")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let prompt = rows.iter().find(|row| row["kind"] == "prompt").unwrap(); + assert_eq!(prompt["sessionId"], session_id); + assert!(uuid::Uuid::parse_str(prompt["uuid"].as_str().unwrap()).is_ok()); + assert_eq!(rows.iter().filter(|row| row["kind"] == "query").count(), 1); + assert_eq!(rows.iter().filter(|row| row["kind"] == "result").count(), 1); + #[cfg(unix)] + let processes = { + let boot = rows.iter().find(|row| row["kind"] == "boot").unwrap(); + [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| { + ( + pid, + crate::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap()), + ) + }) + }; + // Keep the Rust state alive: ending the unique fixture guard must itself + // close the SDK query and stop both the production sidecar and its CLI. + drop(env); + #[cfg(unix)] + for (pid, process) in processes { + assert!( + process.exits_within(Duration::from_secs(15)).await, + "dropping the fixture guard left process {pid} running" + ); + } + state.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs index 01f48640e..dcec8c677 100644 --- a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -72,7 +72,6 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme permission_mode: None, sandbox: None, }, - "freshclaude", ) .await }) @@ -128,6 +127,7 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_removed() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"transcript observed"}}])); let home = tempfile::tempdir().unwrap(); let (st, mut rx) = state_with_bus(); let mut environment: Vec<(String, String)> = std::env::vars().collect(); @@ -143,7 +143,8 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem let id = created["sessionId"].as_str().unwrap().to_string(); assert!(st.sessions.lock().await[&id].launch_genesis.is_some()); write_fake_transcript(home.path(), &id); - inject_raw_send(&st, &id, "__emit_result__").await; + st.handle_send(send_msg(&id, "observe the saved transcript")) + .await; await_frame_of_inner_type(&mut rx, "freshAgent.result").await; let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while st.sessions.lock().await[&id].launch_genesis.is_some() { @@ -153,12 +154,15 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem ); tokio::time::sleep(Duration::from_millis(10)).await; } - let transcript = home.path().join("projects/-t").join(format!("{id}.jsonl")); - std::fs::rename(&transcript, transcript.with_extension("saved")).unwrap(); let pid = st.sessions.lock().await[&id].child.id().unwrap(); // SAFETY: this process belongs to this test's isolated fake sidecar. assert_eq!(unsafe { libc::kill(pid as i32, libc::SIGKILL) }, 0); - await_frame_of_inner_type(&mut rx, "freshAgent.status").await; + loop { + let status = await_frame_of_inner_type(&mut rx, "freshAgent.status").await; + if status["event"]["status"] == "exited" { + break; + } + } let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while st.resolve_session_key(&id).await.is_some() { assert!( @@ -167,6 +171,20 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem ); tokio::task::yield_now().await; } + // The real CLI wrote its own project directory in addition to the seeded + // file. Once its writer is confirmed stopped, remove every copy of this id. + let mut moved = 0; + for project in std::fs::read_dir(home.path().join("projects")).unwrap() { + let transcript = project.unwrap().path().join(format!("{id}.jsonl")); + if transcript.is_file() { + std::fs::rename(&transcript, transcript.with_extension("saved")).unwrap(); + moved += 1; + } + } + assert!( + moved >= 1, + "the observed transcript was present before removal" + ); assert!(matches!( st.ensure_runtime(&id, SessionType::Freshclaude, None, None) .await, diff --git a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs new file mode 100644 index 000000000..a34f34563 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_configure_receipt_after_reset_saves_the_same_runtime_under_its_current_uuid() { + let (state, _rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &old).await; + let pending = state.sessions.lock().await[&old].pending.clone(); + let turn_lock = state.sessions.lock().await[&old].turn_lock.clone(); + let task = { + let state = state.clone(); + let old = old.clone(); + tokio::spawn(async move { + let _turn = turn_lock.lock().await; + state + .configure_for_send( + &old, + &freshell_protocol::FreshAgentSendSettings { + model: Some("sonnet".into()), + cwd: None, + effort: None, + permission_mode: None, + sandbox: None, + }, + ) + .await + }) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if state.sessions.lock().await[&old] + .configuration + .pending + .is_some() + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "configure did not reach its control await" + ); + tokio::task::yield_now().await; + } + state + .rekey_conversation(&old, &new, "freshclaude", &pending, "test-control-await") + .await + .unwrap(); + let (_, reply) = state + .sessions + .lock() + .await + .get_mut(&new) + .unwrap() + .configuration + .pending + .take() + .unwrap(); + reply.send(Ok(())).unwrap(); + let result = task.await.unwrap(); + let saved = identity + .settings + .lock() + .unwrap() + .get(&(PROVIDER.into(), new.clone())) + .cloned(); + state.shutdown().await; + assert!(result.is_ok(), "{result:?}"); + assert_eq!(saved.unwrap().model.as_deref(), Some("sonnet")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn settings_persistence_failure_preserves_the_runtime_and_unsent_prompt_for_retry() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (mut state, _rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let mut create = dedup_create_msg("settings-persist-retry-create"); + create.model = Some("opus".into()); + let ClaudeCreateOutcome::Created { session_id, .. } = state.handle_create(create, None).await + else { + panic!("a settings persistence fixture must create"); + }; + let owner = state.sessions.lock().await[&session_id] + .ownership_id + .clone(); + let observed = state.ownership_snapshot(PROVIDER, &session_id); + let mut send = send_msg(&session_id, "keep this prompt until settings are saved"); + send.settings = Some(freshell_protocol::FreshAgentSendSettings { + model: Some("sonnet".into()), + cwd: None, + effort: None, + permission_mode: None, + sandbox: None, + }); + send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { + logical_attempt_id: "settings-persist-checkpoint-attempt".into(), + workspace: None, + }); + send.observed_epoch = Some(observed.epoch); + send.observed_generation = Some(observed.generation); + identity.set_fail_writes(true); + let refused = state.handle_send(send.clone()).await; + let same_runtime = state + .sessions + .lock() + .await + .get(&session_id) + .is_some_and(|session| { + session.ownership_id == owner + && session.configuration.settings.model.as_deref() == Some("sonnet") + }); + identity.set_fail_writes(false); + if !same_runtime || !matches!(refused, ClaudeSendOutcome::Refused { .. }) { + state.shutdown().await; + panic!("failed settings persistence must preserve the runtime and refuse its prompt: same_runtime={same_runtime}, outcome={refused:?}"); + } + let initial = env.respond_log_frames(1).await; + assert_eq!( + initial.len(), + 1, + "the first prompt was never sent: {initial:?}" + ); + assert_eq!(initial[0]["type"], "configure"); + send.request_id = Some("settings-persist-retry-send".into()); + let accepted = state.handle_send(send).await; + let recorded = env.respond_log_frames(3).await; + let saved = identity + .settings + .lock() + .unwrap() + .get(&(PROVIDER.into(), session_id.clone())) + .cloned(); + state.shutdown().await; + assert!( + matches!(accepted, ClaudeSendOutcome::Accepted { .. }), + "{accepted:?}" + ); + assert_eq!(saved.unwrap().model.as_deref(), Some("sonnet")); + assert_eq!( + recorded + .iter() + .filter(|frame| frame["type"] == "send") + .count(), + 1 + ); +} diff --git a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs new file mode 100644 index 000000000..a51788e55 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs @@ -0,0 +1,220 @@ +//! The production sidecar over the contract-tested CLI simulator. + +use serde_json::{json, Value}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const KEYS: &[&str] = &[ + "FRESHELL_CLAUDE_SIDECAR", + "FRESHELL_CLAUDE_NODE", + "FRESHELL_CLAUDE_SDK_QUERY_MODULE", + "FRESHELL_TEST_CLAUDE_SPAWN_LOG", + "FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", + "FRESHELL_TEST_CLAUDE_RESPOND_LOG", + "FRESHELL_TEST_CLAUDE_SDK_LOG", + "FRESHELL_TEST_CLAUDE_SDK_MESSAGES", + "FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", + "FRESHELL_TEST_CLAUDE_FAIL_CREATE", + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", + "FRESHELL_TEST_CLAUDE_REJECT_MODEL", + "FRESHELL_TEST_CLAUDE_REJECT_EFFORT", + "FRESHELL_CLAUDE_SIM_PROGRAM", + "FRESHELL_CLAUDE_SIM_TIME_SCALE", + "FRESHELL_CLAUDE_SIM_LEDGER", + "CLAUDE_CONFIG_DIR", +]; + +pub(crate) struct ClaudeTestRuntime { + pub(crate) dir: PathBuf, + spawn_log: PathBuf, + interrupt_log: PathBuf, + respond_log: PathBuf, + previous: Vec<(&'static str, Option)>, +} + +impl ClaudeTestRuntime { + pub(crate) fn install() -> Self { + Self::install_with_knobs(None, false) + } + + pub(crate) fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { + let previous = KEYS + .iter() + .map(|&key| (key, std::env::var_os(key))) + .collect(); + let dir = std::env::temp_dir().join(format!( + "freshell-claude-test-runtime-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("create runtime fixture directory"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let spawn_log = dir.join("query-options.jsonl"); + let interrupt_log = dir.join("interrupt.log"); + let respond_log = dir.join("requests.jsonl"); + for file in [&spawn_log, &interrupt_log, &respond_log] { + std::fs::write(file, "").unwrap(); + } + let guard = dir.join("active"); + std::fs::write(&guard, "").unwrap(); + std::env::set_var( + "FRESHELL_CLAUDE_SIDECAR", + root.join("crates/freshell-claude-sidecar/index.mjs"), + ); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE", + freshell_platform::opencode_plugin::plugin_file_spec( + &root.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + ), + ); + std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG", &respond_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_SDK_LOG", dir.join("sdk.jsonl")); + std::env::remove_var("FRESHELL_TEST_CLAUDE_SDK_MESSAGES"); + std::env::set_var("FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", &guard); + std::env::set_var("FRESHELL_CLAUDE_SIM_LEDGER", dir.join("sim-ledger.jsonl")); + std::env::set_var("FRESHELL_CLAUDE_SIM_TIME_SCALE", "0.01"); + std::env::set_var("CLAUDE_CONFIG_DIR", dir.join("claude")); + // Most runtime unit tests inspect an accepted, still-running turn. + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({"rules":[{"on":"msg:prompt", "emit":[{"kind":"hang"}]}]}).to_string(), + ); + match defer_create_ms { + Some(ms) => std::env::set_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", ms.to_string()), + None => std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"), + } + if fail_create { + std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); + } else { + std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); + } + for key in [ + "FRESHELL_TEST_CLAUDE_REJECT_MODEL", + "FRESHELL_TEST_CLAUDE_REJECT_EFFORT", + ] { + std::env::remove_var(key); + } + Self { + dir, + spawn_log, + interrupt_log, + respond_log, + previous, + } + } + + pub(crate) fn spawn_log_path(&self) -> &Path { + &self.spawn_log + } + + pub(crate) fn program(&self, steps: Value) { + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({ + "rules":[{"on":"msg:prompt", "emit":steps}] + }) + .to_string(), + ); + } + + pub(crate) async fn simulator_row(&self, kind: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + if let Some(row) = rows(&self.dir.join("sim-ledger.jsonl")) + .into_iter() + .find(|row| row["kind"] == kind) + { + return row; + } + assert!( + tokio::time::Instant::now() < deadline, + "simulator never recorded {kind}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + pub(crate) async fn create_request(&self) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + if let Some(row) = self.query_rows().first() { + return row["options"].clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "query never reached the SDK" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + fn query_rows(&self) -> Vec { + rows(&self.spawn_log) + } + + pub(crate) fn spawn_count(&self) -> usize { + self.query_rows().len() + } + + pub(crate) fn interrupt_log_contents(&self) -> String { + std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() + } + + pub(crate) async fn respond_log_frames(&self, min: usize) -> Vec { + self.wait_request_frames(None, min).await + } + + pub(crate) async fn request_frames_of_type(&self, kind: &str, min: usize) -> Vec { + self.wait_request_frames(Some(kind), min).await + } + + async fn wait_request_frames(&self, kind: Option<&str>, min: usize) -> Vec { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let frames: Vec<_> = rows(&self.respond_log) + .into_iter() + .filter(|frame| kind.is_none_or(|kind| frame["type"] == kind)) + .collect(); + if frames.len() >= min { + return frames; + } + assert!( + tokio::time::Instant::now() < deadline, + "sidecar stdin never received {min} frame(s): {frames:?}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} + +fn rows(file: &Path) -> Vec { + std::fs::read_to_string(file) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("fixture JSONL row")) + .collect() +} + +impl Drop for ClaudeTestRuntime { + fn drop(&mut self) { + let _ = std::fs::write(self.dir.join("active.stop"), ""); + let _ = std::fs::remove_file(self.dir.join("active")); + // The SDK watcher also treats this unique directory disappearing as + // shutdown, so deleting it cannot lose the sentinel notification. + let _ = std::fs::remove_dir_all(&self.dir); + for (key, value) in &self.previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} diff --git a/crates/freshell-freshagent/src/claude/turn_status.rs b/crates/freshell-freshagent/src/claude/turn_status.rs index 22fd67bc0..a891bad6c 100644 --- a/crates/freshell-freshagent/src/claude/turn_status.rs +++ b/crates/freshell-freshagent/src/claude/turn_status.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; -use super::{FreshClaudeState, PROVIDER}; +use super::{FreshClaudeState, RuntimeIdentity, PROVIDER}; use crate::claude_turns::{ EffectiveStatus, Folded, ResultVerdict, SharedLedger, StopReissue, TurnFact, }; @@ -62,13 +62,7 @@ impl FreshClaudeState { .lock() .await .get(map_key) - .map(|session| { - session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone() - }) + .map(|session| session.identity.current()) .unwrap_or_else(|| map_key.to_string()) } @@ -114,11 +108,7 @@ impl FreshClaudeState { let Some(session) = sessions.get(&key) else { return; }; - let session_id = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let session_id = session.identity.current(); let quiet = session.quiet.state.lock().expect("quiet deadman lock"); let mut ledger = session.ledger.lock().expect("turn ledger lock"); let status = ledger @@ -141,7 +131,7 @@ impl FreshClaudeState { &self, ledger: &SharedLedger, line: &Value, - broadcast_id: &std::sync::Mutex, + identity: &RuntimeIdentity, session_type: &str, ) -> LedgerStep { let Some(fact) = TurnFact::from_line(line) else { @@ -152,7 +142,7 @@ impl FreshClaudeState { let folded = ledger.fold(&fact); (folded, ledger.busy()) }; - let session_id = broadcast_id.lock().expect("broadcast id lock").clone(); + let session_id = identity.current(); match folded { Folded::Applied => LedgerStep::Nothing, Folded::UnknownCommand => { diff --git a/crates/freshell-freshagent/src/claude_create_defaults.rs b/crates/freshell-freshagent/src/claude_create_defaults.rs new file mode 100644 index 000000000..bbf49264a --- /dev/null +++ b/crates/freshell-freshagent/src/claude_create_defaults.rs @@ -0,0 +1,225 @@ +//! Claude REST pane defaults follow the picker, including saved catalog levels. + +use freshell_protocol::{ModelSelection, SettingsFreshAgent}; +use serde::Serialize; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedClaudeCreate { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_selection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_effort_levels: Option>, + pub permission_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + pub plugins: Vec, +} + +pub fn resolve_claude_rest_create( + session_type: &str, + body: &Value, + settings: &SettingsFreshAgent, +) -> Result { + if !matches!(session_type, "freshclaude" | "kilroy") { + return Err(format!("unsupported Claude session type: {session_type}")); + } + let inputs = crate::rest_lane::provider_inputs(body)?; + let saved = &settings.providers[session_type]; + let explicit_model = optional_string(body, "model")?; + let model_selection = match inputs.model_selection { + Some(selection) => selection, + None if explicit_model.is_some() => None, + None => saved + .get("modelSelection") + .and_then(|selection| serde_json::from_value::(selection.clone()).ok()) + .filter(|selection| selection.kind == "exact" && !selection.model_id.trim().is_empty()), + }; + let model = explicit_model + .or_else(|| { + model_selection + .as_ref() + .map(|selection| selection.model_id.trim().to_string()) + }) + .unwrap_or_else(|| "opus[1m]".into()); + let model_effort_levels = saved + .get("modelEffortLevels") + .filter(|stamp| stamp.get("modelId").and_then(Value::as_str) == Some(model.as_str())) + .and_then(|stamp| stamp.get("levels")) + .and_then(|levels| serde_json::from_value::>(levels.clone()).ok()); + let candidate = optional_string(body, "effort")?.or_else(|| { + saved + .get("effort") + .and_then(Value::as_str) + .and_then(non_blank) + }); + let effort = resolve_effort(&model, model_effort_levels.as_deref(), candidate); + Ok(ResolvedClaudeCreate { + model, + model_selection, + model_effort_levels, + permission_mode: inputs + .permission_mode + .or_else(|| { + saved + .get("defaultPermissionMode") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "bypassPermissions".into()), + effort, + plugins: inputs + .plugins + .unwrap_or_else(|| settings.default_plugins.clone()), + }) +} + +fn resolve_effort( + model: &str, + levels: Option<&[String]>, + candidate: Option, +) -> Option { + let candidate = candidate.unwrap_or_else(|| "high".into()); + if model != "opus[1m]" { + if let Some(levels) = levels { + return if levels.contains(&candidate) { + Some(candidate) + } else { + levels.first().cloned() + }; + } + } + Some( + if matches!( + candidate.as_str(), + "low" | "medium" | "high" | "xhigh" | "max" + ) { + candidate + } else { + "high".into() + }, + ) +} + +fn non_blank(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn optional_string(body: &Value, key: &str) -> Result, String> { + body.get(key) + .map(|value| { + value + .as_str() + .map(non_blank) + .ok_or_else(|| format!("invalid {key}")) + }) + .transpose() + .map(Option::flatten) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn settings(providers: Value) -> SettingsFreshAgent { + SettingsFreshAgent { + default_plugins: vec!["/default-plugin".into()], + enabled: true, + providers, + initial_setup_done: None, + } + } + + #[test] + fn explicit_request_fields_override_saved_defaults_and_can_clear_selection() { + let settings = settings(json!({"freshclaude": { + "modelSelection": {"kind":"exact", "modelId":"saved-model"}, + "defaultPermissionMode":"plan", "effort":"low" + }})); + let resolved = resolve_claude_rest_create( + "freshclaude", + &json!({ + "model":"opus[1m]", "modelSelection":null, "effort":"max", + "permissionMode":"acceptEdits", "plugins":[] + }), + &settings, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(resolved).unwrap(), + json!({ + "model":"opus[1m]", "effort":"max", "permissionMode":"acceptEdits", "plugins":[] + }) + ); + } + + #[test] + fn explicit_selection_changes_the_model_and_discards_old_catalog_levels() { + let settings = settings(json!({"kilroy": { + "modelSelection": {"kind":"exact", "modelId":"saved-model"}, + "modelEffortLevels":{"modelId":"saved-model", "levels":[]} + }})); + let resolved = resolve_claude_rest_create( + "kilroy", + &json!({ + "modelSelection":{"kind":"exact", "modelId":"new-model"} + }), + &settings, + ) + .unwrap(); + assert_eq!(resolved.model, "new-model"); + assert_eq!(resolved.model_selection.unwrap().model_id, "new-model"); + assert_eq!(resolved.model_effort_levels, None); + assert_eq!(resolved.effort.as_deref(), Some("high")); + } + + #[test] + fn invalid_provider_inputs_are_refused() { + let settings = settings(json!({})); + assert!( + resolve_claude_rest_create("freshclaude", &json!({"plugins":"bad"}), &settings) + .is_err() + ); + assert!(resolve_claude_rest_create("freshcodex", &json!({}), &settings).is_err()); + } + + #[test] + fn rest_and_picker_resolve_the_same_claude_defaults() { + let fixtures: Vec = serde_json::from_str(include_str!( + "../../../test/fixtures/fresh-agent/claude-create-defaults.json" + )) + .unwrap(); + for fixture in fixtures { + let mut settings = SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + providers: json!({}), + initial_setup_done: None, + }; + if let Some(fresh) = fixture["settings"].get("freshAgent") { + if let Some(plugins) = fresh.get("defaultPlugins") { + settings.default_plugins = serde_json::from_value(plugins.clone()).unwrap(); + } + if let Some(providers) = fresh.get("providers") { + settings.providers = providers.clone(); + } + } + let resolved = resolve_claude_rest_create( + fixture["sessionType"].as_str().unwrap(), + &json!({}), + &settings, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(resolved).unwrap(), + fixture["expected"], + "{}", + fixture["name"] + ); + } + } +} diff --git a/crates/freshell-freshagent/src/claude_dead_sessions.rs b/crates/freshell-freshagent/src/claude_dead_sessions.rs index 967c4be05..c77e0cd51 100644 --- a/crates/freshell-freshagent/src/claude_dead_sessions.rs +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -8,9 +8,8 @@ //! a send to it is refused visibly instead of vanishing. Resuming the session //! (attach or a create that resumes it) or closing it forgets the record. //! -//! Until the durable id is minted at create (C23) a session answers to several -//! ids — its sessions-map key (the pane's id from create or attach), its envelope -//! stamp and its durable claude UUIDs — so a record answers to all of them. +//! A record retains the current native UUID and the native history of its +//! runtime, so late requests using an earlier UUID can recover the same writer. use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -31,7 +30,7 @@ pub(crate) struct DeadClaudeSession { pub(crate) ids: Vec, /// The settings the session last ran under. pub(crate) settings: FreshAgentSettings, - pub(crate) cli_session_id: Option, + pub(crate) session_id: String, pub(crate) ownership_id: String, pub(crate) launch_genesis: Option, pub(crate) turn_lock: Arc>, @@ -103,8 +102,6 @@ pub(crate) struct Death<'a> { pub(crate) session_type: &'a str, /// The durable ids the session's aliases named. pub(crate) durables: &'a [String], - /// The durable id the session itself held. - pub(crate) cli_session_id: Option<&'a str>, pub(crate) settings: &'a FreshAgentSettings, pub(crate) ownership_id: &'a str, pub(crate) launch_genesis: Option<&'a crate::identity_sink::RecordedGenesis>, @@ -133,10 +130,7 @@ impl SharedDeadSessions { /// map key, its envelope stamp and every durable id it held. pub(crate) fn record(&self, death: Death<'_>) { let mut ids = vec![death.map_key.to_string()]; - for id in std::iter::once(death.stamp) - .chain(death.durables.iter().map(String::as_str)) - .chain(death.cli_session_id) - { + for id in std::iter::once(death.stamp).chain(death.durables.iter().map(String::as_str)) { if !ids.iter().any(|known| known == id) { ids.push(id.to_string()); } @@ -144,7 +138,7 @@ impl SharedDeadSessions { let dead = DeadClaudeSession { ids: ids.clone(), settings: death.settings.clone(), - cli_session_id: death.cli_session_id.map(str::to_string), + session_id: death.stamp.to_string(), ownership_id: death.ownership_id.to_string(), launch_genesis: death.launch_genesis.cloned(), turn_lock: death.turn_lock.clone(), @@ -193,7 +187,7 @@ impl SharedDeadSessions { let mut store = self.0.lock().expect("dead sessions lock"); for dead in &mut store.entries { if dead.answers_to(id) - && dead.cli_session_id.as_deref().is_none_or(|cli| cli == id) + && dead.session_id == id && ownership_id.is_none_or(|owner| dead.ownership_id == owner) { dead.launch_genesis = None; @@ -232,7 +226,7 @@ mod tests { DeadClaudeSession { ids: ids.iter().map(|id| id.to_string()).collect(), settings: FreshAgentSettings::default(), - cli_session_id: None, + session_id: ids.last().expect("a native identity").to_string(), ownership_id: "dead-runtime".into(), launch_genesis: None, turn_lock: Arc::new(tokio::sync::Mutex::new(())), diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs new file mode 100644 index 000000000..bec8840d3 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -0,0 +1,420 @@ +//! REST control of the same Claude runtime used by WebSocket panes. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::claude_edges::ClaudeTurnEvent; +use crate::claude_snapshot::ClaudeSnapshotError; +use crate::rest_lane::*; +use crate::{ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; +use freshell_protocol::{ + AgentProvider, FreshAgentCreate, FreshAgentSend, SessionLocator, SessionType, +}; + +pub struct ClaudeRestLane { + state: FreshClaudeState, + enabled: Arc bool + Send + Sync>, +} + +impl ClaudeRestLane { + pub fn new(state: FreshClaudeState, enabled: Arc bool + Send + Sync>) -> Self { + Self { state, enabled } + } + + async fn create(&self, request: RestLaneCreate) -> Result { + let session_type = claude_session_type(&request.provider, &request.session_type)?; + if !(self.enabled)() { + return Err(RestLaneError::new( + RestLaneErrorKind::Forbidden, + "FRESH_AGENT_DISABLED", + "fresh agents are disabled", + )); + } + let session_ref = request.native_session_id.map(|session_id| SessionLocator { + provider: "claude".into(), + session_id, + }); + match self + .state + .handle_create( + FreshAgentCreate { + request_id: request.request_id, + provider: Some(AgentProvider::Claude), + session_type, + cwd: request.cwd, + model: request.model, + model_selection: request.model_selection, + permission_mode: request.permission_mode, + effort: request.effort, + plugins: request.plugins, + session_ref, + sandbox: request.sandbox, + tab_id: request.tab_id, + pane_id: request.pane_id, + naming_handle: request.naming_handle, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, + legacy_restore_context: None, + resume_session_id: None, + }, + None, + ) + .await + { + ClaudeCreateOutcome::Created { + session_id, + session_ref, + cleanup_ownership_id, + } => Ok(RestLaneCreated { + session_id, + session_ref, + cleanup_token: cleanup_ownership_id, + }), + ClaudeCreateOutcome::Failed { + code, + message, + retryable, + owner, + } => { + let mut error = RestLaneError::new(create_error_kind(&code), code, message); + error.retryable = retryable; + error.retry_after_ms = retryable.filter(|retryable| *retryable).map(|_| 250); + error.owner = owner; + Err(error) + } + } + } + + async fn send(&self, request: &RestLaneSend) -> Result { + let session_type = claude_session_type(&request.provider, &request.session_type)?; + if request + .text + .bytes() + .all(|byte| matches!(byte, b'\r' | b'\n')) + { + return Ok(RestLaneSendResult { + session_id: request.session_id.clone(), + completed: true, + noop: true, + ..Default::default() + }); + } + let text = request + .text + .strip_suffix(['\r', '\n']) + .unwrap_or(&request.text) + .to_string(); + // A short turn may settle before handle_send returns its acceptance. + let mut events = self.state.subscribe_turn_events(); + let accepted = self + .state + .handle_send(FreshAgentSend { + provider: AgentProvider::Claude, + session_id: request.session_id.clone(), + session_type, + text, + request_id: Some(request.request_id.clone()), + cwd: None, + images: None, + settings: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, + checkpoint_intent: None, + }) + .await; + let (session_key, submitted_turn_id, ownership_id) = match accepted { + ClaudeSendOutcome::Accepted { + session_key, + submitted_turn_id, + ownership_id, + } => (session_key, submitted_turn_id, ownership_id), + ClaudeSendOutcome::Refused { + code, + message, + retryable, + owner, + } => { + let mut error = send_error(code, message); + error.retryable = retryable.or(error.retryable); + error.retry_after_ms = error.retryable.filter(|retryable| *retryable).map(|_| 250); + error.owner = owner; + return Err(error); + } + }; + let settled_key = wait_for_turn( + &mut events, + &session_key, + &ownership_id, + &request.request_id, + &submitted_turn_id, + Duration::from_millis(request.timeout_ms), + ) + .await?; + let completed = settled_key.is_some(); + Ok(RestLaneSendResult { + session_id: settled_key.unwrap_or(session_key), + completed, + submitted_turn_id: Some(submitted_turn_id), + noop: false, + }) + } +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ClaudeRestLane { + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + if let Some(ownership_id) = created.cleanup_token.as_deref() { + self.state + .abandon_created_runtime( + &created.session_id, + claude_session_type(&request.provider, &request.session_type)?, + ownership_id, + ) + .await + .map_err(|error| { + let mut refused = RestLaneError::new( + RestLaneErrorKind::Unavailable, + error.code, + error.message, + ); + refused.retryable = Some(true); + refused.retry_after_ms = Some(250); + refused + })?; + tracing::info!(event = "freshagent.claude.rest_lane.create_abandoned", request_id = %request.request_id, session_id = %created.session_id, ownership_id, "processed the closed destination's create cleanup"); + } + Ok(()) + } + + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let started = Instant::now(); + let request_id = request.request_id.clone(); + let session_type = request.session_type.clone(); + let result = self.create(request).await; + match &result { + Ok(created) => tracing::info!( + event = "freshagent.claude.rest_lane.create", + request_id, + session_type, + session_id = created.session_id, + outcome = "created", + elapsed_ms = started.elapsed().as_millis() as u64 + ), + Err(error) => tracing::warn!( + event = "freshagent.claude.rest_lane.create", + request_id, + session_type, + code = error.code, + outcome = "refused", + elapsed_ms = started.elapsed().as_millis() as u64 + ), + } + result + } + + async fn send_agent(&self, request: RestLaneSend) -> Result { + let started = Instant::now(); + let result = self.send(&request).await; + match &result { + Ok(sent) => tracing::info!( + event = "freshagent.claude.rest_lane.send", + session_id = request.session_id, + request_id = request.request_id, + submitted_turn_id = sent.submitted_turn_id.as_deref(), + completed = sent.completed, + noop = sent.noop, + waited_ms = started.elapsed().as_millis() as u64 + ), + Err(error) => tracing::warn!( + event = "freshagent.claude.rest_lane.send", + session_id = request.session_id, + request_id = request.request_id, + code = error.code, + outcome = "refused", + waited_ms = started.elapsed().as_millis() as u64 + ), + } + result + } + + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + claude_session_type(&request.provider, &request.session_type) + .map_err(|_| RestLaneCaptureError::Unsupported)?; + let snapshot = match self + .state + .disk_snapshot(&request.session_type, &request.session_id) + .await + { + Ok(snapshot) => snapshot, + Err(ClaudeSnapshotError::NotFound) + if self.state.has_live_session(&request.session_id).await => + { + serde_json::json!({"turns":[]}) + } + Err(ClaudeSnapshotError::NotFound) => return Err(RestLaneCaptureError::NotFound), + Err(ClaudeSnapshotError::Io(error)) => { + tracing::warn!( + event = "freshagent.claude.rest_lane.capture", + session_id = request.session_id, + outcome = "unreadable", + error + ); + return Err(RestLaneCaptureError::Unavailable); + } + }; + let (text, truncated) = freshell_agent_runtime::snapshot_text::render_snapshot_text( + &snapshot, + request.max_bytes.min(256 * 1024), + ); + tracing::info!( + event = "freshagent.claude.rest_lane.capture", + session_id = request.session_id, + outcome = "captured", + bytes = text.len(), + truncated + ); + Ok(RestLaneCaptureResult { + session_id: request.session_id.clone(), + native_session_id: request.session_id, + text, + truncated, + }) + } +} + +fn claude_session_type(provider: &str, session_type: &str) -> Result { + match (provider, session_type) { + ("claude", "freshclaude") => Ok(SessionType::Freshclaude), + ("claude", "kilroy") => Ok(SessionType::Kilroy), + _ => Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "the local Claude lane supports freshclaude and kilroy sessions", + )), + } +} + +fn create_error_kind(code: &str) -> RestLaneErrorKind { + match code { + "SESSION_RESERVED" | "SESSION_ID_IN_USE" | "STALE_CLAIM" | "SESSION_BUSY" => { + RestLaneErrorKind::Conflict + } + "CONVERSATION_NOT_FOUND" | "RESUME_POINT_NOT_FOUND" => RestLaneErrorKind::NotFound, + "FRESH_AGENT_CREATE_FAILED" | "CLAUDE_CREATE_FAILED" | "SIDECAR_PROTOCOL_MISMATCH" => { + RestLaneErrorKind::Unavailable + } + code if code.starts_with("INVALID_") => RestLaneErrorKind::BadRequest, + code if code.starts_with("CLAUDE_SIDECAR_") => RestLaneErrorKind::Unavailable, + _ => RestLaneErrorKind::Internal, + } +} + +fn retryable_conflict(code: &str, message: &str) -> RestLaneError { + let mut error = RestLaneError::new(RestLaneErrorKind::Conflict, code, message); + error.retryable = Some(true); + error.retry_after_ms = Some(250); + error +} + +fn send_error(code: String, message: String) -> RestLaneError { + match code.as_str() { + "SESSION_NOT_FOUND" | "INVALID_SESSION_ID" | "SESSION_CLOSED" => { + let kind = if code == "SESSION_CLOSED" { + RestLaneErrorKind::Conflict + } else { + RestLaneErrorKind::NotFound + }; + let mut error = RestLaneError::new(kind, code, message); + error.retryable = Some(false); + error + } + "SESSION_BUSY" | "SESSION_RESERVED" | "STALE_CLAIM" => retryable_conflict(&code, &message), + "CLAUDE_SEND_FAILED" | "CLAUDE_SETTINGS_FAILED" | "CLAUDE_ATTACH_RESUME_FAILED" => { + RestLaneError::new(RestLaneErrorKind::Unavailable, code, message) + } + code if code.starts_with("INVALID_") => { + RestLaneError::new(RestLaneErrorKind::BadRequest, code, message) + } + _ => RestLaneError::new(RestLaneErrorKind::Internal, code, message), + } +} + +async fn wait_for_turn( + events: &mut tokio::sync::broadcast::Receiver, + session_key: &str, + ownership_id: &str, + request_id: &str, + submitted_turn_id: &str, + budget: Duration, +) -> Result, RestLaneError> { + let waited = tokio::time::timeout(budget, async { + loop { + match events.recv().await { + Ok(ClaudeTurnEvent::Settled { + session_key: settled_key, + retired_request_ids, + retired_command_ids, + .. + }) if retired_request_ids + .iter() + .any(|retired| retired == request_id) + && retired_command_ids + .iter() + .any(|retired| retired == submitted_turn_id) => + { + // /clear moves this runtime to a new conversation key. + // Its minted command UUID and request still identify the + // exact accepted send, including across that key change. + return Ok(Some(settled_key)); + } + Ok(ClaudeTurnEvent::Ended { + ownership_id: ended_owner, + reason, + .. + }) if ended_owner == ownership_id => { + tracing::warn!( + event = "freshagent.claude.rest_lane.send_ended", + session_key, + request_id, + submitted_turn_id, + reason + ); + return Err(retryable_conflict( + "FRESH_AGENT_SESSION_ENDED", + "Claude stopped before this turn completed; attach the pane and retry", + )); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!( + event = "freshagent.claude.rest_lane.send_lagged", + session_key, + request_id, + skipped + ), + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Err(RestLaneError::new( + RestLaneErrorKind::Unavailable, + "FRESH_AGENT_EVENTS_UNAVAILABLE", + "Claude turn events are unavailable", + )) + } + } + } + }) + .await; + waited.unwrap_or(Ok(None)) +} + +#[cfg(test)] +#[path = "claude_rest_lane_tests.rs"] +mod tests; diff --git a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs new file mode 100644 index 000000000..d2b76e039 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs @@ -0,0 +1,223 @@ +use super::*; +use crate::TurnOutcome; +use std::time::Duration; + +fn lane(enabled: bool) -> ClaudeRestLane { + ClaudeRestLane::new( + FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)), + Arc::new(move || enabled), + ) +} + +fn create() -> RestLaneCreate { + RestLaneCreate { + provider: "claude".into(), + session_type: "freshclaude".into(), + request_id: "create-rest".into(), + ..Default::default() + } +} + +fn send(text: &str) -> RestLaneSend { + RestLaneSend { + provider: "claude".into(), + session_type: "freshclaude".into(), + session_id: "67cebd37-9576-4de0-93bb-8f286f5fc499".into(), + request_id: "rest-request".into(), + text: text.into(), + timeout_ms: 100, + observed_epoch: None, + observed_generation: None, + } +} + +#[tokio::test] +async fn disabled_create_is_forbidden_before_a_runtime_is_started() { + let error = Arc::new(lane(false)) + .create_agent(create()) + .await + .unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::Forbidden); + assert_eq!(error.code, "FRESH_AGENT_DISABLED"); +} + +#[tokio::test] +async fn local_lane_refuses_other_providers_and_session_types() { + let mut request = create(); + request.provider = "codex".into(); + assert_eq!( + Arc::new(lane(true)) + .create_agent(request) + .await + .unwrap_err() + .kind, + RestLaneErrorKind::BadRequest + ); + let mut request = send("hello"); + request.session_type = "freshcodex".into(); + assert_eq!( + lane(true).send_agent(request).await.unwrap_err().kind, + RestLaneErrorKind::BadRequest + ); +} + +#[tokio::test] +async fn line_terminators_only_are_noop_without_a_live_runtime() { + for text in ["\r", "\n", "\r\n", "\n\r\n"] { + let result = lane(true).send_agent(send(text)).await.unwrap(); + assert!(result.noop); + assert!(result.completed); + assert_eq!(result.submitted_turn_id, None); + } +} + +#[tokio::test] +async fn send_for_a_missing_conversation_returns_a_permanent_not_found() { + let root = tempfile::tempdir().unwrap(); + let state = FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)) + .with_sidecar_environment([( + "CLAUDE_CONFIG_DIR".into(), + root.path().to_str().unwrap().into(), + )]); + let lane = ClaudeRestLane::new(state, Arc::new(|| true)); + let error = lane.send_agent(send("hello\r")).await.unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::NotFound); + assert_eq!(error.code, "INVALID_SESSION_ID"); + assert_eq!(error.retryable, Some(false)); + assert_eq!(error.retry_after_ms, None); +} + +#[tokio::test] +#[cfg(unix)] +async fn capture_distinguishes_missing_history_from_unreadable_storage() { + let root = tempfile::tempdir().unwrap(); + let state = FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)) + .with_sidecar_environment([( + "CLAUDE_CONFIG_DIR".into(), + root.path().to_str().unwrap().into(), + )]); + let lane = ClaudeRestLane::new(state, Arc::new(|| true)); + let request = RestLaneCapture { + provider: "claude".into(), + session_type: "freshclaude".into(), + session_id: "67cebd37-9576-4de0-93bb-8f286f5fc499".into(), + max_bytes: 1024, + }; + assert_eq!( + lane.capture(request.clone()).await, + Err(RestLaneCaptureError::NotFound) + ); + // A symlink loop gives a real store traversal error even for root, unlike + // mode 000. Capture must not report that unchecked conversation as absent. + std::os::unix::fs::symlink("projects", root.path().join("projects")).unwrap(); + assert_eq!( + lane.capture(request).await, + Err(RestLaneCaptureError::Unavailable) + ); +} + +fn settled(session: &str, request: &str, uuid: &str) -> ClaudeTurnEvent { + ClaudeTurnEvent::Settled { + session_key: session.into(), + retired_request_ids: vec![request.into()], + retired_command_ids: vec![uuid.into()], + outcome: TurnOutcome::Completed, + at: None, + } +} + +#[tokio::test] +async fn completion_requires_the_accepted_command_uuid_and_request_even_when_sends_share_a_request_id( +) { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let future = wait_for_turn( + &mut rx, + "session", + "runtime-new", + "request", + "uuid", + Duration::from_secs(1), + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + for unrelated in [ + settled("another-session", "request", "another-uuid"), + settled("session", "request", "another-uuid"), + settled("session", "another-request", "uuid"), + ClaudeTurnEvent::Waiting { + session_key: "session".into(), + at: 1, + }, + ClaudeTurnEvent::Ended { + session_key: "session".into(), + ownership_id: "runtime-old".into(), + reason: "sidecar_exited".into(), + }, + ] { + tx.send(unrelated).unwrap(); + assert!(futures_util::poll!(&mut future).is_pending()); + } + // A user-silenced result still settles its sender, despite carrying no bell. + tx.send(settled("session", "request", "uuid")).unwrap(); + assert_eq!(future.await, Ok(Some("session".into()))); +} + +#[tokio::test] +async fn the_accepted_uuid_settles_after_the_runtime_moves_to_a_new_session_key() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + tx.send(settled("new-session-key", "request", "uuid")) + .unwrap(); + assert_eq!( + wait_for_turn( + &mut rx, + "accepted-session-key", + "runtime", + "request", + "uuid", + Duration::ZERO + ) + .await, + Ok(Some("new-session-key".into())) + ); +} + +#[tokio::test] +async fn only_the_accepted_runtime_ending_refuses_the_waiting_send() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + tx.send(ClaudeTurnEvent::Ended { + session_key: "session".into(), + ownership_id: "runtime-new".into(), + reason: "cli_exited".into(), + }) + .unwrap(); + let error = wait_for_turn( + &mut rx, + "session", + "runtime-new", + "request", + "uuid", + Duration::from_secs(1), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::Conflict); + assert_eq!(error.code, "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(error.retryable, Some(true)); +} + +#[tokio::test] +async fn a_deadline_reports_an_incomplete_turn() { + let (_tx, mut rx) = tokio::sync::broadcast::channel(16); + assert_eq!( + wait_for_turn( + &mut rx, + "session", + "runtime", + "request", + "uuid", + Duration::ZERO + ) + .await, + Ok(None) + ); +} diff --git a/crates/freshell-freshagent/src/claude_sidecar_io.rs b/crates/freshell-freshagent/src/claude_sidecar_io.rs index 8298244c2..b9b21b20c 100644 --- a/crates/freshell-freshagent/src/claude_sidecar_io.rs +++ b/crates/freshell-freshagent/src/claude_sidecar_io.rs @@ -305,9 +305,9 @@ struct LabelsInner { /// `None` for a sidecar process that serves no session (the model catalog probe). session_type: Option, pid: Option, - /// The session's envelope id handle (the id clients address it by; an - /// attach by durable id may change it), once the sidecar has named it. - session: OnceLock>>, + /// Read the current native conversation id without depending on the + /// runtime's identity representation. Observers never change this reader. + session: OnceLock String + Send + Sync>>, } impl SidecarLogLabels { @@ -344,11 +344,11 @@ impl SidecarLogLabels { self.inner.pid } - /// Bind the session this sidecar serves, by its shared envelope id - /// handle: every later event carries the handle's current value. Logged + /// Bind the session this sidecar serves: every later event reads its + /// current native id, including after conversation reset. Logged /// once, as the join between the sidecar's ownership id and its session. - pub(crate) fn bind_session(&self, envelope: &Arc>) { - if self.inner.session.set(Arc::clone(envelope)).is_ok() { + pub(crate) fn bind_session(&self, current_id: impl Fn() -> String + Send + Sync + 'static) { + if self.inner.session.set(Arc::new(current_id)).is_ok() { let session_id = self.session_id(); tracing::info!( provider = PROVIDER, @@ -361,14 +361,9 @@ impl SidecarLogLabels { } } - /// The bound session's current envelope id. + /// The bound session's current native id. fn session_id(&self) -> Option { - self.inner.session.get().map(|envelope| { - envelope - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - }) + self.inner.session.get().map(|current_id| current_id()) } } @@ -907,12 +902,13 @@ mod tests { "unbound until the sidecar names its session" ); let envelope = Arc::new(StdMutex::new("placeholder-id".to_string())); - labels.bind_session(&envelope); + let current_id = Arc::clone(&envelope); + labels.bind_session(move || current_id.lock().unwrap().clone()); assert_eq!(labels.session_id().as_deref(), Some("placeholder-id")); *envelope.lock().unwrap() = "durable-id".to_string(); assert_eq!(labels.session_id().as_deref(), Some("durable-id")); // A second bind changes nothing: one sidecar serves one session. - labels.bind_session(&Arc::new(StdMutex::new("other".to_string()))); + labels.bind_session(|| "other".to_string()); assert_eq!(labels.session_id().as_deref(), Some("durable-id")); } @@ -921,8 +917,9 @@ mod tests { let (events, _guard) = capture(); let labels = labels(); let envelope = Arc::new(StdMutex::new("session-a".to_string())); - labels.bind_session(&envelope); - labels.bind_session(&envelope); + let current_id = Arc::clone(&envelope); + labels.bind_session(move || current_id.lock().unwrap().clone()); + labels.bind_session(|| "ignored-second-binding".into()); let bound = with_message(&events, "freshagent.claude.sidecar_bound"); assert_eq!(bound.len(), 1, "{bound:?}"); assert_eq!(bound[0].level, Level::INFO); @@ -1038,7 +1035,7 @@ mod tests { .await .unwrap(); wait_until(|| !sidecar_logs(&events).is_empty()).await; - labels.bind_session(&Arc::new(StdMutex::new("session-b".to_string()))); + labels.bind_session(|| "session-b".to_string()); writer .write_all(b"{\"severity\":\"warn\",\"event\":\"configure.failed\"}\n") .await diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e8a158b11..2e0bbc63c 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -23451,57 +23451,103 @@ pub(crate) mod tests { #[tokio::test] async fn crash_recovery_resume_wrong_thread_id_is_rejected_not_silently_recovered() { let _guard = ENV_LOCK.lock().await; - configure_fake_codex_cmd(r#"{"threadResumeThreadId":"thread-B-wrong"}"#); + let log_dir = tempfile::tempdir().unwrap(); + let operation_log = log_dir.path().join("thread-operations.jsonl"); + configure_fake_codex_cmd( + &json!({ + "threadResumeThreadId": "thread-B-wrong", + "appendThreadOperationLogPath": operation_log, + }) + .to_string(), + ); let (st, mut rx) = state_with_bus(); - // A dead in-process client + a child that exits immediately: the exit-watcher - // flips `exited`, so the next attach takes the crash-recovery resume path. + // Keep the owned child alive until its watcher captures the process + // identity. An immediately exiting `true` can leave an unconfirmable + // prior, which correctly refuses recovery before thread/resume. + let ownership_id = "codex-sidecar-test-term25-recovery"; let (transport, _peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); - let mut cmd = tokio::process::Command::new("true"); - cmd.kill_on_drop(true); - let child = cmd.spawn().expect("spawn true fixture"); + let mut cmd = tokio::process::Command::new("node"); + cmd.args([ + "-e", + "process.stdin.resume(); process.stdin.once('end', () => process.exit(0)); process.stdout.write('ready\\n');", + ]) + .env(CODEX_SIDECAR_OWNERSHIP_ENV, ownership_id) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn controlled crash fixture"); + let exit_gate = child.stdin.take().expect("fixture exit gate"); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut ready = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut ready), + ) + .await + .expect("the fixture becomes ready within the budget") + .expect("read fixture readiness"); + assert_eq!(ready, "ready\n"); insert_fake_session( &st, "thread-A-crashed", Arc::new(client), Arc::new(StdMutex::new(None)), child, - "codex-sidecar-test-term25-recovery", + ownership_id, ) .await; - + // EOF makes the child exit on its own: this exercises the real + // unrequested-exit watcher, never the requested kill channel. + drop(exit_gate); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - let exited = st - .sessions - .lock() - .await - .get("thread-A-crashed") - .map(|s| s.exited.load(Ordering::SeqCst)) - .unwrap_or(false); - if exited { - return; + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["sessionId"] == "thread-A-crashed" + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "exited" + { + break; } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; } }) .await - .expect("the exit-watcher observes the crash within the budget"); - + .expect("the real exit-watcher observes the crash within the budget"); + assert!(st.sessions.lock().await["thread-A-crashed"] + .exited + .load(Ordering::SeqCst)); + let prior_before_attach = st + .condemned_priors + .lock() + .unwrap() + .get("thread-A-crashed") + .cloned(); + assert!(prior_before_attach.is_none(), "the fixture's writer death must be confirmed before testing resume: {prior_before_attach:?}"); st.handle_attach(attach_msg("thread-A-crashed")).await; + let mut observed = Vec::new(); let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(15), async { loop { let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); - if frame["type"] == "error" { + observed.push(frame.clone()); + if frame["type"] == "error" + || frame["event"]["type"] == "freshAgent.error" + || frame["event"]["type"] == "freshAgent.session.snapshot" + { return frame; } } }) .await - .expect("attach resolves within the budget"); + .unwrap_or_else(|error| { + panic!("attach resolves within the budget: {error:?}; frames={observed:?}") + }); + assert_eq!( + frame["type"], "error", + "the wrong-thread resume is rejected: {frame}" + ); let message = frame["message"].as_str().unwrap_or_default(); assert!( message.starts_with("CODEX_ATTACH_RESPAWN_FAILED:"), @@ -23515,6 +23561,19 @@ pub(crate) mod tests { !st.sessions.lock().await.contains_key("thread-B-wrong"), "the wrong thread id must never be adopted" ); + let operations: Vec = std::fs::read_to_string(operation_log) + .expect("the fake records real thread operations before answering") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + operations.len(), + 1, + "recovery never silently starts a fresh thread: {operations:?}" + ); + assert_eq!(operations[0]["method"], "thread/resume"); + assert_eq!(operations[0]["params"]["threadId"], "thread-A-crashed"); + assert_eq!(operations[0]["threadId"], "thread-B-wrong"); } // -- dead-thread negative cache (CODEX-FIRST triage Finding 2) -- diff --git a/crates/freshell-freshagent/src/hosted_rest.rs b/crates/freshell-freshagent/src/hosted_rest.rs deleted file mode 100644 index 4e8fd4a9d..000000000 --- a/crates/freshell-freshagent/src/hosted_rest.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Typed seam for routing the REST/MCP fresh-agent ingress to the durable -//! session-host owner. The implementation lives in `freshell-server`; this -//! crate deliberately knows nothing about supervisor IPC. - -use async_trait::async_trait; -use serde_json::Value; - -#[derive(Default)] -pub struct HostedRestProviderInputs { - pub plugins: Option>, - pub model_selection: Option>, - pub permission_mode: Option, - pub sandbox: Option, -} - -pub fn provider_inputs(body: &Value) -> Result { - let plugins = body - .get("plugins") - .map(|value| { - serde_json::from_value(value.clone()).map_err(|_| "invalid plugins".to_string()) - }) - .transpose()?; - let model_selection = body - .get("modelSelection") - .map(|value| { - serde_json::from_value(value.clone()).map_err(|_| "invalid modelSelection".to_string()) - }) - .transpose()?; - let permission_mode = body - .get("permissionMode") - .map(|value| { - serde_json::from_value(value.clone()).map_err(|_| "invalid permissionMode".to_string()) - }) - .transpose()?; - let sandbox = body - .get("sandbox") - .map(|value| { - serde_json::from_value(value.clone()).map_err(|_| "invalid sandbox".to_string()) - }) - .transpose()?; - Ok(HostedRestProviderInputs { - plugins, - model_selection, - permission_mode, - sandbox, - }) -} - -#[derive(Clone, Debug, PartialEq)] -pub struct HostedRestCreate { - pub request_id: String, - pub provider: String, - pub session_type: String, - pub cwd: Option, - pub model: Option, - pub effort: Option, - pub native_session_id: Option, - pub plugins: Option>, - pub model_selection: Option>, - pub permission_mode: Option, - pub sandbox: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCreated { - /// Stable presentation identity used by the web layout and gateway alias - /// table. It is not substituted for the provider's native identity. - pub session_id: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSend { - pub request_id: String, - pub session_id: String, - pub provider: String, - pub session_type: String, - pub text: String, - pub timeout_ms: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSendResult { - pub session_id: String, - pub completed: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCapture { - pub session_id: String, - pub provider: String, - pub session_type: String, - pub max_bytes: usize, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCaptureResult { - pub session_id: String, - pub native_session_id: String, - pub text: String, - pub truncated: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostedRestCaptureError { - Unsupported, - Unavailable, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSnapshot { - pub session_id: String, - pub provider: String, - pub session_type: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostedRestSnapshotError { - OwnershipUnavailable, - ManagedUnavailable, -} - -#[async_trait] -pub trait HostedFreshAgentRestGateway: Send + Sync { - /// None leaves genuinely unmanaged threads on their existing read path. - /// A hosted read failure must never fall back to saved history as live truth. - async fn snapshot( - &self, - _request: HostedRestSnapshot, - ) -> Result, HostedRestSnapshotError> { - Err(HostedRestSnapshotError::OwnershipUnavailable) - } - async fn create_agent( - self: std::sync::Arc, - request: HostedRestCreate, - ) -> Result; - - async fn send_agent(&self, request: HostedRestSend) -> Result; - - async fn capture( - &self, - _request: HostedRestCapture, - ) -> Result { - Err(HostedRestCaptureError::Unsupported) - } -} - -pub type SharedHostedFreshAgentRestGateway = std::sync::Arc; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hosted_rest_provider_inputs_preserve_explicit_model_clear_and_plugins() { - let body = serde_json::json!({ - "plugins": ["/workspace/plugin"], - "modelSelection": null, - "permissionMode": "default", - "sandbox": "workspace-write" - }); - let inputs = provider_inputs(&body).unwrap(); - assert_eq!(inputs.plugins, Some(vec!["/workspace/plugin".into()])); - assert_eq!(inputs.model_selection, Some(None)); - assert_eq!(inputs.permission_mode.as_deref(), Some("default")); - assert_eq!( - inputs.sandbox, - Some(freshell_protocol::Sandbox::WorkspaceWrite) - ); - assert!(provider_inputs(&serde_json::json!({"plugins": "wrong"})).is_err()); - } -} diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index e694a7a26..96c694da8 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -317,6 +317,17 @@ impl FreshAgentBindingUpsert { pub type SinkWrite = std::pin::Pin> + Send + 'static>>; +/// The awaited shutdown-marker write. `false` means the durable binding row +/// was absent or no longer belonged to a live fresh-agent session. +pub type SinkMarkTurnInterruptedWrite = + std::pin::Pin> + Send + 'static>>; + +/// The awaited one-shot shutdown-marker consumption. The timestamp is +/// returned only after the cleared marker is durable. +pub type SinkTakeTurnInterruptedWrite = std::pin::Pin< + Box>> + Send + 'static>, +>; + /// Delta-r6-r4 (focused-episode-6 round 3, Finding 3): the close envelope's /// failure classes — this crate's mirror of /// `freshell_ws::pane_ledger::CloseEnvelopeError` (it cannot see the ledger; @@ -429,6 +440,23 @@ pub type SinkAliasClearWrite = std::pin::Pin< pub trait PaneIdentitySink: Send + Sync { fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite; fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite; + /// Persist a freshclaude shutdown interruption marker on an existing + /// bound fresh-agent row. Called only for owed live sessions and bounded + /// by the caller during server shutdown. + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite; + /// Atomically clear and return a shutdown interruption marker. Call only + /// after the next successful create/attach acknowledgement; an error + /// leaves the durable marker available for a later retry. + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite; fn load_settings(&self, provider: &str, session_id: &str) -> Option; /// The fresh-agent binding row's genesis (claude core design §4.1), bound /// or retired; `None` when it has none or there is no fresh-agent row. @@ -739,6 +767,8 @@ pub(crate) struct FakeIdentitySink { /// bindings never enter (and a blank rewrite removes the key, matching /// the ledger's full-snapshot replace). pub recorded: std::sync::Mutex>, + /// The optional persisted-restart marker, keyed like a binding row. + pub turn_interrupted: std::sync::Mutex>, /// Focused-ep1-r4 Finding 2: the row's CURRENT provenance stamps, /// atomically applied/preserved on every `record_binding` — the ledger /// merge's (`pane_ledger.rs`) in-memory twin, backing `load_provenance`. @@ -1587,6 +1617,39 @@ impl PaneIdentitySink for FakeIdentitySink { } self.write_result() } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let result = if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::other("fake write failure")) + } else { + self.turn_interrupted + .lock() + .unwrap() + .insert((provider.into(), session_id.into()), at_ms); + Ok(true) + }; + Box::pin(std::future::ready(result)) + } + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let result = if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::other("fake write failure")) + } else { + Ok(self + .turn_interrupted + .lock() + .unwrap() + .remove(&(provider.into(), session_id.into()))) + }; + Box::pin(std::future::ready(result)) + } fn load_settings(&self, provider: &str, session_id: &str) -> Option { self.settings .lock() diff --git a/crates/freshell-freshagent/src/layout_store.rs b/crates/freshell-freshagent/src/layout_store.rs index 8446498e2..8008cf29f 100644 --- a/crates/freshell-freshagent/src/layout_store.rs +++ b/crates/freshell-freshagent/src/layout_store.rs @@ -32,6 +32,8 @@ use content::{migrate_legacy_fresh_agent_content, migrate_legacy_fresh_agent_nod mod persist; use persist::{load_persisted_clients, persist_locked}; +mod publication; + /// One ORDERED tab row (`UiSnapshot.tabs`, `layout-store.ts:7`). #[derive(Clone, Debug, Default)] pub struct TabRow { @@ -190,6 +192,8 @@ struct LayoutInner { /// In-memory only (a transient reconnect-reconciliation aid): the /// persist format serializes the client snapshots, not the stamps. recovery_stamps: std::collections::HashMap, + /// Ordered server creations not yet observed in a browser layout sync. + pending_publications: Vec, /// kata b8ke Task 10 (round-1 review, DURABILITY): `Some` → every /// mutation that changes the snapshot set rewrites the multi-client /// snapshot to this path (atomic temp+rename) and construction loads @@ -492,8 +496,10 @@ impl LayoutStore { seed_pane_title(&mut snapshot, &tab_id, &pane_id, &content); } } - let incoming_pane_ids = pane_ids_of(&snapshot); + let observed_pane_ids = pane_ids_of(&snapshot); let mut inner = self.lock(); + publication::preserve(&mut inner, &mut snapshot, &observed_pane_ids); + let incoming_pane_ids = pane_ids_of(&snapshot); // b8ke ext r25 F2: THE SERVER RECOVERY'S CONTENT AUTHORITY. A // pane mutated through an authoritative recovery path (an // `attach_pane_content` write-through — the REST/MCP respawn and @@ -519,6 +525,9 @@ impl LayoutStore { let mut stale_pane_writes: Vec = Vec::new(); let mut released_stamps: Vec = Vec::new(); for (pane_id, stamp) in inner.recovery_stamps.iter() { + if !observed_pane_ids.contains(pane_id) { + continue; + } let Some(incoming_content) = pane_content_in_snapshot(&snapshot, pane_id) else { // The pane is not in this sync — the stamp stays (a // later sync may still carry the stale copy). @@ -717,6 +726,22 @@ impl LayoutStore { /// `ensureSnapshot` + append an ordered tab with a single terminal leaf and /// a seeded title (`createTab`, `layout-store.ts:431-460`). pub fn create_tab(&self, title: Option<&str>) -> (String, String) { + self.create_tab_with_publication(title, None) + } + + pub(crate) fn create_pending_tab( + &self, + title: Option<&str>, + create_request_id: &str, + ) -> (String, String) { + self.create_tab_with_publication(title, Some(create_request_id)) + } + + fn create_tab_with_publication( + &self, + title: Option<&str>, + pending: Option<&str>, + ) -> (String, String) { let tab_id = Uuid::new_v4().to_string(); let pane_id = Uuid::new_v4().to_string(); // `buildContent({})` (`layout-store.ts:317-325`): a detached terminal pane. @@ -754,6 +779,17 @@ impl LayoutStore { if let Some(tab) = snapshot.tabs.iter_mut().find(|t| t.id == tab_id) { tab.name_source = name_source; } + if let Some(create_request_id) = pending { + inner + .pending_publications + .push(publication::PendingPublication { + tab_id: tab_id.clone(), + pane_id: pane_id.clone(), + placement: publication::Placement::Tab, + published: false, + create_request_id: Some(create_request_id.to_string()), + }); + } persist_locked(&inner); (tab_id, pane_id) } @@ -813,37 +849,15 @@ impl LayoutStore { /// the tab. pub fn close_tab(&self, tab_id: &str) -> RenameOutcome { let mut inner = self.lock(); + inner + .pending_publications + .retain(|entry| entry.tab_id != tab_id); if inner.clients.is_empty() { return RenameOutcome::failed("no layout snapshot"); } let mut found = false; for snapshot in inner.snapshots_mut() { - let Some(removed_index) = snapshot.tabs.iter().position(|t| t.id == tab_id) else { - continue; - }; - // Cursor parity with the client's removeTab reducer: a BACKGROUND - // close never moves the selection (a failed agent create rolls the - // layout back to exactly the user's tab); only closing the cursor's - // own tab selects a survivor (previous neighbor, else the new first). - let was_active = snapshot.active_tab_id.as_deref() == Some(tab_id); - snapshot.tabs.remove(removed_index); - snapshot.layouts.remove(tab_id); - snapshot.active_pane.remove(tab_id); - snapshot.pane_titles.remove(tab_id); - snapshot.pane_title_set_by_user.remove(tab_id); - if was_active { - let next_index = if removed_index > 0 { - removed_index - 1 - } else { - 0 - }; - snapshot.active_tab_id = snapshot - .tabs - .get(next_index) - .or_else(|| snapshot.tabs.first()) - .map(|t| t.id.clone()); - } - found = true; + found |= close_tab_in(snapshot, tab_id); } if found { persist_locked(&inner); @@ -1053,6 +1067,23 @@ impl LayoutStore { &self, pane_id: &str, direction: &str, + ) -> Result<(String, String), &'static str> { + self.split_pane_with_publication(pane_id, direction, false) + } + + pub(crate) fn split_pending_pane( + &self, + pane_id: &str, + direction: &str, + ) -> Result<(String, String), &'static str> { + self.split_pane_with_publication(pane_id, direction, true) + } + + fn split_pane_with_publication( + &self, + pane_id: &str, + direction: &str, + pending: bool, ) -> Result<(String, String), &'static str> { let mut inner = self.lock(); let new_pane_id = Uuid::new_v4().to_string(); @@ -1098,6 +1129,21 @@ impl LayoutStore { } match first { Some(tab_id) => { + if pending { + inner + .pending_publications + .push(publication::PendingPublication { + tab_id: tab_id.clone(), + pane_id: new_pane_id.clone(), + placement: publication::Placement::Split { + source_pane_id: pane_id.to_string(), + split_node_id, + direction: direction.to_string(), + }, + published: false, + create_request_id: None, + }); + } persist_locked(&inner); Ok((tab_id, new_pane_id)) } @@ -1105,6 +1151,97 @@ impl LayoutStore { } } + /// Remove an unannounced split leaf after its runtime failed to launch. + /// Preserve the surrounding split IDs, direction, sizes and any content + /// updates that arrived while creation awaited the provider. + pub(crate) fn rollback_split(&self, pane_id: &str) -> bool { + let mut inner = self.lock(); + inner + .pending_publications + .retain(|entry| entry.pane_id != pane_id); + let mut removed = false; + for snapshot in inner.snapshots_mut() { + let sole_tab = snapshot.layouts.iter().find_map(|(tab_id, root)| { + matches!(root, PaneNode::Leaf { id, .. } if id == pane_id).then(|| tab_id.clone()) + }); + if let Some(tab_id) = sole_tab { + removed |= close_tab_in(snapshot, &tab_id); + continue; + } + let tab_id = snapshot.layouts.iter_mut().find_map(|(tab_id, root)| { + collapse_split_leaf(root, pane_id).then(|| tab_id.clone()) + }); + if let Some(tab_id) = tab_id { + remove_pane_metadata(snapshot, &tab_id, pane_id); + removed = true; + } + } + if removed { + inner.recovery_stamps.remove(pane_id); + persist_locked(&inner); + } + removed + } + + /// A settled send can change its live conversation (/clear). Update only + /// the pane that still names the accepted conversation; a concurrent + /// replacement owns its newer content. The same lock guards that check + /// and the write, and the recovery stamp protects an old client mirror. + pub(crate) fn rekey_agent_pane_if_current( + &self, + pane_id: &str, + provider: &str, + session_type: &str, + before: &str, + after: &str, + ) -> bool { + let matches = |content: &Value| { + let identity = content + .get("sessionRef") + .filter(|reference| reference["provider"].as_str() == Some(provider)) + .and_then(|reference| reference["sessionId"].as_str()) + .filter(|id| !id.is_empty()) + .or_else(|| content["sessionId"].as_str()); + content["kind"] == "fresh-agent" + && content["provider"].as_str() == Some(provider) + && content["sessionType"].as_str() == Some(session_type) + && identity == Some(before) + }; + let mut inner = self.lock(); + let Some(pre_recovery) = inner + .snapshots() + .find_map(|snapshot| pane_content_in_snapshot(snapshot, pane_id)) + else { + return false; + }; + if !matches(&pre_recovery) { + return false; + } + for snapshot in inner.snapshots_mut() { + for root in snapshot.layouts.values_mut() { + let Some(PaneNode::Leaf { content, .. }) = root.find_leaf(pane_id) else { + continue; + }; + if !matches(content) { + continue; + } + let mut current = content.clone(); + current["sessionId"] = json!(after); + current["sessionRef"] = json!({ "provider":provider, "sessionId":after }); + root.replace_leaf_content(pane_id, current); + break; + } + } + inner.recovery_stamps.insert( + pane_id.to_string(), + RecoveryStamp { + pre_recovery_content: pre_recovery, + }, + ); + persist_locked(&inner); + true + } + /// Re-seeds the derived title (non-sticky). Runs the legacy content /// migration first (`attachPaneContent`, `layout-store.ts:680-694`). pub fn attach_pane_content( @@ -1113,7 +1250,15 @@ impl LayoutStore { pane_id: &str, content: Value, ) -> RenameOutcome { - let mut inner = self.lock(); + Self::attach_pane_content_locked(&mut self.lock(), tab_id, pane_id, content) + } + + fn attach_pane_content_locked( + inner: &mut LayoutInner, + tab_id: &str, + pane_id: &str, + content: Value, + ) -> RenameOutcome { if inner.clients.is_empty() { return RenameOutcome::failed("no layout snapshot"); } @@ -1176,6 +1321,16 @@ impl LayoutStore { found = true; } if found { + if let Some(pending) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + { + pending.create_request_id = normalized + .get("createRequestId") + .and_then(Value::as_str) + .map(str::to_string); + } // b8ke ext r25 F2: record the recovery stamp ONLY when the // write actually changed the pane's content — an idempotent // re-attach (the same content) stamps nothing. @@ -1187,7 +1342,7 @@ impl LayoutStore { }, ); } - persist_locked(&inner); + persist_locked(inner); RenameOutcome::tab_pane(tab_id, pane_id) } else { RenameOutcome::failed("tab not found") @@ -1230,6 +1385,13 @@ impl LayoutStore { // b8ke ext r25 F2: a closed pane's recovery stamp is dead // weight — the pane no longer exists to protect. inner.recovery_stamps.remove(pane_id); + inner.pending_publications.retain(|entry| { + entry.pane_id != pane_id + && (entry.published + || !matches!(&entry.placement, + publication::Placement::Split { source_pane_id, .. } + if source_pane_id == pane_id)) + }); persist_locked(&inner); } first.unwrap_or(Err("pane not found")) @@ -1614,6 +1776,27 @@ fn string_field(content: &Value, key: &str) -> Option { /// Every pane id in every tab layout of one snapshot — the supersede /// (subset) check's unit of coverage. +fn close_tab_in(snapshot: &mut UiSnapshot, tab_id: &str) -> bool { + let Some(removed_index) = snapshot.tabs.iter().position(|tab| tab.id == tab_id) else { + return false; + }; + let was_active = snapshot.active_tab_id.as_deref() == Some(tab_id); + snapshot.tabs.remove(removed_index); + snapshot.layouts.remove(tab_id); + snapshot.active_pane.remove(tab_id); + snapshot.pane_titles.remove(tab_id); + snapshot.pane_title_set_by_user.remove(tab_id); + if was_active { + let next_index = removed_index.saturating_sub(1); + snapshot.active_tab_id = snapshot + .tabs + .get(next_index) + .or_else(|| snapshot.tabs.first()) + .map(|tab| tab.id.clone()); + } + true +} + fn pane_ids_of(snapshot: &UiSnapshot) -> std::collections::HashSet { snapshot .layouts @@ -1687,6 +1870,19 @@ fn close_pane_in(snapshot: &mut UiSnapshot, pane_id: &str) -> Option bool { + let PaneNode::Split { children, .. } = root else { + return false; + }; + for removed in 0..2 { + if matches!(&children[removed], PaneNode::Leaf { id, .. } if id == pane_id) { + *root = children[1 - removed].clone(); + return true; + } + } + collapse_split_leaf(&mut children[0], pane_id) || collapse_split_leaf(&mut children[1], pane_id) +} + fn find_pane_tab(snapshot: &UiSnapshot, pane_id: &str) -> Option { snapshot .tabs diff --git a/crates/freshell-freshagent/src/layout_store/publication.rs b/crates/freshell-freshagent/src/layout_store/publication.rs new file mode 100644 index 000000000..33daae715 --- /dev/null +++ b/crates/freshell-freshagent/src/layout_store/publication.rs @@ -0,0 +1,166 @@ +//! Keep a server-created pane addressable until the browser observes its +//! announcement. Merge only that creation into an older browser layout. + +use super::*; + +pub(super) struct PendingPublication { + pub tab_id: String, + pub pane_id: String, + pub placement: Placement, + pub published: bool, + pub create_request_id: Option, +} + +pub(super) enum Placement { + Tab, + Split { + source_pane_id: String, + split_node_id: String, + direction: String, + }, +} + +pub(super) fn preserve( + inner: &mut LayoutInner, + incoming: &mut UiSnapshot, + observed_panes: &std::collections::HashSet, +) { + let mut pending = std::mem::take(&mut inner.pending_publications); + pending.retain(|entry| !(entry.published && observed_panes.contains(&entry.pane_id))); + for entry in &pending { + if pane_content_in_snapshot(incoming, &entry.pane_id).is_some() { + continue; + } + let Some(current) = inner + .snapshots() + .find(|snapshot| pane_content_in_snapshot(snapshot, &entry.pane_id).is_some()) + else { + continue; + }; + let Some(content) = pane_content_in_snapshot(current, &entry.pane_id) else { + continue; + }; + let leaf = PaneNode::Leaf { + id: entry.pane_id.clone(), + content: content.clone(), + }; + let merged = match &entry.placement { + Placement::Tab => { + if incoming.tabs.iter().any(|tab| tab.id == entry.tab_id) { + false + } else if let Some(tab) = current.tabs.iter().find(|tab| tab.id == entry.tab_id) { + incoming.tabs.push(tab.clone()); + incoming.layouts.insert(entry.tab_id.clone(), leaf); + incoming + .active_pane + .insert(entry.tab_id.clone(), entry.pane_id.clone()); + true + } else { + false + } + } + Placement::Split { + source_pane_id, + split_node_id, + direction, + } => { + let Some(root) = incoming.layouts.get_mut(&entry.tab_id) else { + continue; + }; + let Some(source) = root.find_leaf(source_pane_id).cloned() else { + continue; + }; + replace_node( + root, + source_pane_id, + &PaneNode::Split { + id: split_node_id.clone(), + direction: direction.clone(), + sizes: [50.0, 50.0], + children: Box::new([source, leaf]), + }, + ) + } + }; + if merged { + seed_pane_title(incoming, &entry.tab_id, &entry.pane_id, &content); + tracing::debug!( + event = "agent_api.layout_creation_preserved", + tab_id = %entry.tab_id, + pane_id = %entry.pane_id, + published = entry.published, + "preserved a server-created pane across a pre-announcement layout sync" + ); + } + } + inner.pending_publications = pending; +} + +impl LayoutStore { + pub(crate) fn set_pending_create_request(&self, pane_id: &str, request_id: &str) -> bool { + let mut inner = self.lock(); + let Some(entry) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + else { + return false; + }; + entry.create_request_id = Some(request_id.to_string()); + true + } + + /// Register caches and queue the announcement before a close can acquire + /// the layout lock. The callback must stay synchronous and not reenter us. + pub(crate) fn finish_pending_pane( + &self, + tab_id: &str, + pane_id: &str, + content: Value, + publish: impl FnOnce(), + ) -> bool { + let mut inner = self.lock(); + if !inner + .pending_publications + .iter() + .any(|entry| entry.pane_id == pane_id) + || !inner.snapshots().any(|snapshot| { + snapshot + .layouts + .get(tab_id) + .and_then(|root| root.find_leaf(pane_id)) + .is_some() + }) + { + inner + .pending_publications + .retain(|entry| entry.pane_id != pane_id); + return false; + } + Self::attach_pane_content_locked(&mut inner, tab_id, pane_id, content); + if let Some(entry) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + { + entry.published = true; + } + publish(); + true + } + + /// `pane.closed` is acknowledged before the browser removes its leaf. + /// Release publication authority first so its next layout sync can close it. + pub fn release_pane_publication(&self, create_request_id: &str) { + let mut inner = self.lock(); + let released: std::collections::HashSet<_> = inner.pending_publications.iter().filter(|entry| { + entry.create_request_id.as_deref() == Some(create_request_id) + || (!entry.published && matches!(&entry.placement, Placement::Split { source_pane_id, .. } + if inner.snapshots().find_map(|snapshot| pane_content_in_snapshot(snapshot, source_pane_id)) + .is_some_and(|content| content.get("createRequestId").and_then(Value::as_str) == Some(create_request_id)))) + }).map(|entry| entry.pane_id.clone()).collect(); + inner + .pending_publications + .retain(|entry| !released.contains(&entry.pane_id)); + } +} diff --git a/crates/freshell-freshagent/src/layout_store_tests.rs b/crates/freshell-freshagent/src/layout_store_tests.rs index d2ee437a3..32eb780b5 100644 --- a/crates/freshell-freshagent/src/layout_store_tests.rs +++ b/crates/freshell-freshagent/src/layout_store_tests.rs @@ -23,6 +23,39 @@ fn split(id: &str, direction: &str, sizes: [i64; 2], a: Value, b: Value) -> Valu }) } +#[test] +fn failed_split_rollback_preserves_the_surrounding_tree_and_later_content_updates() { + let store = LayoutStore::default(); + let original = split( + "existing-split", + "vertical", + [30, 70], + leaf("p1", json!({"kind":"terminal", "terminalId":"old"})), + leaf("p2", json!({"kind":"browser", "url":"https://example.com"})), + ); + store.update_from_ui( + &sync_from(json!({ + "tabs":[{"id":"tab"}], "activeTabId":"tab", "layouts":{"tab":original}, + "activePane":{"tab":"p1"}, "timestamp":1, + })), + "client", + ); + let (_, temporary) = store.split_pane("p1", "horizontal").unwrap(); + store.attach_pane_content( + "tab", + "p1", + json!({"kind":"terminal", "terminalId":"replacement"}), + ); + assert!(store.rollback_split(&temporary)); + let mut expected = original; + expected["children"][0]["content"]["terminalId"] = json!("replacement"); + let snapshot = store.get_normalized_snapshot(None); + assert_eq!(snapshot["layouts"]["tab"], expected); + assert_eq!(snapshot["activePane"]["tab"], "p1"); + assert!(store.get_pane_snapshot(&temporary).is_none()); + assert!(!store.rollback_split(&temporary)); +} + /// One tab `t1`, single claude-terminal leaf `p1`. fn single_pane_sync( pane_titles: Option, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index fb42cd91f..c6b924988 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -41,6 +41,7 @@ pub(crate) mod attention; pub mod attention_ledger; pub mod claude; +pub mod claude_create_defaults; pub(crate) mod claude_dead_sessions; pub(crate) mod claude_edges; pub mod claude_freshell_mcp; @@ -50,6 +51,7 @@ pub(crate) mod claude_live_events; pub(crate) mod claude_permission_update; pub(crate) mod claude_projection; pub(crate) mod claude_projection_cache; +pub mod claude_rest_lane; pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; @@ -59,7 +61,6 @@ pub(crate) mod claude_turns; pub mod codex; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; -pub mod hosted_rest; pub mod identity_sink; pub mod layout_store; pub mod layout_tree; @@ -77,6 +78,9 @@ 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 rest_lane; +pub(crate) mod rest_lane_create; +mod rest_lane_sync; pub mod rollback_record; pub mod session_handoff; pub mod session_lease; @@ -89,7 +93,7 @@ pub mod terminal_tabs; #[cfg(test)] mod test_environment; -pub use claude::{ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; +pub use claude::{ClaudeCleanupError, 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 @@ -109,7 +113,7 @@ pub use identity_sink::{ BindProvenance, ClaimCommit, ClaudeGenesis, CloseAnswer, FreshAgentBindingUpsert, FreshAgentSettings, GenesisUpdate, PaneIdentitySink, ProvenanceUpdate, RecordedGenesis, SharedPaneIdentitySink, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, SinkCommitWrite, - SinkWrite, + SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; pub use opencode_ws::FreshOpencodeState; pub use rollback_record::{ @@ -2084,7 +2088,9 @@ pub struct FreshAgentState { opencode: SharedOpencodeManagerHandle, /// Opt-in durable host gateway. When installed, REST/MCP fresh-agent /// requests never touch this web process's legacy provider manager. - hosted_rest: Arc>, + hosted_rest: Arc>, + local_claude_rest: Arc>, + rest_create_settings: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, /// Slice 1 (`docs/plans/2026-07-18-agent-api-mcp-parity-spec.md`): the SAME @@ -2423,6 +2429,8 @@ impl FreshAgentState { panes: Arc::new(Mutex::new(HashMap::new())), opencode: Arc::new(tokio::sync::Mutex::new(None)), hosted_rest: Arc::new(std::sync::OnceLock::new()), + local_claude_rest: Arc::new(std::sync::OnceLock::new()), + rest_create_settings: Arc::new(std::sync::OnceLock::new()), sessions_revision: Arc::new(AtomicI64::new(0)), terminal_registry: None, session_identity: None, @@ -2548,17 +2556,36 @@ impl FreshAgentState { pub fn set_hosted_rest_gateway( &self, - gateway: hosted_rest::SharedHostedFreshAgentRestGateway, + gateway: rest_lane::SharedFreshAgentRestLane, ) -> Result<(), &'static str> { self.hosted_rest .set(gateway) .map_err(|_| "hosted fresh-agent REST gateway is already installed") } - fn hosted_rest_gateway(&self) -> Option { + fn hosted_rest_gateway(&self) -> Option { self.hosted_rest.get().cloned() } + pub fn set_claude_rest_lane( + &self, + lane: rest_lane::SharedFreshAgentRestLane, + ) -> Result<(), &'static str> { + self.local_claude_rest + .set(lane) + .map_err(|_| "local Claude REST lane is already installed") + } + + fn rest_lane_for(&self, provider: &str) -> Option { + self.hosted_rest_gateway().or_else(|| { + if provider == "claude" { + self.local_claude_rest.get().cloned() + } else { + None + } + }) + } + /// Install a scripted model-catalog probe (tests) — the registry's /// `opencodeCatalogProvider` injection seam /// (`model-capability-registry.ts:196-202`). Rebuilds the registry around @@ -4731,6 +4758,31 @@ fn fail_json(status: StatusCode, message: String) -> Response { .into_response() } +fn rest_lane_failure(error: rest_lane::RestLaneError) -> Response { + use rest_lane::RestLaneErrorKind; + let status = match error.kind { + RestLaneErrorKind::BadRequest => StatusCode::BAD_REQUEST, + RestLaneErrorKind::Forbidden => StatusCode::FORBIDDEN, + RestLaneErrorKind::NotFound => StatusCode::NOT_FOUND, + RestLaneErrorKind::Conflict => StatusCode::CONFLICT, + RestLaneErrorKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE, + RestLaneErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + let mut body = json!({ "status": "error", "code": error.code, "message": error.message }); + if let Some(retryable) = error.retryable { + body["retryable"] = json!(retryable); + } + if let Some(retry_after_ms) = error.retry_after_ms { + body["retryAfterMs"] = json!(retry_after_ms); + } + if let Some(owner) = error.owner { + body["ownerKind"] = json!(owner.owner_kind); + body["ownerGeneration"] = json!(owner.owner_generation); + body["ownerEpoch"] = json!(owner.owner_epoch); + } + (status, Json(body)).into_response() +} + /// `fail_json` + a machine-readable code, matching how the WS side keys /// errors (`error["code"] == "RESTORE_UNAVAILABLE"`). Envelope is additive: /// `{status:"error", code, message}`. @@ -4860,49 +4912,19 @@ async fn create_tab( .map(str::to_string); let name = body.get("name").and_then(Value::as_str).map(str::to_string); - if let Some(gateway) = state.hosted_rest_gateway() { - let provider_inputs = match hosted_rest::provider_inputs(&body) { - Ok(inputs) => inputs, - Err(error) => return fail_json(StatusCode::BAD_REQUEST, error), - }; - let native_session_id = match body.get("sessionRef") { - None => None, - Some(value) => match serde_json::from_value::(value.clone()) { - Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { - Some(locator.session_id) - } - _ => { - return fail_json( - StatusCode::BAD_REQUEST, - format!("sessionRef must identify a {provider} session"), - ) - } - }, + if let Some(gateway) = state.rest_lane_for(provider) { + let request = match state + .build_rest_lane_create(provider, session_type, &body) + .await + { + Ok(request) => request, + Err(error) => return rest_lane_failure(error), }; - return create_hosted_agent_tab( - &state, - gateway, - hosted_rest::HostedRestCreate { - request_id: Uuid::new_v4().simple().to_string(), - provider: provider.into(), - session_type: session_type.into(), - cwd, - model, - effort, - native_session_id, - plugins: provider_inputs.plugins, - model_selection: provider_inputs.model_selection, - permission_mode: provider_inputs.permission_mode, - sandbox: provider_inputs.sandbox, - }, - name, - ) - .await; + return create_lane_agent_tab(&state, gateway, request, name).await; } - // The retained in-process implementation is OpenCode-only. Every hosted - // mode above uses the common supervisor gateway; never silently fall back - // to a web-owned Claude/Codex provider. + // OpenCode retains its existing implementation. Codex REST creation needs + // the opt-in hosted gateway; the local lane drives Claude modes only. if agent != "opencode" { return fail_json( StatusCode::SERVICE_UNAVAILABLE, @@ -5050,69 +5072,62 @@ async fn create_tab( ) } -async fn create_hosted_agent_tab( +async fn create_lane_agent_tab( state: &FreshAgentState, - gateway: hosted_rest::SharedHostedFreshAgentRestGateway, - request: hosted_rest::HostedRestCreate, + gateway: rest_lane::SharedFreshAgentRestLane, + mut request: rest_lane::RestLaneCreate, name: Option, ) -> Response { - let created = match gateway.create_agent(request.clone()).await { + let (tab_id, pane_id) = state + .layout + .create_pending_tab(name.as_deref(), &request.request_id); + request.tab_id = Some(tab_id.clone()); + request.pane_id = Some(pane_id.clone()); + let created = match Arc::clone(&gateway).create_agent(request.clone()).await { Ok(created) => created, - Err(()) => { - return fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent host could not be created".to_string(), - ) + Err(error) => { + state.layout.close_tab(&tab_id); + return rest_lane_failure(error); } }; - let hosted_rest::HostedRestCreate { - request_id, - provider, - session_type, - cwd, - model, - effort, - .. - } = request; - let (tab_id, pane_id) = state.layout.create_tab(name.as_deref()); - let mut pane_content = json!({ - "kind": "fresh-agent", - "sessionType": session_type, - "provider": provider, - "sessionId": created.session_id, - "createRequestId": request_id, - "status": "connected", - }); - if let Some(value) = &cwd { - pane_content["initialCwd"] = json!(value); - } - if let Some(value) = &model { - pane_content["model"] = json!(value); + let pane_content = rest_lane_create::pane_content(&request, &created); + let published = + state + .layout + .finish_pending_pane(&tab_id, &pane_id, pane_content.clone(), || { + register_fresh_agent_tab_cache( + state, + &tab_id, + &pane_id, + name.as_deref(), + PaneEntry { + placeholder_id: created.session_id.clone(), + provider: request.provider.clone(), + session_type: request.session_type.clone(), + cwd: request.cwd.clone(), + model: request.model.clone(), + effort: request.effort.clone(), + durable_id: Some( + created + .session_ref + .as_ref() + .map(|reference| reference.session_id.clone()) + .unwrap_or_else(|| created.session_id.clone()), + ), + }, + ); + broadcast_tab_create(state, &tab_id, &pane_id, name.as_deref(), &pane_content); + }); + if !published { + return state + .abandon_rest_create(&gateway, &request, &created) + .await; } - if let Some(value) = &effort { - pane_content["effort"] = json!(value); + let mut data = json!({"tabId":tab_id,"paneId":pane_id,"sessionId":created.session_id}); + if let Some(reference) = created.session_ref { + data["sessionRef"] = json!(reference); } - register_fresh_agent_tab( - state, - &tab_id, - &pane_id, - name.as_deref(), - &pane_content, - PaneEntry { - placeholder_id: created.session_id.clone(), - provider, - session_type, - cwd, - model, - effort, - durable_id: Some(created.session_id.clone()), - }, - ); - broadcast_tab_create(state, &tab_id, &pane_id, name.as_deref(), &pane_content); - ok_json( - json!({"tabId":tab_id,"paneId":pane_id,"sessionId":created.session_id}), - "fresh-agent pane created", - ) + ok_json(data, "fresh-agent pane created") } /// The registration every fresh-agent pane-minting path needs: attach the pane @@ -5131,6 +5146,16 @@ fn register_fresh_agent_tab( state .layout .attach_pane_content(tab_id, pane_id, pane_content.clone()); + register_fresh_agent_tab_cache(state, tab_id, pane_id, name, pane_entry); +} + +fn register_fresh_agent_tab_cache( + state: &FreshAgentState, + tab_id: &str, + pane_id: &str, + name: Option<&str>, + pane_entry: PaneEntry, +) { state.tabs.lock().expect("tabs mutex").insert( tab_id.to_string(), TabRecord { @@ -5149,16 +5174,41 @@ fn register_fresh_agent_tab( .insert(pane_id.to_string(), tab_id.to_string()); } -/// A web restart drops the REST pane map while the shared layout and hosted -/// runtime survive. Resolve hosted panes from the persisted layout on a miss; -/// the gateway still checks the session against the supervisor inventory. +/// Browser-created panes and a restored layout need not have a REST pane row. +/// Resolve them when their provider has a lane; that lane owns the live-session +/// checks and transcript lookup. fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option { - if let Some(pane) = state.panes.lock().expect("panes mutex").get(pane_id) { - return Some(pane.clone()); - } - state.hosted_rest_gateway()?; - let snapshot = state.layout.get_pane_snapshot(pane_id)?; - let content = snapshot.pane_content?; + let cached = state + .panes + .lock() + .expect("panes mutex") + .get(pane_id) + .cloned(); + let Some(snapshot) = state.layout.get_pane_snapshot(pane_id) else { + return cached; + }; + // An existing snapshot is authoritative even when its current content + // cannot be controlled by this route. A retired cache row must not make + // a browser, shell or unsupported provider address the previous session. + let current = pane_entry_from_content(snapshot.pane_content.as_ref()?)?; + if state.rest_lane_for(¤t.provider).is_some() { + return Some(current); + } + // Local OpenCode predates the generic lane and keeps its materialized + // identity in the cache while the layout can still show its placeholder. + // Retain that path only for the same, still-visible OpenCode identity. + let cached = cached?; + (current.provider == PROVIDER + && current.session_type == SESSION_TYPE + && cached.provider == PROVIDER + && cached.session_type == SESSION_TYPE + && (current.durable_id == cached.durable_id + || (current.placeholder_id == cached.placeholder_id + && current.durable_id.as_ref() == Some(¤t.placeholder_id)))) + .then_some(cached) +} + +fn pane_entry_from_content(content: &Value) -> Option { if content.get("kind")?.as_str()? != "fresh-agent" { return None; } @@ -5173,10 +5223,18 @@ fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option(value.clone()).ok()) + .filter(|locator| locator.provider == provider && !locator.session_id.is_empty()) + .map(|locator| locator.session_id); + let session_id = content + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .or_else(|| durable_id.clone())?; + let durable_id = durable_id.unwrap_or_else(|| session_id.clone()); Some(PaneEntry { placeholder_id: session_id.clone(), provider: provider.to_string(), @@ -5193,7 +5251,7 @@ fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option fence, + Err(error) => return rest_lane_failure(error), + }; let request_id = body .get("requestId") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); + let addressed_session_id = pane + .durable_id + .clone() + .unwrap_or_else(|| pane.placeholder_id.clone()); let result = gateway - .send_agent(hosted_rest::HostedRestSend { + .send_agent(rest_lane::RestLaneSend { request_id: request_id.clone(), - session_id: pane.placeholder_id.clone(), + session_id: addressed_session_id.clone(), provider: pane.provider.clone(), session_type: pane.session_type.clone(), text, timeout_ms: u64::try_from(turn_timeout.as_millis()).unwrap_or(u64::MAX), + observed_epoch, + observed_generation, }) .await; return match result { - Ok(result) if result.completed => ok_json( - json!({ - "paneId":pane_id, - "sessionId":result.session_id, - "submittedTurnId":request_id, - "sessionRef":{"provider":pane.provider,"sessionId":result.session_id}, - "status":"idle", - }), - "prompt sent", - ), - Ok(result) => approx_json( - json!({ + Ok(result) => { + state.reconcile_rest_send_session( + &pane_id, + &pane, + &addressed_session_id, + &result.session_id, + ); + let mut data = json!({ "paneId":pane_id, "sessionId":result.session_id, - "submittedTurnId":request_id, "sessionRef":{"provider":pane.provider,"sessionId":result.session_id}, - "status":"approx", - }), - "prompt sent; turn did not complete within deadline", - ), - Err(()) => fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent input was not accepted".to_string(), - ), + "status":if result.noop { "noop" } else if result.completed { "idle" } else { "approx" }, + }); + if !result.noop { + data["submittedTurnId"] = json!(result.submitted_turn_id.unwrap_or(request_id)); + } + if result.noop { + ok_json(data, "no input sent") + } else if result.completed { + ok_json(data, "prompt sent") + } else { + approx_json(data, "prompt sent; turn did not complete within deadline") + } + } + Err(error) => rest_lane_failure(error), }; } @@ -7074,7 +7143,7 @@ async fn capture( return fail_json(StatusCode::NOT_FOUND, "pane not found".to_string()); } }; - if let Some(gateway) = state.hosted_rest_gateway() { + if let Some(gateway) = state.rest_lane_for(&pane.provider) { let session_id = pane .durable_id .clone() @@ -7085,7 +7154,7 @@ async fn capture( .unwrap_or(256 * 1024) .clamp(1, 256 * 1024); return match gateway - .capture(hosted_rest::HostedRestCapture { + .capture(rest_lane::RestLaneCapture { session_id, provider: pane.provider.clone(), session_type: pane.session_type.clone(), @@ -7094,13 +7163,17 @@ async fn capture( .await { Ok(capture) => text_plain(capture.text), - Err(hosted_rest::HostedRestCaptureError::Unsupported) => fail_json( + Err(rest_lane::RestLaneCaptureError::Unsupported) => fail_json( StatusCode::UNPROCESSABLE_ENTITY, - "hosted fresh-agent provider does not support transcript capture".to_string(), + "fresh-agent provider does not support transcript capture".to_string(), ), - Err(hosted_rest::HostedRestCaptureError::Unavailable) => fail_json( + Err(rest_lane::RestLaneCaptureError::Unavailable) => fail_json( StatusCode::SERVICE_UNAVAILABLE, - "hosted fresh-agent transcript is temporarily unavailable".to_string(), + "fresh-agent transcript is temporarily unavailable".to_string(), + ), + Err(rest_lane::RestLaneCaptureError::NotFound) => fail_json( + StatusCode::NOT_FOUND, + "fresh-agent transcript was not found".to_string(), ), }; } @@ -9269,40 +9342,41 @@ mod tests { // -- P1.13 Task 7: REST send-keys materialization writes a binding row -- #[derive(Default)] - struct RecordingHostedRestGateway { - sends: Mutex>, - captures: Mutex>, + struct RecordingRestLaneGateway { + sends: Mutex>, + captures: Mutex>, } #[async_trait::async_trait] - impl hosted_rest::HostedFreshAgentRestGateway for RecordingHostedRestGateway { + impl rest_lane::FreshAgentRestLane for RecordingRestLaneGateway { async fn create_agent( self: Arc, - _request: hosted_rest::HostedRestCreate, - ) -> Result { - Ok(hosted_rest::HostedRestCreated { + _request: rest_lane::RestLaneCreate, + ) -> Result { + Ok(rest_lane::RestLaneCreated { session_id: "managed-kilroy-recovered".into(), + ..Default::default() }) } async fn send_agent( &self, - request: hosted_rest::HostedRestSend, - ) -> Result { + request: rest_lane::RestLaneSend, + ) -> Result { self.sends.lock().expect("sends mutex").push(request); - Ok(hosted_rest::HostedRestSendResult { + Ok(rest_lane::RestLaneSendResult { session_id: "managed-kilroy-recovered".into(), completed: true, + ..Default::default() }) } async fn capture( &self, - request: hosted_rest::HostedRestCapture, - ) -> Result - { + request: rest_lane::RestLaneCapture, + ) -> Result { self.captures.lock().expect("captures mutex").push(request); - Ok(hosted_rest::HostedRestCaptureResult { + Ok(rest_lane::RestLaneCaptureResult { session_id: "managed-kilroy-recovered".into(), native_session_id: "kilroy-native".into(), text: "recovered transcript".into(), @@ -9315,7 +9389,7 @@ mod tests { async fn hosted_rest_send_and_capture_recover_pane_from_persisted_layout_after_web_restart() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("layout.json"); - let gateway = Arc::new(RecordingHostedRestGateway::default()); + let gateway = Arc::new(RecordingRestLaneGateway::default()); let first = state().with_layout(layout_store::LayoutStore::with_persistence(path.clone())); first.set_hosted_rest_gateway(gateway.clone()).unwrap(); let mut headers = HeaderMap::new(); diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 0de554c5e..c2c2f06b3 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -271,6 +271,11 @@ pub struct FreshOpencodeState { /// `Started`), mirroring the freshcodex onExit self-heal. See /// [`Self::ensure_daemon_loss_watcher`]. daemon_loss_watcher: Arc>, + /// Test-only pause after a revival restart, before its ownership guard releases. + #[cfg(test)] + revival_completion_pause: Arc>>, + #[cfg(test)] + daemon_loss_watcher_bootstrapped: Arc, /// ep2-r3 test seam (`cfg(test)`-only): when armed for a durable id, the /// post-commit rescue tail parks BETWEEN its map re-lookup clone and the /// session-lock take — the exact window the ep2-r3 fresheyes Major pins @@ -302,6 +307,13 @@ struct RescueStallGate { release_rx: StdMutex>>, } +#[cfg(test)] +struct RevivalCompletionPause { + session_id: String, + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, +} + /// The handles [`FreshOpencodeState::arm_rescue_stall`] hands the test /// (ep2-r3 test seam, `cfg(test)`-only): `entered` fires at the parked point; /// `release` lets the rescue proceed. @@ -860,6 +872,10 @@ impl FreshOpencodeState { condemned_sessions: Arc::new(std::sync::Mutex::new(HashMap::new())), daemon_loss_watcher: Arc::new(std::sync::OnceLock::new()), #[cfg(test)] + revival_completion_pause: Arc::new(StdMutex::new(None)), + #[cfg(test)] + daemon_loss_watcher_bootstrapped: Arc::new(tokio::sync::Notify::new()), + #[cfg(test)] rescue_stall: Arc::new(StdMutex::new(None)), #[cfg(test)] rescue_lookup_stall: Arc::new(StdMutex::new(None)), @@ -8202,6 +8218,8 @@ impl FreshOpencodeState { // history — a daemon that re-warmed before this subscription // must still get its dead bridges revived now. state.revive_dead_bridges_if_daemon_running().await; + #[cfg(test)] + state.daemon_loss_watcher_bootstrapped.notify_waiters(); loop { match signals.recv().await { Ok(DaemonSignal::Lost { reason }) => { @@ -8358,10 +8376,29 @@ impl FreshOpencodeState { // lock; the snapshot push goes ONLY to actually-restarted // bridges. `Ok(None)` (bridge alive against the CURRENT daemon / // unmaterialized) is the quiet no-op. - let restarted = { - let mut session = session_arc.lock().await; - self.restart_session_bridge_guarded(&mut session).await - }; + // Keep publication inside the same session/ownership window. Tuple + // fields drop in order, so ownership releases before the session + // mutex wakes a waiting kill, including when this future is cancelled. + let mut guards = (adopt_guard, session_arc.lock().await); + let restarted = self.restart_session_bridge_guarded(&mut guards.1).await; + #[cfg(test)] + { + let pause = { + let mut gate = self.revival_completion_pause.lock().unwrap(); + if gate + .as_ref() + .is_some_and(|pause| pause.session_id == durable) + { + gate.take() + } else { + None + } + }; + if let Some(pause) = pause { + let _ = pause.entered.send(()); + let _ = pause.release.await; + } + } match restarted { Ok(Some(real_id)) => { self.broadcast(&event_frame(&real_id, snapshot_event(&real_id, "idle"))); @@ -8376,8 +8413,7 @@ impl FreshOpencodeState { ); } } - // The guard covered the restart; release the window. - drop(adopt_guard); + drop(guards); } } @@ -9104,6 +9140,7 @@ mod tests { next_session: AtomicUsize, prompts: StdMutex>, aborts: StdMutex>, + prompt_dispatched: tokio::sync::Notify, } impl AbortRecordingHttp { fn session_of(url: &str, marker: &str) -> Option { @@ -9113,18 +9150,20 @@ mod tests { .next() .map(str::to_string) } - fn await_prompt(&self, budget: Duration) -> String { - let deadline = std::time::Instant::now() + budget; - loop { - if let Some(id) = self.prompts.lock().unwrap().first().cloned() { - return id; + async fn await_prompt(&self, budget: Duration) -> String { + tokio::time::timeout(budget, async { + loop { + let dispatched = self.prompt_dispatched.notified(); + tokio::pin!(dispatched); + dispatched.as_mut().enable(); + if let Some(id) = self.prompts.lock().unwrap().first().cloned() { + return id; + } + dispatched.await; } - assert!( - std::time::Instant::now() < deadline, - "the prompt POST was never dispatched" - ); - std::thread::sleep(Duration::from_millis(5)); - } + }) + .await + .expect("the prompt POST was never dispatched") } } impl ServeHttp for AbortRecordingHttp { @@ -9152,6 +9191,7 @@ mod tests { && matches!(req.method, freshell_opencode::serve::HttpMethod::Post) { self.prompts.lock().unwrap().push(id); + self.prompt_dispatched.notify_one(); } else if req.url.contains("/abort") && matches!(req.method, freshell_opencode::serve::HttpMethod::Post) { @@ -9163,6 +9203,46 @@ mod tests { } } + async fn abort_recording_state() -> ( + FreshOpencodeState, + Arc, + Arc, + tokio::sync::broadcast::Receiver, + ) { + let (tx, rx) = tokio::sync::broadcast::channel::(64); + let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + let http = Arc::new(AbortRecordingHttp::default()); + let deps = ServeDeps { + spawner: Arc::new(TrackedSpawner { + killed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }), + http: http.clone(), + ports: Arc::new(FakeAllocator), + events: Arc::new(NoopEventSource), + }; + let manager = OpencodeServeManager::new(deps, ServeConfig::default()); + manager + .ensure_started() + .await + .expect("healthy fake serve starts"); + fresh_agent.set_manager_for_test(manager).await; + let mut state = FreshOpencodeState::new(fresh_agent); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + state.set_ownership(registry.clone()); + // Let the real watcher finish its initial pass while the map is empty, + // before this fixture creates the accepted daemon-side turn under test. + { + let ready = state.daemon_loss_watcher_bootstrapped.notified(); + tokio::pin!(ready); + ready.as_mut().enable(); + state.ensure_daemon_loss_watcher().await; + tokio::time::timeout(Duration::from_secs(10), ready) + .await + .expect("the initial daemon recovery pass completed"); + } + (state, registry, http, rx) + } + /// A `ServeProcess` fake that records whether it was ever killed, so tests can /// assert the SHARED sidecar survives a per-session `freshAgent.kill`. struct TrackedProcess { @@ -11746,26 +11826,7 @@ mod tests { /// committed the stop with the daemon turn still running. #[tokio::test(flavor = "multi_thread")] async fn a_kill_during_an_accepted_daemon_turn_aborts_it_before_the_stop_commits() { - let (tx, mut rx) = tokio::sync::broadcast::channel::(64); - let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); - let http = Arc::new(AbortRecordingHttp::default()); - let deps = ServeDeps { - spawner: Arc::new(TrackedSpawner { - killed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }), - http: http.clone(), - ports: Arc::new(FakeAllocator), - events: Arc::new(NoopEventSource), - }; - let manager = OpencodeServeManager::new(deps, ServeConfig::default()); - manager - .ensure_started() - .await - .expect("healthy fake serve starts"); - fresh_agent.set_manager_for_test(manager).await; - let mut st = FreshOpencodeState::new(fresh_agent); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); + let (st, registry, http, mut rx) = abort_recording_state().await; st.handle_create(create_msg("req-kill-daemon-abort"), None) .await; @@ -11785,9 +11846,18 @@ mod tests { // witness armed at the dispatch boundary); then the LOCAL task is // settled — the IdleTimeout shape (a finished local task with the // daemon-side turn still running). - let real_id = http.await_prompt(Duration::from_secs(10)); + let real_id = http.await_prompt(Duration::from_secs(10)).await; assert_eq!(real_id, "ses_1"); st.settle_local_turn_task_for_test(placeholder).await; + assert!( + session_arc + .lock() + .await + .daemon_turn_accepted + .load(Ordering::SeqCst), + "fixture: the daemon-side turn is still accepted after local settlement" + ); + let owner_before_kill = registry.observe("opencode", "ses_1"); while rx.try_recv().is_ok() {} st.handle_kill(FreshAgentKill { @@ -11800,6 +11870,14 @@ mod tests { }) .await; + let mut killed_frame = None; + while let Ok(raw) = rx.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); + if frame["type"] == "freshAgent.killed" { + killed_frame = Some(frame); + } + } + let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); // THE R3-2 regression: the kill issued the per-session daemon-side // abort for the REAL session id before declaring the reap // confirmed. @@ -11807,7 +11885,7 @@ mod tests { assert_eq!( aborts, vec!["ses_1".to_string()], - "the kill must abort the accepted daemon-side turn (per-session /abort)" + "the kill must abort the accepted daemon-side turn (per-session /abort); owner={owner_before_kill:?}; answer={killed_frame}" ); // The stop committed AFTER the abort: Stopping → Vacant. assert_eq!( @@ -11816,20 +11894,100 @@ mod tests { "the kill's coordinator stop must commit (Stopping → Vacant)" ); // The answer reports success (the quiesced kill). - let mut killed_frame = None; - while let Ok(raw) = rx.try_recv() { - let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.killed" { - killed_frame = Some(frame); - } - } - let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); assert_eq!( killed_frame["success"], true, "the quiesced kill answers success: {killed_frame}" ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_kill_waits_for_daemon_revival_to_release_its_ownership_guard() { + for restart_bridge in [false, true] { + let (st, registry, http, mut rx) = abort_recording_state().await; + // Drive the real revival pass explicitly after materialization, + // controlling the same ordering a delayed background pass can reach. + st.handle_create(create_msg("req-kill-revival"), None).await; + let placeholder = "freshopencode-req-kill-revival"; + st.handle_send(send_msg(placeholder, "accepted in the shared daemon")) + .await; + assert_eq!(http.await_prompt(Duration::from_secs(10)).await, "ses_1"); + st.settle_local_turn_task_for_test(placeholder).await; + if restart_bridge { + let session = st.sessions.lock().await[placeholder].clone(); + session.lock().await.serve_bridge.take().unwrap().abort(); + } + let _ = drain_frames(&mut rx); + + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *st.revival_completion_pause.lock().unwrap() = Some(RevivalCompletionPause { + session_id: "ses_1".into(), + entered: entered_tx, + release: release_rx, + }); + let revival = { + let state = st.clone(); + tokio::spawn(async move { state.revive_dead_bridges_if_daemon_running().await }) + }; + tokio::time::timeout(Duration::from_secs(10), entered_rx) + .await + .unwrap() + .unwrap(); + let kill = tokio::task::unconstrained(st.handle_kill(FreshAgentKill { + provider: freshell_protocol::AgentProvider::Opencode, + session_id: placeholder.into(), + session_type: SessionType::Freshopencode, + observed_epoch: None, + observed_generation: None, + cwd: None, + })); + tokio::pin!(kill); + let waiting = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::future::Future::poll(kill.as_mut(), cx).is_pending()) + }) + .await; + assert!( + waiting, + "kill escaped the guarded session before revival released ownership: {:?}", + drain_frames(&mut rx) + ); + assert!(http.aborts.lock().unwrap().is_empty()); + + release_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), revival) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), kill) + .await + .unwrap(); + assert_eq!(*http.aborts.lock().unwrap(), vec!["ses_1".to_string()]); + assert_eq!( + registry.observe("opencode", "ses_1").state, + freshell_ownership::OwnershipState::Vacant + ); + let frames = drain_frames(&mut rx); + let killed = frames + .iter() + .position(|frame| frame["type"] == "freshAgent.killed" && frame["success"] == true) + .expect("the quiesced kill succeeds"); + let snapshot = frames + .iter() + .position(|frame| frame["event"]["type"] == "freshAgent.session.snapshot"); + if restart_bridge { + assert!( + snapshot.expect("the restarted bridge publishes its snapshot") < killed, + "publication precedes the completed kill: {frames:?}" + ); + } else { + assert!( + snapshot.is_none(), + "a healthy bridge needs no restart publication" + ); + } + } + } + /// Delta-r6-r4 (focused-episode-6 round 3, Finding 3), the opencode /// lane's PERSISTED class on the pre-lock envelope: the whole identity /// set's journal record IS durable although its write reports failure. diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index b41e8c8f9..6a3b324a1 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -39,14 +39,14 @@ use axum::response::Response; use axum::{Json, Router}; use serde_json::{json, Value}; -use freshell_protocol::{ServerMessage, SessionLocator, UiCommand, LEGACY_RESUME_IDENTITY_REFUSAL}; +use freshell_protocol::{ServerMessage, UiCommand, LEGACY_RESUME_IDENTITY_REFUSAL}; use crate::layout_store::RenameOutcome; use crate::target_resolver::{resolve_target, ResolvedTarget}; use crate::terminal_tabs::{spawn_terminal_pane, TerminalSpawnResult}; use crate::{ - approx_json, authorized, fail_json, hosted_rest, ok_json, parse_required_name, rest_agent_mode, - FreshAgentState, PaneEntry, PaneNameResolution, + approx_json, authorized, fail_json, ok_json, parse_required_name, rest_agent_mode, + rest_lane_failure, FreshAgentState, PaneEntry, PaneNameResolution, }; /// Mount the pane + tab lifecycle routes onto an existing router. Split out of @@ -140,7 +140,7 @@ pub(crate) fn resolve_pane_target(state: &FreshAgentState, raw: &str) -> PaneTar /// via `attachPaneContent` (`router.ts:1374`). A store miss (unknown pane) /// responds Node's `approx('pane split requested; not applied')` /// (`router.ts:1312-1314`). `agent`-based fresh-agent splits route through the -/// same typed hosted gateway used by tab creation, roll the tentative layout +/// same typed runtime lane used by tab creation, roll the tentative layout /// mutation back on launch failure, and attach the returned provider-native /// session to the store-minted pane. Response + broadcast shapes remain /// compatible with Slice 3b-1. @@ -175,7 +175,12 @@ pub(crate) async fn split_pane( .unwrap_or("horizontal") .to_string(); - let (tab_id, new_pane_id) = match state.layout.split_pane(&pane_id, &direction) { + let split = if body.get("agent").is_some() { + state.layout.split_pending_pane(&pane_id, &direction) + } else { + state.layout.split_pane(&pane_id, &direction) + }; + let (tab_id, new_pane_id) = match split { Ok(split) => split, // Node: `if (!result?.tabId || !result?.newPaneId) res.json(approx( // result, 'pane split requested; not applied'))` (router.ts:1312-1314). @@ -184,106 +189,91 @@ pub(crate) async fn split_pane( let new_content = if let Some(agent) = body.get("agent").and_then(Value::as_str) { let Some((provider, session_type)) = rest_agent_mode(agent) else { - let _ = state.layout.close_pane(&new_pane_id); + state.layout.rollback_split(&new_pane_id); return fail_json( StatusCode::BAD_REQUEST, format!("unknown agent \"{agent}\""), ); }; - let Some(gateway) = state.hosted_rest_gateway() else { - let _ = state.layout.close_pane(&new_pane_id); + let Some(gateway) = state.rest_lane_for(provider) else { + state.layout.rollback_split(&new_pane_id); return fail_json( StatusCode::SERVICE_UNAVAILABLE, "durable fresh-agent gateway is not installed".to_string(), ); }; - let native_session_id = match body.get("sessionRef") { - None => None, - Some(value) => match serde_json::from_value::(value.clone()) { - Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { - Some(locator.session_id) - } - _ => { - let _ = state.layout.close_pane(&new_pane_id); - return fail_json( - StatusCode::BAD_REQUEST, - format!("sessionRef must identify a {provider} session"), - ); - } - }, - }; - let request_id = uuid::Uuid::new_v4().simple().to_string(); - let provider_inputs = match hosted_rest::provider_inputs(&body) { - Ok(inputs) => inputs, + let mut request = match state + .build_rest_lane_create(provider, session_type, &body) + .await + { + Ok(request) => request, Err(error) => { - let _ = state.layout.close_pane(&new_pane_id); - return fail_json(StatusCode::BAD_REQUEST, error); + state.layout.rollback_split(&new_pane_id); + return rest_lane_failure(error); } }; - let cwd = body.get("cwd").and_then(Value::as_str).map(str::to_string); - let model = body - .get("model") - .and_then(Value::as_str) - .map(str::to_string); - let effort = body - .get("effort") - .and_then(Value::as_str) - .map(str::to_string); - let created = match gateway - .create_agent(hosted_rest::HostedRestCreate { - request_id: request_id.clone(), - provider: provider.into(), - session_type: session_type.into(), - cwd: cwd.clone(), - model: model.clone(), - effort: effort.clone(), - native_session_id, - plugins: provider_inputs.plugins, - model_selection: provider_inputs.model_selection, - permission_mode: provider_inputs.permission_mode, - sandbox: provider_inputs.sandbox, - }) - .await + request.tab_id = Some(tab_id.clone()); + request.pane_id = Some(new_pane_id.clone()); + if !state + .layout + .set_pending_create_request(&new_pane_id, &request.request_id) { + state.layout.rollback_split(&new_pane_id); + return crate::rest_lane_create::cancelled_create_response(); + } + let created = match gateway.clone().create_agent(request.clone()).await { Ok(created) => created, - Err(()) => { - let _ = state.layout.close_pane(&new_pane_id); - return fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent host could not be created".to_string(), - ); + Err(error) => { + state.layout.rollback_split(&new_pane_id); + return rest_lane_failure(error); } }; - let mut content = json!({ - "kind":"fresh-agent", - "sessionType":session_type, - "provider":provider, - "sessionId":created.session_id, - "createRequestId":request_id, - "status":"connected", - }); - if let Some(value) = &cwd { - content["initialCwd"] = json!(value); - } - if let Some(value) = &model { - content["model"] = json!(value); - } - if let Some(value) = &effort { - content["effort"] = json!(value); + let content = crate::rest_lane_create::pane_content(&request, &created); + let published = + state + .layout + .finish_pending_pane(&tab_id, &new_pane_id, content.clone(), || { + state.panes.lock().expect("panes mutex").insert( + new_pane_id.clone(), + PaneEntry { + placeholder_id: created.session_id.clone(), + provider: provider.into(), + session_type: session_type.into(), + cwd: request.cwd.clone(), + model: request.model.clone(), + effort: request.effort.clone(), + durable_id: Some( + created + .session_ref + .as_ref() + .map(|reference| reference.session_id.clone()) + .unwrap_or_else(|| created.session_id.clone()), + ), + }, + ); + state + .pane_tabs + .lock() + .expect("pane_tabs mutex") + .insert(new_pane_id.clone(), tab_id.clone()); + broadcast_pane_split( + &state, + &tab_id, + &pane_id, + &direction, + &new_pane_id, + &content, + ); + }); + if !published { + return state + .abandon_rest_create(&gateway, &request, &created) + .await; } - state.panes.lock().expect("panes mutex").insert( - new_pane_id.clone(), - PaneEntry { - placeholder_id: created.session_id.clone(), - provider: provider.into(), - session_type: session_type.into(), - cwd, - model, - effort, - durable_id: Some(created.session_id), - }, + return ok_json( + json!({ "paneId": new_pane_id, "terminalId": Value::Null }), + "pane split (non-terminal)", ); - content } else if body .get("hostStats") .and_then(Value::as_bool) @@ -350,8 +340,35 @@ pub(crate) async fn split_pane( let terminal_id = new_content.get("terminalId").cloned(); - // `ui.command{pane.split}` payload (`router.ts:1373-1382`): tabId, paneId - // (the SOURCE pane), direction, newPaneId, newContent. + broadcast_pane_split( + &state, + &tab_id, + &pane_id, + &direction, + &new_pane_id, + &new_content, + ); + + let message = if terminal_id.is_some() { + "pane split" + } else { + "pane split (non-terminal)" + }; + ok_json( + json!({ "paneId": new_pane_id, "terminalId": terminal_id }), + message, + ) +} + +fn broadcast_pane_split( + state: &FreshAgentState, + tab_id: &str, + pane_id: &str, + direction: &str, + new_pane_id: &str, + new_content: &Value, +) { + // The source pane and newly allocated pane remain distinct on the wire. state.broadcast(&ServerMessage::UiCommand(UiCommand { command: "pane.split".to_string(), payload: Some(json!({ @@ -362,16 +379,18 @@ pub(crate) async fn split_pane( "newContent": new_content, })), })); +} - let message = if terminal_id.is_some() { - "pane split" - } else { - "pane split (non-terminal)" - }; - ok_json( - json!({ "paneId": new_pane_id, "terminalId": terminal_id }), - message, - ) +fn pane_tab_from_cache_or_layout(state: &FreshAgentState, pane_id: &str) -> Option { + // Publication takes layout before these caches, so release this guard + // before the fallback acquires layout. + let cached = state + .pane_tabs + .lock() + .expect("pane_tabs mutex") + .get(pane_id) + .cloned(); + cached.or_else(|| state.layout.find_pane_tab(pane_id)) } // ── POST /api/panes/:id/close ────────────────────────────────────────────── @@ -931,13 +950,7 @@ pub(crate) async fn respawn_pane( ); } - let tab_id = state - .pane_tabs - .lock() - .expect("pane_tabs mutex") - .get(&pane_id) - .cloned() - .or_else(|| state.layout.find_pane_tab(&pane_id)); + let tab_id = pane_tab_from_cache_or_layout(&state, &pane_id); // Round-1 review: an authoritative registry misses only when no connected // browser has synced the pane -- ask the owner client to re-sync before // giving up (bounded window, above the mirror's debounce). @@ -1075,14 +1088,7 @@ pub(crate) async fn attach_pane( }; // The pane resolves through the same authoritative registry // respawn uses (`pane_tabs` first, then the durable LayoutStore). - let Some(tab_id) = state - .pane_tabs - .lock() - .expect("pane_tabs mutex") - .get(&pane_id) - .cloned() - .or_else(|| state.layout.find_pane_tab(&pane_id)) - else { + let Some(tab_id) = pane_tab_from_cache_or_layout(&state, &pane_id) else { return crate::fail_json_code( StatusCode::NOT_FOUND, "PANE_NOT_FOUND", @@ -1315,3 +1321,11 @@ mod tab_tests; #[cfg(test)] #[path = "pane_ops_store_tests.rs"] mod store_tests; + +#[cfg(test)] +#[path = "rest_lane_http_tests.rs"] +mod rest_lane_http_tests; + +#[cfg(test)] +#[path = "rest_lane_publication_tests.rs"] +mod rest_lane_publication_tests; diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index b019bc949..96d930547 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -5,10 +5,10 @@ use super::tests::{app, create_shell_tab, delete, get, patch, post, state_with_registry}; use super::*; -use crate::hosted_rest; +use crate::rest_lane; use tower::util::ServiceExt; -struct HostedRestFake { +struct RestLaneFake { creates: std::sync::atomic::AtomicUsize, sends: std::sync::atomic::AtomicUsize, captures: std::sync::atomic::AtomicUsize, @@ -16,11 +16,11 @@ struct HostedRestFake { } #[async_trait::async_trait] -impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { +impl rest_lane::FreshAgentRestLane for RestLaneFake { async fn create_agent( self: Arc, - request: hosted_rest::HostedRestCreate, - ) -> Result { + request: rest_lane::RestLaneCreate, + ) -> Result { assert_eq!(request.cwd.as_deref(), Some("/workspace")); self.identities .lock() @@ -28,15 +28,16 @@ impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { .push((request.provider, request.session_type)); self.creates .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestCreated { + Ok(rest_lane::RestLaneCreated { session_id: "managed-rest-session".into(), + ..Default::default() }) } async fn send_agent( &self, - request: hosted_rest::HostedRestSend, - ) -> Result { + request: rest_lane::RestLaneSend, + ) -> Result { assert_eq!(request.session_id, "managed-rest-session"); assert_eq!( (request.provider.as_str(), request.session_type.as_str()), @@ -44,23 +45,24 @@ impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { ); assert_eq!(request.text, "continue"); self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestSendResult { + Ok(rest_lane::RestLaneSendResult { session_id: request.session_id, completed: true, + ..Default::default() }) } async fn capture( &self, - request: hosted_rest::HostedRestCapture, - ) -> Result { + request: rest_lane::RestLaneCapture, + ) -> Result { assert_eq!( (request.provider.as_str(), request.session_type.as_str()), ("opencode", "freshopencode") ); self.captures .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestCaptureResult { + Ok(rest_lane::RestLaneCaptureResult { session_id: request.session_id, native_session_id: "native-rest-session".into(), text: "user: continue\nassistant: done\n".into(), @@ -729,7 +731,7 @@ async fn fresh_agent_rest_create_registers_tab_and_pane_in_the_layout_store() { #[tokio::test] async fn hosted_rest_create_and_send_never_start_the_web_owned_opencode_transport() { let state = state_with_registry(); - let gateway = Arc::new(HostedRestFake { + let gateway = Arc::new(RestLaneFake { creates: std::sync::atomic::AtomicUsize::new(0), sends: std::sync::atomic::AtomicUsize::new(0), captures: std::sync::atomic::AtomicUsize::new(0), @@ -790,7 +792,7 @@ async fn hosted_rest_create_and_send_never_start_the_web_owned_opencode_transpor #[tokio::test] async fn hosted_rest_tab_routes_every_fresh_agent_mode_to_the_typed_gateway() { let state = state_with_registry(); - let gateway = Arc::new(HostedRestFake { + let gateway = Arc::new(RestLaneFake { creates: std::sync::atomic::AtomicUsize::new(0), sends: std::sync::atomic::AtomicUsize::new(0), captures: std::sync::atomic::AtomicUsize::new(0), diff --git a/crates/freshell-freshagent/src/pane_ops_tests.rs b/crates/freshell-freshagent/src/pane_ops_tests.rs index d5dc982fd..4cf5adeb0 100644 --- a/crates/freshell-freshagent/src/pane_ops_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tests.rs @@ -5,7 +5,7 @@ //! module's `pub(super)` helpers). use super::*; -use crate::hosted_rest; +use crate::rest_lane; use axum::body::Body; use axum::http::Request; use std::sync::Arc; @@ -14,24 +14,25 @@ use tower::util::ServiceExt; struct SplitHostedGateway; #[async_trait::async_trait] -impl hosted_rest::HostedFreshAgentRestGateway for SplitHostedGateway { +impl rest_lane::FreshAgentRestLane for SplitHostedGateway { async fn create_agent( self: Arc, - request: hosted_rest::HostedRestCreate, - ) -> Result { + request: rest_lane::RestLaneCreate, + ) -> Result { assert_eq!( (request.provider.as_str(), request.session_type.as_str()), ("claude", "freshclaude") ); - Ok(hosted_rest::HostedRestCreated { + Ok(rest_lane::RestLaneCreated { session_id: "managed-split-claude".into(), + ..Default::default() }) } async fn send_agent( &self, - _request: hosted_rest::HostedRestSend, - ) -> Result { + _request: rest_lane::RestLaneSend, + ) -> Result { unreachable!("split creation does not send a prompt") } } diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs new file mode 100644 index 000000000..73df0e26f --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -0,0 +1,273 @@ +//! Typed REST/MCP ingress shared by local and hosted fresh-agent runtimes. +//! Hosted implementations live in `freshell-server`; this crate deliberately +//! knows nothing about supervisor IPC. + +use async_trait::async_trait; +use serde_json::Value; + +pub type RestCreateSettingsReader = std::sync::Arc< + dyn Fn() -> futures_util::future::BoxFuture<'static, freshell_protocol::SettingsFreshAgent> + + Send + + Sync, +>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestLaneErrorKind { + BadRequest, + Forbidden, + NotFound, + Conflict, + Unavailable, + Internal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneError { + pub kind: RestLaneErrorKind, + pub code: String, + pub message: String, + pub retryable: Option, + pub retry_after_ms: Option, + pub owner: Option, +} + +impl RestLaneError { + pub fn new( + kind: RestLaneErrorKind, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind, + code: code.into(), + message: message.into(), + retryable: None, + retry_after_ms: None, + owner: None, + } + } +} + +impl From<()> for RestLaneError { + fn from(_: ()) -> Self { + Self::new( + RestLaneErrorKind::Unavailable, + "FRESH_AGENT_UNAVAILABLE", + "durable fresh-agent operation is temporarily unavailable", + ) + } +} + +#[derive(Default)] +pub struct RestLaneProviderInputs { + pub plugins: Option>, + pub model_selection: Option>, + pub permission_mode: Option, + pub sandbox: Option, +} + +pub fn provider_inputs(body: &Value) -> Result { + let plugins = body + .get("plugins") + .map(|value| { + serde_json::from_value(value.clone()).map_err(|_| "invalid plugins".to_string()) + }) + .transpose()?; + let model_selection = body + .get("modelSelection") + .map(|value| { + serde_json::from_value(value.clone()).map_err(|_| "invalid modelSelection".to_string()) + }) + .transpose()?; + let permission_mode = body + .get("permissionMode") + .map(|value| { + serde_json::from_value(value.clone()).map_err(|_| "invalid permissionMode".to_string()) + }) + .transpose()?; + let sandbox = body + .get("sandbox") + .map(|value| { + serde_json::from_value(value.clone()).map_err(|_| "invalid sandbox".to_string()) + }) + .transpose()?; + Ok(RestLaneProviderInputs { + plugins, + model_selection, + permission_mode, + sandbox, + }) +} + +pub(crate) fn observed_fence(body: &Value) -> Result<(Option, Option), RestLaneError> { + let read = |field: &str| match body.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_u64().map(Some).ok_or_else(|| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_FENCE", + format!("{field} must be an unsigned integer"), + ) + }), + }; + let epoch = read("observedEpoch")?; + let generation = read("observedGeneration")?; + crate::ownership_lane::wire_fence(epoch, generation).map_err(|error| { + RestLaneError::new(RestLaneErrorKind::BadRequest, error.code(), error.message()) + })?; + Ok((epoch, generation)) +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RestLaneCreate { + pub request_id: String, + pub provider: String, + pub session_type: String, + pub cwd: Option, + pub model: Option, + pub effort: Option, + pub native_session_id: Option, + pub plugins: Option>, + pub model_selection: Option>, + pub permission_mode: Option, + pub sandbox: Option, + pub tab_id: Option, + pub pane_id: Option, + pub naming_handle: Option, + pub model_effort_levels: Option>, + pub observed_epoch: Option, + pub observed_generation: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RestLaneCreated { + /// Stable presentation identity used by the web layout and gateway alias + /// table. It is not substituted for the provider's native identity. + pub session_id: String, + pub session_ref: Option, + /// Provider-owned identity of a runtime newly started by this create. + /// An adopted existing writer has no cleanup token. + pub cleanup_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneSend { + pub request_id: String, + pub session_id: String, + pub provider: String, + pub session_type: String, + pub text: String, + pub timeout_ms: u64, + pub observed_epoch: Option, + pub observed_generation: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RestLaneSendResult { + pub session_id: String, + pub completed: bool, + pub submitted_turn_id: Option, + pub noop: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneCapture { + pub session_id: String, + pub provider: String, + pub session_type: String, + pub max_bytes: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneCaptureResult { + pub session_id: String, + pub native_session_id: String, + pub text: String, + pub truncated: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestLaneCaptureError { + Unsupported, + Unavailable, + NotFound, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneSnapshot { + pub session_id: String, + pub provider: String, + pub session_type: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestLaneSnapshotError { + OwnershipUnavailable, + ManagedUnavailable, +} + +#[async_trait] +pub trait FreshAgentRestLane: Send + Sync { + /// None leaves genuinely unmanaged threads on their existing read path. + /// A hosted read failure must never fall back to saved history as live truth. + async fn snapshot( + &self, + _request: RestLaneSnapshot, + ) -> Result, RestLaneSnapshotError> { + Err(RestLaneSnapshotError::OwnershipUnavailable) + } + async fn create_agent( + self: std::sync::Arc, + request: RestLaneCreate, + ) -> Result; + + async fn abandon_create( + &self, + _request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + if created.cleanup_token.is_none() { + return Ok(()); + } + Err(RestLaneError::new( + RestLaneErrorKind::Unavailable, + "CREATE_CLEANUP_UNAVAILABLE", + "the provider could not clean up the abandoned create", + )) + } + + async fn send_agent(&self, request: RestLaneSend) -> Result; + + async fn capture( + &self, + _request: RestLaneCapture, + ) -> Result { + Err(RestLaneCaptureError::Unsupported) + } +} + +pub type SharedFreshAgentRestLane = std::sync::Arc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosted_rest_provider_inputs_preserve_explicit_model_clear_and_plugins() { + let body = serde_json::json!({ + "plugins": ["/workspace/plugin"], + "modelSelection": null, + "permissionMode": "default", + "sandbox": "workspace-write" + }); + let inputs = provider_inputs(&body).unwrap(); + assert_eq!(inputs.plugins, Some(vec!["/workspace/plugin".into()])); + assert_eq!(inputs.model_selection, Some(None)); + assert_eq!(inputs.permission_mode.as_deref(), Some("default")); + assert_eq!( + inputs.sandbox, + Some(freshell_protocol::Sandbox::WorkspaceWrite) + ); + assert!(provider_inputs(&serde_json::json!({"plugins": "wrong"})).is_err()); + } +} diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs new file mode 100644 index 000000000..3b15548e0 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -0,0 +1,220 @@ +//! One create recipe for the REST tab and split paths. Defaults are read once +//! and the same resolved values launch the runtime and populate the web pane. + +use crate::rest_lane::{self, RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneErrorKind}; +use crate::{naming, FreshAgentState}; +use freshell_protocol::{SessionLocator, SettingsFreshAgent}; +use serde_json::{json, Value}; + +impl FreshAgentState { + pub(crate) async fn abandon_rest_create( + &self, + lane: &rest_lane::SharedFreshAgentRestLane, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> axum::response::Response { + if let Some(pane_id) = request.pane_id.as_deref() { + self.layout.rollback_split(pane_id); + } + if let Err(error) = lane.abandon_create(request, created).await { + tracing::error!(event = "agent_api.abandoned_create_cleanup_failed", request_id = %request.request_id, session_id = %created.session_id, code = %error.code, message = %error.message); + return crate::rest_lane_failure(error); + } + cancelled_create_response() + } + + pub fn set_rest_create_settings_reader( + &self, + reader: rest_lane::RestCreateSettingsReader, + ) -> Result<(), &'static str> { + self.rest_create_settings + .set(reader) + .map_err(|_| "REST create settings reader is already installed") + } + + pub(crate) async fn build_rest_lane_create( + &self, + provider: &str, + session_type: &str, + body: &Value, + ) -> Result { + let invalid = + |message| RestLaneError::new(RestLaneErrorKind::BadRequest, "INVALID_REQUEST", message); + let inputs = rest_lane::provider_inputs(body).map_err(invalid)?; + let (observed_epoch, observed_generation) = rest_lane::observed_fence(body)?; + let native_session_id = match body.get("sessionRef") { + None => None, + Some(value) => match serde_json::from_value::(value.clone()) { + Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { + Some(locator.session_id) + } + _ => { + return Err(invalid(format!( + "sessionRef must identify a {provider} session" + ))) + } + }, + }; + let mut request = RestLaneCreate { + request_id: uuid::Uuid::new_v4().simple().to_string(), + provider: provider.into(), + session_type: session_type.into(), + cwd: body.get("cwd").and_then(Value::as_str).map(str::to_string), + model: body + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + effort: body + .get("effort") + .and_then(Value::as_str) + .map(str::to_string), + native_session_id, + plugins: inputs.plugins, + model_selection: inputs.model_selection, + permission_mode: inputs.permission_mode, + sandbox: inputs.sandbox, + observed_epoch, + observed_generation, + ..Default::default() + }; + if provider == "claude" { + let settings = if let Some(reader) = self.rest_create_settings.get() { + reader().await + } else { + SettingsFreshAgent { + default_plugins: vec![], + enabled: false, + providers: json!({}), + initial_setup_done: None, + } + }; + let resolved = crate::claude_create_defaults::resolve_claude_rest_create( + session_type, + body, + &settings, + ) + .map_err(invalid)?; + request.model = Some(resolved.model); + request.model_selection = Some(resolved.model_selection); + request.model_effort_levels = resolved.model_effort_levels; + request.effort = resolved.effort; + request.permission_mode = Some(resolved.permission_mode); + request.plugins = Some(resolved.plugins); + } + if naming::is_unified_agent_mode(None, Some(session_type)) { + request.naming_handle = Some( + body.get("namingHandle") + .and_then(Value::as_str) + .filter(|handle| !handle.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("nh-{}", uuid::Uuid::new_v4().simple())), + ); + } + self.admit_rest_create_name(&request, body).await?; + Ok(request) + } + + async fn admit_rest_create_name( + &self, + request: &RestLaneCreate, + body: &Value, + ) -> Result<(), RestLaneError> { + use freshell_protocol::session_names::{NameIntent, NamedProvider, SessionNameRef}; + let intent = body + .get("nameIntent") + .map(|value| serde_json::from_value::(value.clone())) + .transpose() + .map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "nameIntent must be user or automatic", + ) + })? + .unwrap_or(NameIntent::Automatic); + let (Some(handle), Some(sink)) = (request.naming_handle.as_ref(), self.naming()) else { + return Ok(()); + }; + let provider = match request.provider.as_str() { + "claude" => NamedProvider::Claude, + "codex" => NamedProvider::Codex, + "opencode" => NamedProvider::Opencode, + _ => return Ok(()), + }; + let pending = SessionNameRef::Pending { id: handle.clone() }; + match sink + .ensure_pending(naming::PendingNameInput { + handle: handle.clone(), + provider, + cwd: request.cwd.clone(), + }) + .await + { + Ok(_) => { + if let Some(name) = body + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + { + if let Err(error) = sink + .rename(naming::RenameNameInput { + target: pending.clone(), + name: name.to_string(), + intent, + if_revision: None, + }) + .await + { + naming::log_name_error("rename", &pending, &error); + } + } + } + Err(error) => naming::log_name_error("ensure_pending", &pending, &error), + } + Ok(()) + } +} + +pub(crate) fn cancelled_create_response() -> axum::response::Response { + let mut error = RestLaneError::new( + RestLaneErrorKind::Conflict, + "PANE_CREATE_CANCELLED", + "the pane was closed while its runtime was starting", + ); + error.retryable = Some(false); + crate::rest_lane_failure(error) +} + +pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) -> Value { + let mut content = json!({ + "kind": "fresh-agent", "sessionType": request.session_type, "provider": request.provider, + "sessionId": created.session_id, "createRequestId": request.request_id, "status": "connected", + }); + for (key, value) in [ + ("initialCwd", request.cwd.as_ref()), + ("model", request.model.as_ref()), + ("effort", request.effort.as_ref()), + ("permissionMode", request.permission_mode.as_ref()), + ("namingHandle", request.naming_handle.as_ref()), + ] { + if let Some(value) = value { + content[key] = json!(value); + } + } + if let Some(value) = &request.plugins { + content["plugins"] = json!(value); + } + if let Some(value) = &request.model_selection { + content["modelSelection"] = json!(value); + } + if let Some(value) = &request.sandbox { + content["sandbox"] = json!(value); + } + if let Some(value) = &request.model_effort_levels { + content["modelEffortLevels"] = json!({ "modelId": request.model, "levels": value }); + } + if let Some(value) = &created.session_ref { + content["sessionRef"] = json!(value); + } + content +} diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs new file mode 100644 index 000000000..63095c71d --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -0,0 +1,841 @@ +use super::tests::{app, get, post, state_with_registry}; +use crate::ownership_lane::TerminalOwnerFields; +use crate::rest_lane::*; +use axum::http::StatusCode; +use serde_json::{json, Value}; +use std::sync::Arc; + +struct RefusingLane(RestLaneErrorKind); + +#[async_trait::async_trait] +impl FreshAgentRestLane for RefusingLane { + async fn create_agent( + self: Arc, + _: RestLaneCreate, + ) -> Result { + Err(RestLaneError { + kind: self.0, + code: "SESSION_RESERVED".into(), + message: "the conversation already has an owner".into(), + retryable: Some(true), + retry_after_ms: Some(250), + owner: Some(TerminalOwnerFields { + owner_kind: "terminal", + owner_generation: 7, + owner_epoch: 3, + }), + }) + } + + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("creation is refused") + } +} + +#[tokio::test] +async fn typed_create_failures_preserve_status_and_owner_without_leaving_a_tab() { + for (kind, wanted_status) in [ + (RestLaneErrorKind::BadRequest, StatusCode::BAD_REQUEST), + (RestLaneErrorKind::Forbidden, StatusCode::FORBIDDEN), + (RestLaneErrorKind::Conflict, StatusCode::CONFLICT), + ( + RestLaneErrorKind::Unavailable, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + RestLaneErrorKind::Internal, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let state = state_with_registry(); + state + .set_hosted_rest_gateway(Arc::new(RefusingLane(kind))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({ "agent": "claude" }), + true, + ) + .await; + assert_eq!(status, wanted_status, "{body}"); + assert_eq!(body["code"], "SESSION_RESERVED"); + assert_eq!(body["retryable"], true); + assert_eq!(body["retryAfterMs"], 250); + assert_eq!(body["ownerKind"], "terminal"); + assert_eq!(body["ownerGeneration"], 7); + assert_eq!(body["ownerEpoch"], 3); + let (status, tabs) = get(router, "/api/tabs", true).await; + assert_eq!(status, StatusCode::OK); + assert!( + tabs["data"]["tabs"].as_array().unwrap().is_empty(), + "{tabs}" + ); + } +} + +struct ContextLane { + layout: crate::layout_store::LayoutStore, + creates: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ContextLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let pane_id = request + .pane_id + .as_deref() + .expect("create has its final pane context"); + let tab_id = request + .tab_id + .as_deref() + .expect("create has its final tab context"); + let pane = self + .layout + .get_pane_snapshot(pane_id) + .expect("layout exists before runtime create"); + assert_eq!(pane.tab_id, tab_id); + let created = RestLaneCreated { + session_id: format!("session-{}", request.request_id), + ..Default::default() + }; + self.creates.lock().unwrap().push(request); + Ok(created) + } + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("context test only creates") + } +} + +#[tokio::test] +async fn tab_and_split_create_use_final_layout_context_and_one_create_request_id() { + let state = state_with_registry(); + let (original_tab, original_pane) = state.layout.create_tab(Some("Original")); + state + .layout + .attach_pane_content(&original_tab, &original_pane, json!({ "kind": "terminal" })); + state.layout.select_tab(&original_tab); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let router = app(state.clone()); + for (uri, agent, expected_tab) in [ + ("/api/tabs".to_string(), "claude", None), + ( + format!("/api/panes/{original_pane}/split"), + "kilroy", + Some(original_tab.as_str()), + ), + ] { + let (status, body) = post(router.clone(), &uri, json!({ "agent": agent }), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + let pane_id = request.pane_id.as_ref().unwrap(); + let pane = state.layout.get_pane_snapshot(pane_id).unwrap(); + assert_eq!( + pane.pane_content.as_ref().unwrap()["createRequestId"], + request.request_id + ); + assert_eq!( + pane.pane_content.as_ref().unwrap()["sessionId"], + format!("session-{}", request.request_id) + ); + if let Some(tab_id) = expected_tab { + assert_eq!(pane.tab_id, tab_id); + } + let (_, tabs) = get(router.clone(), "/api/tabs", true).await; + assert_eq!( + tabs["data"]["activeTabId"], original_tab, + "automation stays focus neutral" + ); + } + assert_eq!(lane.creates.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let reads_for_reader = reads.clone(); + state.set_rest_create_settings_reader(Arc::new(move || { + reads_for_reader.fetch_add(1, Ordering::SeqCst); + Box::pin(async { freshell_protocol::SettingsFreshAgent { + default_plugins: vec!["/saved-plugin".into()], enabled: true, initial_setup_done: None, + providers: json!({ + "freshclaude": { "modelSelection": { "kind": "exact", "modelId": "catalog-a" }, "modelEffortLevels": { "modelId": "catalog-a", "levels": [] }, "effort": "high", "defaultPermissionMode": "plan" }, + "kilroy": { "modelSelection": { "kind": "exact", "modelId": "catalog-b" }, "effort": "low", "defaultPermissionMode": "bypassPermissions" }, + }), + } }) + })).unwrap(); + let router = app(state.clone()); + for (agent, model, mode, effort) in [ + ("claude", "catalog-a", "plan", None), + ("kilroy", "catalog-b", "bypassPermissions", Some("low")), + ] { + let (status, body) = + post(router.clone(), "/api/tabs", json!({ "agent": agent }), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + let pane = state + .layout + .get_pane_snapshot(request.pane_id.as_ref().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(request.model.as_deref(), Some(model)); + assert_eq!(request.permission_mode.as_deref(), Some(mode)); + assert_eq!(request.effort.as_deref(), effort); + assert_eq!(request.plugins, Some(vec!["/saved-plugin".into()])); + assert_eq!(pane["model"], model); + assert_eq!(pane["permissionMode"], mode); + assert_eq!(pane["effort"].as_str(), effort); + assert_eq!(pane["plugins"], json!(["/saved-plugin"])); + assert_eq!(pane["modelSelection"]["modelId"], model); + assert_eq!(request.naming_handle.is_some(), agent == "claude"); + if agent == "claude" { + assert_eq!( + pane["modelEffortLevels"], + json!({ "modelId": model, "levels": [] }) + ); + } + } + assert_eq!( + reads.load(Ordering::SeqCst), + 2, + "one settings read per create" + ); +} + +#[tokio::test] +async fn an_explicit_model_override_clears_a_different_saved_exact_selection() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + state + .set_rest_create_settings_reader(Arc::new(|| { + Box::pin(async { + freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + initial_setup_done: None, + providers: json!({ "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "saved-model" }, + "modelEffortLevels": { "modelId": "saved-model", "levels": [] }, + } }), + } + }) + })) + .unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ "agent": "claude", "model": "explicit-model" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!(request.model.as_deref(), Some("explicit-model")); + assert_eq!( + request.model_selection, + Some(None), + "provider create must not prefer an unrelated saved selection" + ); + let content = state + .layout + .get_pane_snapshot(request.pane_id.as_ref().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["model"], "explicit-model"); + assert_eq!(content["modelSelection"], Value::Null); + assert!(content.get("modelEffortLevels").is_none()); +} + +#[tokio::test] +async fn local_claude_lane_creates_tabs_and_splits_without_enabling_codex() { + let state = state_with_registry(); + let (_, original_pane) = state.layout.create_tab(Some("Original")); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + for (uri, agent) in [ + ("/api/tabs".to_string(), "claude"), + (format!("/api/panes/{original_pane}/split"), "kilroy"), + ] { + let (status, body) = post(router.clone(), &uri, json!({"agent":agent}), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!(request.provider, "claude"); + assert_eq!( + request.session_type, + if agent == "claude" { + "freshclaude" + } else { + "kilroy" + } + ); + } + assert_eq!(lane.creates.lock().unwrap().len(), 2); + let (status, _) = post(router, "/api/tabs", json!({"agent":"codex"}), true).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(lane.creates.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn hosted_gateway_takes_precedence_over_the_local_claude_lane() { + let state = state_with_registry(); + let local = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + let hosted = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(local.clone()).unwrap(); + state.set_hosted_rest_gateway(hosted.clone()).unwrap(); + let (status, body) = post(app(state), "/api/tabs", json!({"agent":"claude"}), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(hosted.creates.lock().unwrap().len(), 1); + assert!(local.creates.lock().unwrap().is_empty()); +} + +#[derive(Default)] +struct ControlLane { + sends: std::sync::Mutex>, + captures: std::sync::Mutex>, + materialized_session_id: Option, + concurrent_replacement: Option<(crate::layout_store::LayoutStore, String, String, Value)>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ControlLane { + async fn create_agent( + self: Arc, + _: RestLaneCreate, + ) -> Result { + Ok(RestLaneCreated { + session_id: "cached-rest-session".into(), + ..Default::default() + }) + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + let noop = request.text == "\n"; + let first = self.sends.lock().unwrap().is_empty(); + if first { + if let Some((layout, tab_id, pane_id, content)) = &self.concurrent_replacement { + layout.attach_pane_content(tab_id, pane_id, content.clone()); + } + } + let result = RestLaneSendResult { + session_id: first + .then(|| self.materialized_session_id.clone()) + .flatten() + .unwrap_or_else(|| request.session_id.clone()), + completed: true, + submitted_turn_id: (!noop).then(|| "actual-submitted-uuid".into()), + noop, + }; + self.sends.lock().unwrap().push(request); + Ok(result) + } + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + let result = RestLaneCaptureResult { + session_id: request.session_id.clone(), + native_session_id: request.session_id.clone(), + text: "saved conversation".into(), + truncated: false, + }; + self.captures.lock().unwrap().push(request); + Ok(result) + } +} + +#[tokio::test] +async fn settled_session_identity_updates_headless_layout_for_later_send_and_capture() { + const NEXT_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let lane = Arc::new(ControlLane { + materialized_session_id: Some(NEXT_ID.into()), + ..Default::default() + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (_, created) = post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + let pane = created["data"]["paneId"].as_str().unwrap(); + for text in ["/clear", "next turn"] { + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":text}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["sessionId"], NEXT_ID); + } + assert_eq!(lane.sends.lock().unwrap()[1].session_id, NEXT_ID); + assert_eq!(capture_response(router, pane).await.0, StatusCode::OK); + assert_eq!( + lane.captures.lock().unwrap().last().unwrap().session_id, + NEXT_ID + ); +} + +#[tokio::test] +async fn an_old_send_settling_does_not_overwrite_a_concurrent_pane_replacement() { + const SETTLED_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + const REPLACED_ID: &str = "39a6e5a0-3601-4c71-a878-d8da4db265c0"; + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"prior-session"})); + let lane = Arc::new(ControlLane { + materialized_session_id: Some(SETTLED_ID.into()), + concurrent_replacement: Some(( + state.layout.clone(), + tab, + pane.clone(), + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":REPLACED_ID, + "sessionRef":{"provider":"claude", "sessionId":REPLACED_ID}, "model":"replacement-model", + }), + )), + ..Default::default() + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"/clear"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let content = state + .layout + .get_pane_snapshot(&pane) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["sessionId"], REPLACED_ID); + assert_eq!(content["model"], "replacement-model"); + assert_eq!(capture_response(router, &pane).await.0, StatusCode::OK); + assert_eq!( + lane.captures.lock().unwrap().last().unwrap().session_id, + REPLACED_ID + ); +} + +async fn capture_response(router: axum::Router, pane_id: &str) -> (StatusCode, String) { + use tower::util::ServiceExt; + let response = router + .oneshot( + axum::http::Request::builder() + .uri(format!("/api/panes/{pane_id}/capture?maxBytes=30")) + .header("x-auth-token", "tok") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) +} + +#[tokio::test] +async fn local_lane_controls_browser_created_panes_using_the_provider_matched_durable_ref() { + const DURABLE_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let (tab, pane_id) = state.layout.create_tab(Some("Browser-created")); + state.layout.attach_pane_content( + &tab, + &pane_id, + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"kilroy", + "sessionId":"legacy-alias", "sessionRef":{"provider":"claude", "sessionId":DURABLE_ID}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"continue", "requestId":"caller-request"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["submittedTurnId"], "actual-submitted-uuid"); + assert_eq!(body["data"]["sessionRef"]["sessionId"], DURABLE_ID); + let sent = lane.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!(sent.session_id, DURABLE_ID); + assert_eq!(sent.request_id, "caller-request"); + assert_eq!(sent.session_type, "kilroy"); + let (status, text) = capture_response(router, &pane_id).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert_eq!(text, "saved conversation"); + let captured = lane.captures.lock().unwrap().last().unwrap().clone(); + assert_eq!(captured.session_id, DURABLE_ID); + assert_eq!(captured.max_bytes, 30); +} + +#[tokio::test] +async fn a_restored_pane_with_only_its_durable_ref_supports_send_and_capture() { + const ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content( + &tab, + &pane, + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", + "sessionRef":{"provider":"claude", "sessionId":ID}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (status, sent) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"restored prompt"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{sent}"); + assert_eq!(sent["data"]["sessionId"], ID); + assert_eq!(lane.sends.lock().unwrap().last().unwrap().session_id, ID); + let (status, text) = capture_response(router, &pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert_eq!(text, "saved conversation"); + assert_eq!(lane.captures.lock().unwrap().last().unwrap().session_id, ID); +} + +#[tokio::test] +async fn replacing_a_cached_claude_pane_with_other_content_never_controls_the_old_session() { + for (content, capture_status) in [ + ( + json!({"kind":"terminal", "mode":"shell"}), + StatusCode::NOT_FOUND, + ), + ( + json!({"kind":"browser", "url":"https://example.com"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"kind":"editor", "filePath":"/tmp/example.txt"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + let state = state_with_registry(); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (_, created) = post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state.layout.attach_pane_content(tab, pane, content); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"do not send to the retired pane"}), + true, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + let (status, text) = capture_response(router, pane).await; + assert_eq!(status, capture_status, "{text}"); + assert!(lane.sends.lock().unwrap().is_empty()); + assert!(lane.captures.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn the_existing_local_opencode_pane_keeps_its_cached_materialization() { + let state = state_with_registry(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/tabs", + json!({"agent":"opencode"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state + .panes + .lock() + .unwrap() + .get_mut(pane) + .unwrap() + .durable_id = Some("ses_materialized".into()); + // The local OpenCode path stores materialization separately from the + // placeholder still shown by the layout. No generic REST lane is installed. + let (status, text) = capture_response(router.clone(), pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert!(text.is_empty()); + state.layout.attach_pane_content(tab, pane, json!({ + "kind":"fresh-agent", "provider":"opencode", "sessionType":"freshopencode", + "sessionId":"ses_materialized", "sessionRef":{"provider":"opencode", "sessionId":"ses_materialized"}, + })); + let (status, text) = capture_response(router.clone(), pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); + state.layout.attach_pane_content( + tab, + pane, + json!({"kind":"browser", "url":"https://example.com"}), + ); + let (status, text) = capture_response(router, pane).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{text}"); +} + +#[tokio::test] +async fn lane_noop_does_not_report_a_submitted_turn() { + let state = state_with_registry(); + let (tab, pane_id) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane_id, json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"durable-uuid", + })); + state + .set_hosted_rest_gateway(Arc::new(ControlLane::default())) + .unwrap(); + let (status, body) = post( + app(state), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"\n"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["status"], "noop"); + assert!(body["data"].get("submittedTurnId").is_none(), "{body}"); +} + +#[tokio::test] +async fn lane_controls_use_the_current_layout_identity_after_a_rest_created_pane_is_replaced() { + const CURRENT_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, created) = + post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane_id = created["data"]["paneId"].as_str().unwrap(); + let tab_id = created["data"]["tabId"].as_str().unwrap(); + state.layout.attach_pane_content(tab_id, pane_id, json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"kilroy", "sessionId":"new-pane-alias", + "sessionRef":{"provider":"claude", "sessionId":CURRENT_ID}, + })); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"continue"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let send = lane.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!(send.session_id, CURRENT_ID); + assert_eq!(send.session_type, "kilroy"); + let (status, text) = capture_response(router, pane_id).await; + assert_eq!(status, StatusCode::OK, "{text}"); + let capture = lane.captures.lock().unwrap().last().unwrap().clone(); + assert_eq!(capture.session_id, CURRENT_ID); + assert_eq!(capture.session_type, "kilroy"); +} + +#[tokio::test] +async fn actual_claude_create_conflict_preserves_the_refusing_owner_and_retry_hint() { + use freshell_ownership::{ + BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind, RuntimeOwnershipRegistry, + }; + const ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let owner = Arc::new(RuntimeOwnershipRegistry::with_epoch(41)); + let BeginOutcome::Granted { generation } = owner.begin_start( + "claude", + ID, + RuntimeOwnerKind::Terminal, + "terminal-create", + None, + "test", + 1, + ) else { + panic!("terminal claim granted") + }; + assert_eq!( + owner.commit_live( + "claude", + ID, + "terminal-create", + generation, + OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some("existing-terminal".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + CommitOutcome::Committed + ); + let state = state_with_registry(); + let mut claude = crate::FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(16).0)); + claude.set_ownership(owner); + state + .set_claude_rest_lane(Arc::new(crate::claude_rest_lane::ClaudeRestLane::new( + claude, + Arc::new(|| true), + ))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "sessionRef":{"provider":"claude", "sessionId":ID}}), + true, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "SESSION_RESERVED"); + assert_eq!(body["ownerKind"], "terminal"); + assert_eq!(body["ownerEpoch"], 41); + assert_eq!(body["ownerGeneration"], generation); + assert_eq!(body["retryable"], true); + assert!( + body["retryAfterMs"].as_u64().is_some_and(|delay| delay > 0), + "{body}" + ); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn observed_ownership_pair_reaches_the_create_and_send_lanes_unchanged() { + let state = state_with_registry(); + let creates = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(creates.clone()).unwrap(); + let (status, body) = post( + app(state), + "/api/tabs", + json!({"agent":"claude", "observedEpoch":41, "observedGeneration":7}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let created = creates.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!( + (created.observed_epoch, created.observed_generation), + (Some(41), Some(7)) + ); + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"session"})); + let sends = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(sends.clone()).unwrap(); + let (status, body) = post( + app(state), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"continue", "observedEpoch":41, "observedGeneration":7}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let sent = sends.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!( + (sent.observed_epoch, sent.observed_generation), + (Some(41), Some(7)) + ); +} + +#[tokio::test] +async fn actual_claude_spawn_failure_is_unavailable_and_leaves_no_tab() { + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + struct NodeOverride(Option); + impl Drop for NodeOverride { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("FRESHELL_CLAUDE_NODE", value), + None => std::env::remove_var("FRESHELL_CLAUDE_NODE"), + } + } + } + let root = tempfile::tempdir().unwrap(); + let _restore = NodeOverride(std::env::var_os("FRESHELL_CLAUDE_NODE")); + std::env::set_var("FRESHELL_CLAUDE_NODE", root.path().join("missing-node")); + let state = state_with_registry(); + let claude = crate::FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(16).0)); + state + .set_claude_rest_lane(Arc::new(crate::claude_rest_lane::ClaudeRestLane::new( + claude, + Arc::new(|| true), + ))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "cwd":root.path().to_str().unwrap()}), + true, + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert_eq!(body["code"], "CLAUDE_SIDECAR_START_FAILED"); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn malformed_or_incomplete_rest_fences_refuse_before_provider_creation() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + for body in [ + json!({"agent":"claude", "observedEpoch":41}), + json!({"agent":"claude", "observedEpoch":"41", "observedGeneration":7}), + json!({"agent":"claude", "observedEpoch":41, "observedGeneration":-1}), + ] { + let (status, body) = post(router.clone(), "/api/tabs", body, true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["code"], "INVALID_FENCE"); + } + assert!(lane.creates.lock().unwrap().is_empty()); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} diff --git a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs new file mode 100644 index 000000000..6cc1cf8e5 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs @@ -0,0 +1,564 @@ +use super::tests::{app, delete, get, post, state_with_registry}; +use crate::rest_lane::*; +use axum::http::StatusCode; +use serde_json::json; +use std::sync::Arc; + +#[derive(Default)] +struct PausedCreateLane { + started: tokio::sync::Notify, + finish: tokio::sync::Notify, + request: std::sync::Mutex>, + running: std::sync::atomic::AtomicBool, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for PausedCreateLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + *self.request.lock().unwrap() = Some(request); + self.started.notify_one(); + self.finish.notified().await; + self.running + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(RestLaneCreated { + session_id: "late-runtime".into(), + cleanup_token: Some("late-runtime-owner".into()), + ..Default::default() + }) + } + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("the closed pane must never accept a send") + } + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + assert_eq!( + request.request_id, + self.request.lock().unwrap().as_ref().unwrap().request_id + ); + assert_eq!(created.cleanup_token.as_deref(), Some("late-runtime-owner")); + self.running + .store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} + +#[derive(Clone, Copy)] +enum CloseDuringCreate { + Tab, + SourcePane { sync_layout: bool }, + PendingPane, +} + +async fn close_during_create(target: CloseDuringCreate) { + let split = matches!(target, CloseDuringCreate::SourcePane { .. }); + let state = state_with_registry(); + let before: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"split", "id":"ancestor", "direction":"horizontal", "sizes":[50,50], "children":[ + {"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell", "createRequestId":"source-create"}}, + {"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://survivor.example"}}, + ]}}, "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&before, "creating-browser"); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let create_router = router.clone(); + let mut events = state.broadcast_tx.subscribe(); + let creating = tokio::spawn(async move { + post( + create_router, + if split { + "/api/panes/source-pane/split" + } else { + "/api/tabs" + }, + json!({"agent":"claude"}), + true, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), lane.started.notified()) + .await + .unwrap(); + let request = lane.request.lock().unwrap().clone().unwrap(); + if let CloseDuringCreate::SourcePane { sync_layout } = target { + let (status, closed) = post( + router.clone(), + "/api/panes/source-pane/close", + json!({}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{closed}"); + // The closing browser's next sync excludes the not-yet-announced + // split too: it never knew that tentative pane existed. + let closed: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":2, + "layouts":{"current-tab":{"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://survivor.example"}}}, + "activePane":{"current-tab":"sibling-pane"}, + })).unwrap(); + if sync_layout { + state.layout.update_from_ui(&closed, "creating-browser"); + } + } else if matches!(target, CloseDuringCreate::PendingPane) { + state.layout.release_pane_publication(&request.request_id); + } else { + let (status, closed) = delete( + router.clone(), + &format!("/api/tabs/{}", request.tab_id.as_deref().unwrap()), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{closed}"); + } + lane.finish.notify_one(); + let (status, result) = creating.await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "a closed create target must not publish success: {result}" + ); + assert_eq!(result["code"], "PANE_CREATE_CANCELLED"); + assert_eq!(result["retryable"], false); + assert!( + !lane.running.load(std::sync::atomic::Ordering::SeqCst), + "the late owned runtime must be cleaned up" + ); + assert!(state + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_none()); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert!( + !matches!(frame["command"].as_str(), Some("pane.split" | "tab.create")), + "an abandoned create must not be announced: {frame}" + ); + } + assert_eq!( + state + .layout + .get_pane_snapshot("sibling-pane") + .unwrap() + .pane_content + .unwrap()["url"], + "https://survivor.example" + ); +} + +#[tokio::test] +async fn closing_the_target_tab_while_runtime_create_awaits_cleans_the_late_runtime() { + close_during_create(CloseDuringCreate::Tab).await; +} + +#[tokio::test] +async fn closing_the_source_pane_while_runtime_create_awaits_cleans_the_late_runtime() { + close_during_create(CloseDuringCreate::SourcePane { sync_layout: true }).await; +} + +#[tokio::test] +async fn closing_the_source_before_its_layout_debounce_cancels_the_pending_create() { + close_during_create(CloseDuringCreate::SourcePane { sync_layout: false }).await; +} + +#[tokio::test] +async fn a_pane_closed_ack_before_runtime_create_returns_cancels_its_publication() { + close_during_create(CloseDuringCreate::PendingPane).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_close_after_layout_finalization_cannot_announce_before_the_create() { + let state = state_with_registry(); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let mut events = state.broadcast_tx.subscribe(); + let create_router = router.clone(); + let creating = tokio::spawn(async move { + post(create_router, "/api/tabs", json!({"agent":"claude"}), true).await + }); + lane.started.notified().await; + let request = lane.request.lock().unwrap().clone().unwrap(); + let tab_id = request.tab_id.clone().unwrap(); + + // Block the existing cache insertion after the layout has been finalized. + // DELETE does not need this cache lock to close and announce the tab. + let held_state = state.clone(); + let (locked, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _cache = held_state.panes.lock().unwrap(); + locked.send(()).unwrap(); + released.recv().unwrap(); + }); + ready.await.unwrap(); + lane.finish.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !state.tabs.lock().unwrap().contains_key(&tab_id) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the create reached cache registration"); + let close_tab = tab_id.clone(); + let mut closing = + tokio::spawn(async move { delete(router, &format!("/api/tabs/{close_tab}"), true).await }); + // While registration is blocked, a properly serialized close must wait. + let early_close = + tokio::time::timeout(std::time::Duration::from_millis(100), &mut closing).await; + release.send(()).unwrap(); + holder.join().unwrap(); + let (created_status, created) = creating.await.unwrap(); + assert_eq!(created_status, StatusCode::OK, "{created}"); + let (closed_status, closed) = match early_close { + Ok(closed) => closed.unwrap(), + Err(_) => closing.await.unwrap(), + }; + assert_eq!(closed_status, StatusCode::OK, "{closed}"); + let mut commands = Vec::new(); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + if let Some(command) = frame["command"].as_str() { + commands.push(command.to_string()); + } + } + assert_eq!(commands, ["tab.create", "tab.close"]); + assert!(!state.tabs.lock().unwrap().contains_key(&tab_id)); + assert!(state + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_none()); +} + +async fn split_finalization_and_close_are_ordered(close_source: bool) { + let state = state_with_registry(); + let before = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"browser", "url":"https://source.example"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&before, "creating-browser"); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let mut events = state.broadcast_tx.subscribe(); + let create_router = router.clone(); + let creating = tokio::spawn(async move { + post( + create_router, + "/api/panes/source-pane/split", + json!({"agent":"claude"}), + true, + ) + .await + }); + lane.started.notified().await; + let request = lane.request.lock().unwrap().clone().unwrap(); + let new_pane = request.pane_id.clone().unwrap(); + let held_state = state.clone(); + let (locked, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _cache = held_state.panes.lock().unwrap(); + locked.send(()).unwrap(); + released.recv().unwrap(); + }); + ready.await.unwrap(); + lane.finish.notify_one(); + + // On the broken path, the finalized content is observable while cache + // insertion is blocked. Atomic publication instead keeps this reader and + // the close behind the layout lock until the announcement is queued. + let observed_state = state.clone(); + let observed_pane = new_pane.clone(); + let stopping = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observer_stopping = stopping.clone(); + let (finalized, observed) = tokio::sync::oneshot::channel(); + let observer = std::thread::spawn(move || { + while !observer_stopping.load(std::sync::atomic::Ordering::SeqCst) { + if observed_state + .layout + .get_pane_snapshot(&observed_pane) + .and_then(|pane| pane.pane_content) + .is_some_and(|content| content["sessionId"] == "late-runtime") + { + let _ = finalized.send(()); + return; + } + std::thread::yield_now(); + } + }); + let _ = tokio::time::timeout(std::time::Duration::from_millis(100), observed).await; + let close_pane = if close_source { + "source-pane".to_string() + } else { + new_pane.clone() + }; + let mut closing = tokio::spawn(async move { + post( + router, + &format!("/api/panes/{close_pane}/close"), + json!({}), + true, + ) + .await + }); + let early_close = + tokio::time::timeout(std::time::Duration::from_millis(100), &mut closing).await; + stopping.store(true, std::sync::atomic::Ordering::SeqCst); + release.send(()).unwrap(); + holder.join().unwrap(); + observer.join().unwrap(); + let (created_status, created) = creating.await.unwrap(); + assert_eq!(created_status, StatusCode::OK, "{created}"); + let (closed_status, closed) = match early_close { + Ok(closed) => closed.unwrap(), + Err(_) => closing.await.unwrap(), + }; + assert_eq!(closed_status, StatusCode::OK, "{closed}"); + let mut commands = Vec::new(); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + if let Some(command) = frame["command"].as_str() { + commands.push(command.to_string()); + } + } + assert_eq!(commands, ["pane.split", "pane.close"]); + assert_eq!( + state.layout.get_pane_snapshot(&new_pane).is_some(), + close_source + ); + assert_eq!( + state.layout.get_pane_snapshot("source-pane").is_some(), + !close_source + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn closing_a_finalized_split_cannot_announce_before_the_split() { + split_finalization_and_close_are_ordered(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn closing_a_finalized_split_source_cannot_announce_before_the_split() { + split_finalization_and_close_are_ordered(true).await; +} + +struct SyncDuringCreateLane { + layout: crate::layout_store::LayoutStore, + browser_sync: freshell_protocol::UiLayoutSync, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for SyncDuringCreateLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + assert!(self + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_some()); + // A browser can publish its already-scheduled layout before it has + // received the create announcement, while the runtime launch awaits. + self.layout + .update_from_ui(&self.browser_sync, "creating-browser"); + Ok(RestLaneCreated { + session_id: "created-during-sync".into(), + ..Default::default() + }) + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + Ok(RestLaneSendResult { + session_id: request.session_id, + completed: true, + submitted_turn_id: Some("submitted-after-create".into()), + noop: false, + }) + } +} + +#[tokio::test] +async fn an_announced_rest_split_is_discoverable_after_a_browser_sync_during_runtime_create() { + let state = state_with_registry(); + let payload = json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"split", "id":"existing-ancestor", "direction":"horizontal", "sizes":[40,60], "children":[ + {"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}, + {"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://before.example"}}, + ]}}, "activePane":{"current-tab":"source-pane"}, + }); + let initial: freshell_protocol::UiLayoutSync = serde_json::from_value(payload.clone()).unwrap(); + state.layout.update_from_ui(&initial, "creating-browser"); + let mut during_create = payload; + during_create["layouts"]["current-tab"]["direction"] = json!("vertical"); + during_create["layouts"]["current-tab"]["children"][1]["content"]["url"] = + json!("https://changed-during-create.example"); + let lane = Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync: serde_json::from_value(during_create).unwrap(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let mut events = state.broadcast_tx.subscribe(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/panes/source-pane/split", + json!({"agent":"kilroy"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane = created["data"]["paneId"].as_str().unwrap(); + // An already-sent pre-announcement sync can arrive just after completion too. + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + let (_, listed) = get(router.clone(), "/api/panes?tabId=current-tab", true).await; + assert!( + listed["data"]["panes"] + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == pane), + "an announced pane must be immediately discoverable: {listed}" + ); + let (_, snapshot) = get(router.clone(), "/api/layout/snapshot", true).await; + assert_eq!( + snapshot["data"]["layouts"]["current-tab"]["id"], + "existing-ancestor" + ); + assert_eq!( + snapshot["data"]["layouts"]["current-tab"]["direction"], + "vertical" + ); + assert_eq!( + state + .layout + .get_pane_snapshot("sibling-pane") + .unwrap() + .pane_content + .unwrap()["url"], + "https://changed-during-create.example" + ); + let (status, sent) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"an immediate external prompt"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{sent}"); + assert_eq!(sent["data"]["sessionId"], "created-during-sync"); + assert!( + events.try_recv().is_ok(), + "the successful split is announced" + ); + let (status, closed) = post(router, &format!("/api/panes/{pane}/close"), json!({}), true).await; + assert_eq!(status, StatusCode::OK, "{closed}"); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + assert!( + state.layout.get_pane_snapshot(pane).is_none(), + "an explicit close releases publication authority" + ); +} + +#[tokio::test] +async fn an_announced_rest_tab_is_discoverable_after_stale_syncs_during_and_after_create() { + let state = state_with_registry(); + let browser_sync: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state + .layout + .update_from_ui(&browser_sync, "creating-browser"); + let lane = Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync, + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "name":"Background conversation"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + let (_, listed) = get(router.clone(), "/api/tabs", true).await; + assert!( + listed["data"]["tabs"] + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == tab), + "the announced tab remains discoverable: {listed}" + ); + assert_eq!( + listed["data"]["activeTabId"], "current-tab", + "publication stays focus neutral" + ); + let (_, listed) = get(router.clone(), &format!("/api/panes?tabId={tab}"), true).await; + assert_eq!(listed["data"]["panes"][0]["id"], pane); + state.layout.close_tab(tab); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + assert!( + state.layout.get_pane_snapshot(pane).is_none(), + "a closed background tab must not reappear" + ); +} + +#[tokio::test] +async fn observing_the_created_leaf_releases_publication_authority_for_later_layout_edits() { + let state = state_with_registry(); + let original: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&original, "creating-browser"); + state + .set_claude_rest_lane(Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync: original.clone(), + })) + .unwrap(); + let (status, created) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"claude"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane = created["data"]["paneId"].as_str().unwrap(); + let mut observed = state.layout.get_normalized_snapshot(None); + observed["timestamp"] = json!(2); + state.layout.update_from_ui( + &serde_json::from_value(observed).unwrap(), + "creating-browser", + ); + // Once the browser observed the leaf, subsequent layout edits are its + // authority again. The publication must not keep forcing the old tab back. + state.layout.update_from_ui(&original, "creating-browser"); + assert!(state.layout.get_pane_snapshot(pane).is_none()); +} diff --git a/crates/freshell-freshagent/src/rest_lane_sync.rs b/crates/freshell-freshagent/src/rest_lane_sync.rs new file mode 100644 index 000000000..953cb0011 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_sync.rs @@ -0,0 +1,44 @@ +//! Carry a REST-driven conversation rekey into its headless pane identity. + +use crate::{FreshAgentState, PaneEntry}; + +impl FreshAgentState { + pub(crate) fn reconcile_rest_send_session( + &self, + pane_id: &str, + accepted: &PaneEntry, + before: &str, + after: &str, + ) { + if before == after || after.is_empty() { + return; + } + let updated = self.layout.rekey_agent_pane_if_current( + pane_id, + &accepted.provider, + &accepted.session_type, + before, + after, + ); + tracing::info!( + event = "freshagent.rest_lane.send_identity", + pane_id, + provider = accepted.provider, + before, + after, + layout_updated = updated + ); + if !updated && self.layout.get_pane_snapshot(pane_id).is_some() { + return; + } + let mut panes = self.panes.lock().expect("panes mutex"); + if let Some(pane) = panes.get_mut(pane_id).filter(|pane| { + pane.provider == accepted.provider + && pane.session_type == accepted.session_type + && pane.durable_id.as_deref().unwrap_or(&pane.placeholder_id) == before + }) { + pane.placeholder_id = after.into(); + pane.durable_id = Some(after.into()); + } + } +} diff --git a/crates/freshell-freshagent/src/session_handoff.rs b/crates/freshell-freshagent/src/session_handoff.rs index 0d760a8a9..7c5648188 100644 --- a/crates/freshell-freshagent/src/session_handoff.rs +++ b/crates/freshell-freshagent/src/session_handoff.rs @@ -3245,7 +3245,7 @@ impl SessionHandoffRunner { .load(std::sync::atomic::Ordering::SeqCst) && hooks .force_reap_timeout_fenced_skip - .fetch_update( + .try_update( std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, |n| if n == 0 { None } else { Some(n - 1) }, @@ -3261,7 +3261,7 @@ impl SessionHandoffRunner { .load(std::sync::atomic::Ordering::SeqCst) && hooks .force_platform_limited_skip - .fetch_update( + .try_update( std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, |n| if n == 0 { None } else { Some(n - 1) }, @@ -4112,7 +4112,6 @@ impl SessionHandoffRunner { req.cwd.as_deref(), req.tab_id.as_deref(), req.pane_id.as_deref(), - operation_id, generation, ) .await @@ -4125,7 +4124,6 @@ impl SessionHandoffRunner { req.cwd.as_deref(), req.tab_id.as_deref(), req.pane_id.as_deref(), - operation_id, generation, ) .await diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 7199833fd..98460787a 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -5,8 +5,7 @@ //! late exit still releases), detached completion, abort leaves zero-or-one //! owner, the OpenCode invariant, and the kilroy flavor. //! -//! Harness: the inline fake claude sidecar env pattern (copied verbatim from -//! `cross_kind_liveness.rs::FakeSidecarEnv` — the request-log knob), the +//! Harness: the production Claude sidecar over the CLI simulator, the //! fake `opencode serve` (the `freshagent_session_lease.rs` shape), and the //! common sleeper CLI spec so terminal targets genuinely spawn Running PTYs. //! `ENV_LOCK` serializes this file's tests (process-global env knobs). @@ -33,94 +32,35 @@ use super::{ /// Serializes the tests in this file: they mutate process-global env vars /// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / -/// `FAKE_SIDECAR_REQUEST_LOG` / `OPENCODE_CMD` / ...). It is the crate-wide +/// `OPENCODE_CMD` / ...). It is the crate-wide /// [`crate::OPENCODE_ENV_LOCK`] under its historical name — the snapshot /// cold-GET tests mutate `OPENCODE_CMD` too and must exclude these. use crate::OPENCODE_ENV_LOCK as ENV_LOCK; -// ── fake claude sidecar (request-log knob only; copied from -// cross_kind_liveness.rs per the plan's instruction) ────────────────────── +// ── production Claude sidecar over the contract-tested CLI simulator ───── -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' +struct ClaudeSidecarEnv(crate::claude::tests::ClaudeTestRuntime); -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || sessionId - 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 === 'shutdown') { - process.exit(0) - } -}) -"#; - -struct FakeSidecarEnv { - dir: std::path::PathBuf, -} - -impl FakeSidecarEnv { +impl ClaudeSidecarEnv { fn install() -> Self { - let dir = - std::env::temp_dir().join(format!("freshell-session-handoff-{}", uuid_like_suffix())); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } + Self(crate::claude::tests::ClaudeTestRuntime::install()) } fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") + std::fs::read_to_string(self.0.spawn_log_path()) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("actual SDK query log row")) .collect() } - /// The sidecar pid that served THIS session's create (the process the - /// handoff must reap). fn sidecar_pid_for(&self, sid: &str) -> Option { self.create_rows() .into_iter() - .find(|r| r["msg"]["boot"]["resume"] == sid) - .and_then(|r| r["pid"].as_u64()) - .map(|p| p as u32) - } -} - -impl Drop for FakeSidecarEnv { - fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } - let _ = std::fs::remove_dir_all(&self.dir); + .find(|row| row["options"]["resume"] == sid || row["options"]["sessionId"] == sid) + .and_then(|row| row["pid"].as_u64()) + .map(|pid| pid as u32) } } @@ -712,7 +652,7 @@ fn handoff_action_contract_defaults_legacy_ack_only_to_clear() { async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_preflight() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let hooks = Arc::new(HandoffTestHooks::default()); hooks .force_unsupported_preflight @@ -927,7 +867,29 @@ async fn await_live_session(state: &crate::FreshClaudeState, sid: &str, want: bo /// Establish a live freshclaude owner for `sid` (the fake sidecar path) and /// return once the coordinator records it Live. async fn establish_fresh_claude_owner(rig: &mut Rig, sid: &str) { - rig.fresh_claude.handle_create(create_msg(sid), None).await; + // The actual CLI resumes a saved conversation; an arbitrary id is not + // a substitute for its transcript. Preserve any scenario's existing file. + let store = std::path::PathBuf::from( + std::env::var_os("CLAUDE_CONFIG_DIR").expect("isolated Claude store"), + ); + let project = store.join("projects").join("-handoff"); + std::fs::create_dir_all(&project).unwrap(); + let file = project.join(format!("{sid}.jsonl")); + let already_saved = std::fs::read_dir(store.join("projects")) + .unwrap() + .flatten() + .any(|entry| entry.path().join(format!("{sid}.jsonl")).is_file()); + if !already_saved { + std::fs::write(file, format!("{}\n", json!({ + "type":"user", "uuid":uuid::Uuid::new_v4().to_string(), "sessionId":sid, + "cwd":"/tmp", "promptSource":"sdk", "message":{"role":"user", "content":"saved handoff conversation"}, + }))).unwrap(); + } + let outcome = rig.fresh_claude.handle_create(create_msg(sid), None).await; + assert!( + matches!(outcome, crate::ClaudeCreateOutcome::Created { .. }), + "{outcome:?}" + ); let deadline = tokio::time::Instant::now() + Duration::from_secs(10); loop { match rig.ownership.observe("claude", sid).state { @@ -1078,7 +1040,7 @@ async fn handoff_to_terminal_reaps_sidecar_before_target_start_and_commits_owner // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let (events, _capture_guard) = tracing_capture::capture(); // M-1 (round-3 review): hooks installed so the runner's step order is @@ -1253,7 +1215,7 @@ async fn handoff_target_spawn_failure_leaves_vacant_with_typed_error() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks.fail_target_spawn_once.store(true, Ordering::SeqCst); @@ -1347,7 +1309,7 @@ async fn handoff_reap_timeout_reprobes_the_prior_and_restores_only_a_live_one() // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -1437,7 +1399,7 @@ async fn handoff_reap_timeout_with_unconfirmable_prior_ends_vacant_typed() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -1558,7 +1520,7 @@ async fn handoff_reap_timeout_fences_the_key_until_the_detached_reap_confirms_de // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let mut rig = build_rig_with_claude_pauses_and_reap_timeout_ms( @@ -1715,7 +1677,7 @@ async fn a_watcher_join_error_fences_the_key_until_the_replacement_probe_confirm // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks::default()); @@ -1877,7 +1839,7 @@ async fn a_watcher_join_error_fences_the_key_until_the_replacement_probe_confirm async fn handoff_with_a_platform_limited_prior_stop_fences_the_key_typed() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -1962,7 +1924,7 @@ async fn handoff_with_a_platform_limited_prior_stop_fences_the_key_typed() { async fn a_stale_stop_fence_recovers_through_the_handoff_runner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); @@ -2211,7 +2173,7 @@ impl crate::session_handoff::FlavorWrite for FixedFlavorWriter { async fn a_kilroy_to_claude_cli_handoff_preserves_the_kilroy_flavor() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let log: FlavorWriteLog = Arc::new(std::sync::Mutex::new(Vec::new())); // The session's current durable flavor is KILROY (the hidden type). @@ -2250,7 +2212,7 @@ async fn a_kilroy_to_claude_cli_handoff_preserves_the_kilroy_flavor() { async fn an_unconfirmed_target_reap_on_the_flavor_failure_fences_then_the_watcher_resolves() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks @@ -2329,7 +2291,7 @@ async fn an_unconfirmed_target_reap_on_the_flavor_failure_fences_then_the_watche async fn a_platform_limited_target_reap_on_the_flavor_failure_fences_typed() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks @@ -2401,7 +2363,7 @@ fn last_handoff_failed_frame(frames: &[Value]) -> &Value { async fn the_unconfirmed_reap_flavor_failure_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); @@ -2483,7 +2445,7 @@ async fn the_unconfirmed_reap_flavor_failure_frame_stays_fenced_never_vacant() { async fn the_platform_limited_reap_failure_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); @@ -2605,7 +2567,7 @@ async fn the_platform_limited_reap_failure_frame_stays_fenced_never_vacant() { async fn the_stale_commit_unconfirmed_reap_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); @@ -2761,7 +2723,7 @@ async fn the_stale_commit_unconfirmed_reap_frame_stays_fenced_never_vacant() { async fn a_watcher_release_mid_failure_window_leaves_the_final_frame_resolved() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let failure_park = Arc::new(tokio::sync::Notify::new()); @@ -2913,7 +2875,7 @@ async fn a_watcher_release_mid_failure_window_leaves_the_final_frame_resolved() async fn a_foreign_commit_during_the_abort_broadcast_window_is_never_overwritten() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let abort_broadcast_pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -3034,7 +2996,7 @@ async fn a_foreign_commit_during_the_abort_broadcast_window_is_never_overwritten async fn the_uncommitted_target_reap_never_confirms_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // A SHORT reap budget: the pid-alive window deterministically fences // within the test instead of waiting the production budget. @@ -3125,7 +3087,7 @@ async fn the_uncommitted_target_reap_never_confirms_on_a_missing_row() { async fn the_confirmed_reap_flavor_failure_broadcasts_the_truthful_vacancy() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_flavor_writer(Arc::new(FailingFlavorWriter)); @@ -3260,7 +3222,7 @@ impl crate::session_handoff::FlavorWrite for TwoPhaseProbeFlavorWriter { async fn an_abort_after_the_staged_flavor_precursor_never_persists_the_target_flavor() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let store_dir = std::env::temp_dir().join(format!("freshell-r28-f4-store-{}", uuid_like_suffix())); @@ -3374,7 +3336,7 @@ async fn an_abort_after_the_staged_flavor_precursor_never_persists_the_target_fl async fn an_abort_in_the_flavor_window_with_an_unconfirmed_target_teardown_fences_not_vacates() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b pattern) // — the fresh TARGET's resume needs the fake transcript. @@ -3566,7 +3528,7 @@ impl crate::session_handoff::FlavorWrite for BlockingFlavorWriter { async fn a_flavor_write_failure_surfaces_as_the_typed_handoff_failure() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_flavor_writer(Arc::new(FailingFlavorWriter)); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -3596,7 +3558,7 @@ async fn a_flavor_write_failure_surfaces_as_the_typed_handoff_failure() { async fn the_flavor_write_serializes_consecutive_handoffs_in_generation_order() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let release = Arc::new(tokio::sync::Notify::new()); let reached = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -3675,7 +3637,7 @@ async fn the_flavor_write_serializes_consecutive_handoffs_in_generation_order() async fn the_handoff_commit_writes_the_durable_flavor_server_side() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -3722,7 +3684,7 @@ async fn the_handoff_commit_writes_the_durable_flavor_server_side() { async fn a_stale_start_fence_recovers_through_the_acknowledged_force_clear() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); @@ -3919,7 +3881,7 @@ async fn a_stale_start_fence_recovers_through_the_acknowledged_force_clear() { async fn a_platform_limited_fence_recovers_only_through_the_acknowledged_force_clear() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4081,7 +4043,7 @@ async fn a_platform_limited_fence_recovers_only_through_the_acknowledged_force_c async fn the_cleared_unverified_state_requires_the_acknowledged_start() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4190,7 +4152,7 @@ async fn the_cleared_unverified_state_requires_the_acknowledged_start() { async fn the_acknowledged_start_reaps_the_live_prior_before_the_new_writer() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4298,7 +4260,7 @@ async fn the_acknowledged_start_reaps_the_live_prior_before_the_new_writer() { async fn the_acknowledged_start_over_an_already_dead_prior_proceeds() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4752,7 +4714,7 @@ async fn the_row_removed_but_pid_alive_window_reports_unconfirmed() { async fn a_delayed_platform_limited_reap_fences_the_key_and_never_releases() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); // The platform-limited seam + a SHORT reap budget: the parked lane kill @@ -4834,7 +4796,7 @@ async fn a_delayed_platform_limited_reap_fences_the_key_and_never_releases() { async fn handoff_with_an_unconfirmable_claude_tree_fences_the_key_until_the_escalation_lands() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The one-round confirmation window: a TERM-immune descendant // deterministically outlives it (the SIGKILL escalation is rounds away). @@ -4973,7 +4935,7 @@ async fn a_prior_restored_after_reap_timeout_still_releases_when_it_later_exits( // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -5023,7 +4985,7 @@ async fn a_wire_fenced_kill_at_the_handoff_generation_converges_after_a_restore( // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -5193,7 +5155,7 @@ async fn handoff_continues_to_consistency_when_client_disconnects() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -5258,7 +5220,7 @@ async fn handoff_task_abort_leaves_zero_or_one_owner() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -5370,7 +5332,7 @@ async fn handoff_abort_during_prior_kill_reprobes_and_repairs_the_stamp() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let mut rig = build_rig_with_claude_pauses(None, Some(Arc::clone(&kill_pause)), None); @@ -5527,7 +5489,7 @@ async fn handoff_abort_during_target_spawn_reaps_the_uncommitted_terminal() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -5635,7 +5597,7 @@ async fn handoff_abort_during_target_spawn_reaps_the_uncommitted_terminal() { async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b // pattern). @@ -5707,7 +5669,7 @@ async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { let target_pid = rows .iter() .rev() - .find(|r| r["msg"]["boot"]["resume"] == sid) + .find(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .and_then(|r| r["pid"].as_u64()) .expect("the target sidecar's create row") as u32; let _ = std::process::Command::new("kill") @@ -5769,7 +5731,7 @@ async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { async fn handoff_abort_of_a_fresh_target_fires_the_failed_transition_repair() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b pattern): // the target's resume of `sid` needs the transcript under @@ -5879,7 +5841,7 @@ async fn handoff_abort_of_a_fresh_target_fires_the_failed_transition_repair() { async fn handoff_abort_during_pre_publication_spawn_fences_until_the_spawn_settles() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pre_publish_park = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -6007,7 +5969,7 @@ async fn handoff_abort_cleanup_spares_a_same_id_different_provider_terminal() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -6132,7 +6094,7 @@ async fn handoff_abort_during_fresh_target_resume_reaps_the_registered_session_a // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-abort-fresh-store-{}", @@ -6185,7 +6147,7 @@ async fn handoff_abort_during_fresh_target_resume_reaps_the_registered_session_a let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6241,7 +6203,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { let _guard = ENV_LOCK.lock().await; // The claude-lane env surface is process-global (see the pattern above). let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r27-f2-{}", uuid_like_suffix())); @@ -6380,7 +6342,7 @@ async fn a_terminal_target_binding_failure_fails_the_handoff_typed() { let _guard = ENV_LOCK.lock().await; // The claude-lane env surface is process-global (see the pattern above). let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r27-f3-{}", uuid_like_suffix())); @@ -6482,7 +6444,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r36-store-{}", uuid_like_suffix())); @@ -6524,7 +6486,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6636,7 +6598,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation async fn an_aborted_from_vacant_platform_limited_target_fences_typed_and_recovers() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-r54a-store-{}", @@ -6769,7 +6731,7 @@ async fn an_aborted_from_vacant_platform_limited_target_fences_typed_and_recover async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_watcher() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-r54b-store-{}", @@ -6820,7 +6782,7 @@ async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_wat let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != 0) .expect("the target sidecar's create row") as u32; @@ -6912,7 +6874,7 @@ async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_wat async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -6992,7 +6954,7 @@ fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let canonical = uuid::Uuid::new_v4().to_string(); let superseded = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); @@ -7003,54 +6965,18 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { let sidecar_pid = env.sidecar_pid_for(&superseded); assert!(sidecar_pid.is_some(), "the live owner's sidecar pid"); - // A re-key in the FULL production sequence: (1) an in-stream init - // naming the NEW durable id publishes the re-key bookkeeping at the LANE - // level (cli_index[new] = map key + the session's cli_session_id = new; - // the session already carries the old id, so the adoption claims - // nothing under the new one), then (2) the registry's own rekey_live - // performs the atomic old→new coordinator move, leaving - // Aliased{to: canonical} at the superseded key — the COORDINATOR as the - // single source of truth (e2r3 F3). Pre-fix this id pair was a - // lane-local HashMap nobody hydrated. - // (1) The re-key's publication — the REAL lane-level bookkeeping, - // driven through the adoption itself. - { - let map_key = rig - .fresh_claude + // Use the consumer's actual native reset path: runtime identity, lease, + // coordinator owner and retained stamp move together. + rig.fresh_claude + .test_rekey_conversation(&superseded, &canonical) + .await; + assert_eq!( + rig.fresh_claude .test_resolve_session_key(&superseded) .await - .expect("the live session's map key"); - rig.fresh_claude - .test_adopt_session_init(&canonical, &map_key, "freshclaude") - .await; - } - // (2) The registry's own rekey_live performs the atomic old→new - // coordinator move (the same step the rollback's Adopt-path commit - // performs), leaving Aliased{to: canonical} at the superseded key — - // the COORDINATOR as the single source of truth (e2r3 F3). - let rekey_map_key = rig - .fresh_claude - .test_resolve_session_key(&superseded) - .await - .expect("the live session's map key"); - assert!(matches!( - rig.ownership.rekey_live( - "claude", - &superseded, - &canonical, - &rekey_map_key, - freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: Some(rekey_map_key.clone()), - pid: sidecar_pid, - ownership_id: Some("test-rekey-op".into()), - }, - "test-rekey", - "test-rekey-op", - ), - freshell_ownership::CommitOutcome::Committed - )); + .as_deref(), + Some(canonical.as_str()) + ); // THE e2r2 F1 red/green: the handoff on the SUPERSEDED id resolves the // canonical key and stops the prior owner. @@ -7097,7 +7023,7 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { async fn a_fresh_created_claude_session_owns_its_uuid_and_hands_off_atomically() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); let _ = drain_runtime_owner_frames(&mut rig.rx); @@ -7186,7 +7112,7 @@ async fn a_fresh_created_claude_session_owns_its_uuid_and_hands_off_atomically() async fn a_fresh_created_kilroy_session_owns_its_acknowledged_uuid() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); let _ = drain_runtime_owner_frames(&mut rig.rx); @@ -8415,7 +8341,7 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume path gates on transcript presence — install a // fake transcript store root for the session. let store_dir = std::env::temp_dir().join(format!( @@ -8471,6 +8397,29 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { // The prior terminal is dead. assert!(rig.registry.terminal_is_dead(&prior_terminal)); + // The real CLI emits its stream init when a turn starts. Prove the + // resumed consumer keeps Kilroy's flavor using that actual first turn. + let sent = rig + .fresh_claude + .handle_send(freshell_protocol::FreshAgentSend { + provider: AgentProvider::Claude, + session_id: sid.clone(), + session_type: SessionType::Kilroy, + text: "prove the Kilroy handoff consumer".into(), + cwd: Some("/tmp".into()), + images: None, + request_id: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + }) + .await; + assert!( + matches!(sent, crate::ClaudeSendOutcome::Accepted { .. }), + "{sent:?}" + ); + // Retain both facts regardless of their relative delivery order. The // consumer can publish its first Kilroy frame before or after the runner's // owner commit; waiting for one must never discard the other. @@ -8677,7 +8626,7 @@ async fn handoff_route_refuses_provider_mismatched_targets_typed() { // The fake serve: wrong-lane dispatches that reach the opencode lane // resume a session the fake 404s (the "missing" id rule). let _serve_env = FakeOpencodeServeEnv::install(); - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); // A LIVE prior for the wrong-mode case: the refusal must leave this @@ -8896,7 +8845,7 @@ async fn handoff_route_resolves_absent_session_type_when_unambiguous() { async fn the_prior_stop_never_confirms_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // A SHORT reap budget: the pid-alive window deterministically fences // within the test. @@ -8989,7 +8938,7 @@ async fn the_prior_stop_never_confirms_on_a_missing_row() { async fn the_replacement_probe_never_confirms_a_terminal_prior_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let rig = build_rig_inner(None, None, None, 10_000, None, false, None, None); // The prior's stand-in process: ALIVE, its registry row ABSENT — the // exact row-removed-but-not-yet-reaped shape a concurrent kill diff --git a/crates/freshell-freshagent/src/session_lease.rs b/crates/freshell-freshagent/src/session_lease.rs index fbe8a4c5e..d0286fcfc 100644 --- a/crates/freshell-freshagent/src/session_lease.rs +++ b/crates/freshell-freshagent/src/session_lease.rs @@ -1022,6 +1022,36 @@ impl FreshAgentSessionLeases { inner.bindings.remove(&lease_key(provider, session_id)); } + /// Move a completed binding with the same live process after a native + /// conversation reset. An unrelated or in-flight target is untouched. + pub(crate) fn rekey_binding( + &self, + provider: &str, + old: &str, + new: &str, + old_live_key: &str, + new_live_key: &str, + ownership_id: &str, + ) -> bool { + let mut inner = self.inner.lock().expect("fresh-agent lease lock poisoned"); + let old_key = lease_key(provider, old); + let new_key = lease_key(provider, new); + let Some(binding) = inner.bindings.get(&old_key) else { + return true; + }; + if binding.live_session_key != old_live_key + || binding.ownership_id.as_deref() != Some(ownership_id) + || inner.bindings.contains_key(&new_key) + || inner.leases.contains_key(&new_key) + { + return false; + } + let mut binding = inner.bindings.remove(&old_key).expect("validated binding"); + binding.live_session_key = new_live_key.into(); + inner.bindings.insert(new_key, binding); + true + } + /// Clear a completed binding only when it still names the watched /// runtime. A missing or different ownership tag is a deliberate no-op: /// the caller is stale or cannot prove it owns the binding. @@ -1201,6 +1231,55 @@ mod tests { ); } + #[test] + fn conversation_reset_moves_only_the_matching_runtime_binding() { + let leases = FreshAgentSessionLeases::new(); + leases.claim("claude", "old", "create", 0); + assert!(leases.complete_with_identity("claude", "old", "create", "old", Some("owner"))); + assert!(!leases.rekey_binding("claude", "old", "new", "old", "new", "stale")); + assert_eq!( + leases.claim("claude", "old", "observe", 1), + FreshSessionClaim::BoundLive { + live_session_key: "old".into() + } + ); + assert!(leases.rekey_binding("claude", "old", "new", "old", "new", "owner")); + assert_eq!( + leases.claim("claude", "new", "observe", 2), + FreshSessionClaim::BoundLive { + live_session_key: "new".into() + } + ); + assert_eq!( + leases.claim("claude", "old", "reuse", 2), + FreshSessionClaim::Acquired + ); + assert!(leases.clear_binding_if_matches("claude", "new", "new", "owner")); + assert_eq!( + leases.claim("claude", "new", "resume", 3), + FreshSessionClaim::Acquired + ); + } + + #[test] + fn conversation_reset_does_not_overwrite_another_launch() { + let leases = FreshAgentSessionLeases::new(); + leases.claim("claude", "old", "create", 0); + assert!(leases.complete_with_identity("claude", "old", "create", "old", Some("owner"))); + leases.claim("claude", "new", "other", 0); + assert!(!leases.rekey_binding("claude", "old", "new", "old", "new", "owner")); + assert_eq!( + leases.claim("claude", "old", "observe", 1), + FreshSessionClaim::BoundLive { + live_session_key: "old".into() + } + ); + assert!(matches!( + leases.claim("claude", "new", "observe", 1), + FreshSessionClaim::Held { .. } + )); + } + #[test] fn clear_binding_reopens_after_the_bound_session_exits() { let leases = FreshAgentSessionLeases::new(); diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index e0d049a24..721a3aba1 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -212,7 +212,7 @@ async fn get_snapshot( { if let Some(gateway) = state.opencode.hosted_rest_gateway() { match gateway - .snapshot(crate::hosted_rest::HostedRestSnapshot { + .snapshot(crate::rest_lane::RestLaneSnapshot { session_id: thread_id.clone(), provider: provider.clone(), session_type: session_type.clone(), @@ -221,13 +221,13 @@ async fn get_snapshot( { Ok(Some(snapshot)) => return Json(snapshot).into_response(), Ok(None) => {} - Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable) => { + Err(crate::rest_lane::RestLaneSnapshotError::ManagedUnavailable) => { return fail( StatusCode::SERVICE_UNAVAILABLE, "Managed conversation snapshot unavailable".into(), ); } - Err(crate::hosted_rest::HostedRestSnapshotError::OwnershipUnavailable) => { + Err(crate::rest_lane::RestLaneSnapshotError::OwnershipUnavailable) => { return state .saved_history_or_unavailable(&session_type, &provider, &thread_id) .await @@ -504,26 +504,25 @@ mod tests { struct SnapshotGateway; #[async_trait::async_trait] - impl crate::hosted_rest::HostedFreshAgentRestGateway for SnapshotGateway { + impl crate::rest_lane::FreshAgentRestLane for SnapshotGateway { async fn create_agent( self: Arc, - _: crate::hosted_rest::HostedRestCreate, - ) -> Result { + _: crate::rest_lane::RestLaneCreate, + ) -> Result { panic!("GET must not create") } async fn send_agent( &self, - _: crate::hosted_rest::HostedRestSend, - ) -> Result { + _: crate::rest_lane::RestLaneSend, + ) -> Result { panic!("GET must not send") } async fn snapshot( &self, - request: crate::hosted_rest::HostedRestSnapshot, - ) -> Result, crate::hosted_rest::HostedRestSnapshotError> - { + request: crate::rest_lane::RestLaneSnapshot, + ) -> Result, crate::rest_lane::RestLaneSnapshotError> { if request.session_id == "unavailable-host" { - return Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable); + return Err(crate::rest_lane::RestLaneSnapshotError::ManagedUnavailable); } Ok(Some( json!({"threadId":request.session_id,"status":"running", @@ -563,6 +562,27 @@ mod tests { } } + #[tokio::test] + async fn local_rest_lane_does_not_replace_the_claude_snapshot_reader() { + let state = snapshot_state(); + state + .opencode + .set_claude_rest_lane(Arc::new(SnapshotGateway)) + .unwrap(); + let response = get_snapshot( + State(state), + Path(( + "freshclaude".into(), + "claude".into(), + "absent-local-rest-snapshot".into(), + )), + Query(HashMap::new()), + headers_with_token("tok"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn missing_auth_header_is_401() { let resp = get_snapshot( @@ -837,7 +857,7 @@ mod tests { /// `pendingQuestions` populated with the exact `.strict()` contract entry keys /// (object equality pins the KEY SET: no extras, no missing) and the /// presence-of-pending gates flipped (`normalize.ts:186-204, 226-232`). Addressed by - /// the DURABLE UUID (the `cli_index` alias), the id a live pane's GET carries. + /// the native UUID, the id a live pane's GET carries. #[tokio::test] async fn claude_locator_overlays_live_pending_and_flips_capability_gates() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -846,7 +866,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-9", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-1", @@ -903,7 +923,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-suggest", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-npm", @@ -970,7 +990,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-12", + durable, Some(durable), &[ json!({ "type": "sdk.question.request", "sessionId": "s", "requestId": "q-prev", @@ -1056,7 +1076,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-10", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-7", @@ -1187,7 +1207,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-11", + durable, Some(durable), &[], ) diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index d5940ee4d..5c8e25ede 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -3,12 +3,11 @@ //! Opt-in external gateway from the retained fresh-agent wire to the //! supervisor/session-host actor. This module owns no provider process. -use freshell_freshagent::hosted_rest::{ - HostedFreshAgentRestGateway, HostedRestCapture, HostedRestCaptureError, - HostedRestCaptureResult, HostedRestCreate, HostedRestCreated, HostedRestSend, - HostedRestSendResult, -}; use freshell_freshagent::naming::{admit_pending_projection, session_projection, SessionNaming}; +use freshell_freshagent::rest_lane::{ + FreshAgentRestLane, RestLaneCapture, RestLaneCaptureError, RestLaneCaptureResult, + RestLaneCreate, RestLaneCreated, RestLaneSend, RestLaneSendResult, +}; use freshell_protocol::session_names::NamedProvider; use freshell_protocol::{ AgentProvider, FreshAgentCreateFailed, FreshAgentCreated, FreshAgentEvent, FreshAgentForked, @@ -492,7 +491,7 @@ impl HostedFreshAgentProxy { async fn create( self: &Arc, message: freshell_protocol::FreshAgentCreate, - ) -> Result<(), ()> { + ) -> Result, ()> { let provider = fresh_provider(&message.provider, message.session_type).ok_or(())?; let public_provider = fresh_provider_wire(&provider); if let Some(session_ref) = message.session_ref.as_ref() { @@ -555,7 +554,7 @@ impl HostedFreshAgentProxy { ) .await; } - return Ok(()); + return Ok(None); } } let cwd = canonical_cwd(message.cwd.as_deref()).map_err(|_| ())?; @@ -823,6 +822,7 @@ impl HostedFreshAgentProxy { "Retain provider inputs until dispatch outcome is confirmed"); })? }; + let cleanup_receipt = (soul.clone(), launched.view.intent_revision); let naming_projection = match message.naming_handle.as_deref() { Some(handle) if !handle.trim().is_empty() => { admit_pending_projection( @@ -864,7 +864,7 @@ impl HostedFreshAgentProxy { session_type, ) .await; - Ok(()) + Ok(Some(cleanup_receipt)) } async fn resolve_decision( diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 01368a2ab..31f86d8b9 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -3,13 +3,13 @@ use super::*; #[async_trait::async_trait] -impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { +impl FreshAgentRestLane for HostedFreshAgentProxy { async fn snapshot( &self, - request: freshell_freshagent::hosted_rest::HostedRestSnapshot, - ) -> Result, freshell_freshagent::hosted_rest::HostedRestSnapshotError> + request: freshell_freshagent::rest_lane::RestLaneSnapshot, + ) -> Result, freshell_freshagent::rest_lane::RestLaneSnapshotError> { - use freshell_freshagent::hosted_rest::HostedRestSnapshotError::{ + use freshell_freshagent::rest_lane::RestLaneSnapshotError::{ ManagedUnavailable, OwnershipUnavailable, }; let (provider, session_type) = @@ -123,13 +123,13 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { } async fn create_agent( self: Arc, - request: HostedRestCreate, - ) -> Result { + request: RestLaneCreate, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { - pane_id: None, + pane_id: request.pane_id, request_id: request.request_id, session_type, cwd: request.cwd, @@ -137,8 +137,8 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { legacy_restore_context: None, model: request.model, model_selection: request.model_selection, - observed_epoch: None, - observed_generation: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, permission_mode: request.permission_mode, plugins: request.plugins, provider: Some(provider), @@ -148,8 +148,8 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { provider: provider_wire(&provider), session_id, }), - tab_id: None, - naming_handle: None, + tab_id: request.tab_id, + naming_handle: request.naming_handle, }; let session_id = presentation_id_for_create( &runtime_provider, @@ -159,11 +159,66 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { .as_ref() .map(|value| value.session_id.as_str()), ); - self.create(message).await?; - Ok(HostedRestCreated { session_id }) + let cleanup_token = self + .create(message) + .await? + .map(|receipt| serde_json::to_string(&receipt).map_err(|_| ())) + .transpose()?; + Ok(RestLaneCreated { + session_id, + cleanup_token, + ..Default::default() + }) } - async fn send_agent(&self, request: HostedRestSend) -> Result { + async fn abandon_create( + &self, + _: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), freshell_freshagent::rest_lane::RestLaneError> { + use freshell_freshagent::rest_lane::{RestLaneError, RestLaneErrorKind}; + let Some(token) = created.cleanup_token.as_deref() else { + return Ok(()); + }; + let (soul, revision): (SoulId, u64) = serde_json::from_str(token).map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::Internal, + "INVALID_CREATE_CLEANUP_TOKEN", + "invalid hosted create cleanup receipt", + ) + })?; + let stopped = self + .client + .stop_expected(soul.clone(), Some(revision)) + .await; + match stopped { + Ok((StopOutcome::VerifiedEmpty, _)) => Ok(()), + Err(error) + if matches!( + error.runtime_code(), + Some(RuntimeErrorCode::StaleIntentRevision | RuntimeErrorCode::UnknownSoul) + ) => + { + Ok(()) + } + result => { + tracing::warn!(event = "fresh_agent.abandoned_create_cleanup_failed", soul_id = %soul, expected_intent_revision = revision, result = ?result); + let mut error = RestLaneError::new( + RestLaneErrorKind::Unavailable, + "CREATE_CLEANUP_UNCONFIRMED", + "the abandoned hosted runtime could not be confirmed stopped", + ); + error.retryable = Some(true); + error.retry_after_ms = Some(250); + Err(error) + } + } + } + + async fn send_agent( + &self, + request: RestLaneSend, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let soul = self @@ -187,32 +242,33 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { Duration::from_millis(request.timeout_ms), ) .await; - Ok(HostedRestSendResult { + Ok(RestLaneSendResult { session_id: request.session_id, completed, + ..Default::default() }) } async fn capture( &self, - request: HostedRestCapture, - ) -> Result { + request: RestLaneCapture, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type) - .map_err(|_| HostedRestCaptureError::Unavailable)?; + .map_err(|_| RestLaneCaptureError::Unavailable)?; let soul = self .resolve_soul(&provider, session_type, &request.session_id) .await - .ok_or(HostedRestCaptureError::Unavailable)?; + .ok_or(RestLaneCaptureError::Unavailable)?; let capture = self .client .fresh_agent_capture(soul, request.max_bytes.min(256 * 1024) as u32) .await .map_err(|error| match error.runtime_code() { - Some(RuntimeErrorCode::UnsupportedOperation) => HostedRestCaptureError::Unsupported, - _ => HostedRestCaptureError::Unavailable, + Some(RuntimeErrorCode::UnsupportedOperation) => RestLaneCaptureError::Unsupported, + _ => RestLaneCaptureError::Unavailable, })?; - Ok(HostedRestCaptureResult { + Ok(RestLaneCaptureResult { session_id: capture.presentation_session_id, native_session_id: capture.native_session_id, text: capture.text, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 6c19b68c6..98c172b6e 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -176,6 +176,98 @@ fn configure_command(session_id: &str) -> HostedFreshAgentCommand { ) } +async fn abandon_hosted_create(stale_intent: bool) { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let owned = SoulId::parse("newly-created-hosted-soul").unwrap(); + let expected = owned.clone(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 77, + installation_id: InstallationId::new(), + }), + AdminCommand::Stop(stop) => { + assert_eq!(stop.soul_id, expected); + assert_eq!(stop.expected_intent_revision, Some(9)); + if stale_intent { + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::StaleIntentRevision, + "another intent now owns this soul", + )) + } else { + Ok(AdminResult::Stop { outcome: StopOutcome::VerifiedEmpty, view: serde_json::from_value(serde_json::json!({ + "soulId":expected, "incarnationId":"created-incarnation", "launchState":"stopped", "cleanupState":"none", + "intentRevision":9, "executionGeneration":1, + })).unwrap() }) + } + } + other => { + panic!("abandon must use its captured receipt without re-resolving: {other:?}") + } + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result, + }, + ) + .await + .unwrap(); + } + }); + let (proxy, _) = configure_proxy( + &socket, + HashMap::from([( + ("claude".into(), "public-created".into()), + SoulId::parse("replacement-alias-target").unwrap(), + )]), + ); + proxy + .abandon_create( + &RestLaneCreate::default(), + &RestLaneCreated { + session_id: "public-created".into(), + cleanup_token: Some(serde_json::to_string(&(owned, 9_u64)).unwrap()), + ..Default::default() + }, + ) + .await + .unwrap(); + settled(server).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_stops_only_the_captured_created_soul_and_intent() { + abandon_hosted_create(false).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_does_not_stop_a_replacement_intent() { + abandon_hosted_create(true).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_preserves_an_adopted_existing_writer_without_a_host_call() { + let dir = tempfile::tempdir().unwrap(); + let (proxy, _) = configure_proxy(&dir.path().join("no-supervisor.sock"), HashMap::new()); + proxy + .abandon_create( + &RestLaneCreate::default(), + &RestLaneCreated { + session_id: "adopted-writer".into(), + ..Default::default() + }, + ) + .await + .unwrap(); +} + /// Answers Health and then one configure the way `answer` decides, and /// returns the admin methods it saw. fn configure_supervisor( @@ -974,7 +1066,7 @@ async fn snapshot_route_value( #[tokio::test] async fn real_gateway_pre_native_opencode_projects_only_current_empty_owned_registration() { - use freshell_freshagent::hosted_rest::HostedRestSnapshot; + use freshell_freshagent::rest_lane::RestLaneSnapshot; for scenario in [ "empty", "nonempty", @@ -1031,7 +1123,7 @@ async fn real_gateway_pre_native_opencode_projects_only_current_empty_owned_regi }); let proxy = snapshot_outage_proxy(&socket); let result = proxy - .snapshot(HostedRestSnapshot { + .snapshot(RestLaneSnapshot { session_id: "managed-opencode-public".into(), provider: "opencode".into(), session_type: "freshopencode".into(), diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 759b09b28..6bbd19ba5 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -5,7 +5,7 @@ use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, RecordedGenesis, RollbackRecord, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, - SinkCommitWrite, SinkWrite, + SinkCommitWrite, SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; use freshell_ws::pane_ledger::{CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger}; use std::sync::Arc; @@ -409,6 +409,39 @@ impl PaneIdentitySink for LedgerIdentitySink { }) } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.mark_fresh_agent_turn_interrupted(&provider, &session_id, at_ms) + }) + .await + .map_err(std::io::Error::other)? + }) + } + + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.take_fresh_agent_turn_interrupted(&provider, &session_id) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn load_settings(&self, provider: &str, session_id: &str) -> Option { // Reads are memory-only against the write-through index — safe inline. let row = self.ledger.load_binding(provider, session_id)?; @@ -835,6 +868,85 @@ mod tests { assert_eq!(row.pane_kind.as_deref(), Some("fresh-agent")); } + #[tokio::test(flavor = "multi_thread")] + async fn fresh_agent_restart_marker_survives_reload_and_is_consumed_once() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))); + let sink = LedgerIdentitySink::new(ledger.clone(), None); + assert!(!sink + .mark_turn_interrupted("claude", "missing", 1_000) + .await + .expect("marking a missing row is a no-op")); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "claude".into(), + session_id: "session-restart".into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: FreshAgentSettings::default(), + }) + .await + .expect("binding write succeeds"); + let updated_at = ledger + .load_binding("claude", "session-restart") + .expect("binding exists") + .updated_at; + assert!(sink + .mark_turn_interrupted("claude", "session-restart", 2_000) + .await + .expect("marker write succeeds")); + + let restarted = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + let marked = restarted + .ledger + .load_binding("claude", "session-restart") + .expect("binding survives reload"); + assert_eq!(marked.turn_interrupted_at, Some(2_000)); + assert_eq!(marked.updated_at, updated_at); + assert_eq!( + restarted + .take_turn_interrupted("claude", "session-restart") + .await + .expect("consume succeeds"), + Some(2_000) + ); + assert_eq!( + restarted + .take_turn_interrupted("claude", "session-restart") + .await + .expect("second consume succeeds"), + None + ); + let after_consume = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + assert_eq!( + after_consume + .ledger + .load_binding("claude", "session-restart") + .expect("binding remains") + .turn_interrupted_at, + None + ); + } + /// G15: the claude pane's plugins are part of the resume record — written /// with the binding and served back by `load_settings`, also after a /// restart, so a resume can hand them to the sidecar again. diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index c084bafbf..97db3e9bb 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1151,6 +1151,13 @@ async fn main() -> ExitCode { fresh_agent_state .set_checkpoint_event_persister(Arc::clone(&checkpoint_event_persister)) .expect("OpenCode checkpoint event persister is installed once"); + let rest_create_settings = settings_store.shared_settings_lock(); + fresh_agent_state + .set_rest_create_settings_reader(Arc::new(move || { + let settings = Arc::clone(&rest_create_settings); + Box::pin(async move { settings.read().await.fresh_agent.clone() }) + })) + .expect("REST create settings reader is installed once"); // The freshopencode WS fresh-agent slice: the post-handshake loop dispatches // `freshAgent.create`/`send`/`kill`/`interrupt` (opencode) here. let mut fresh_opencode_state = @@ -2082,6 +2089,15 @@ async fn main() -> ExitCode { fresh_opencode_state.set_identity_sink(fresh_agent_identity_sink.clone()); // opencode REST surface (Task 7's materialization site; V10 A13-N1) fresh_agent_state.set_identity_sink(fresh_agent_identity_sink.clone()); + let rest_enabled = fresh_codex_state.clone(); + fresh_agent_state + .set_claude_rest_lane(Arc::new( + freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + fresh_claude_state.clone(), + Arc::new(move || rest_enabled.is_enabled()), + ), + )) + .expect("local Claude REST lane is installed once"); // Lane D1: the crash-event channel for terminal auto-resume. The receiver // is consumed by `auto_resume::spawn_auto_resume_hub`, spawned right // after `ws_state` is assembled (the hub needs the full state). diff --git a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs index e168dd342..0a959873b 100644 --- a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs +++ b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs @@ -1,6 +1,6 @@ //! Actual initial controller callers with synthetic authenticated control transport. use super::*; -use freshell_freshagent::hosted_rest::{HostedFreshAgentRestGateway, HostedRestCreate}; +use freshell_freshagent::rest_lane::{FreshAgentRestLane, RestLaneCreate}; use freshell_runtime_protocol::*; use std::{collections::BTreeMap, os::unix::fs::PermissionsExt}; use tokio::net::UnixListener; @@ -364,7 +364,7 @@ mod uncertain_launch { .unwrap() .unwrap(); fresh - .create_agent(HostedRestCreate { + .create_agent(RestLaneCreate { request_id: seed.clone(), provider: if provider == "kilroy" { "claude" @@ -385,6 +385,7 @@ mod uncertain_launch { model_selection: None, permission_mode: None, sandbox: None, + ..Default::default() }) .await .map(|_| ()) @@ -714,7 +715,7 @@ fn managed_provider_initial_opencode_callers_replan_changed_selection_with_two_r let prior_stage=control.join("provider-roots").join(&prior_grant); let prior_manifest=std::fs::read(prior_stage.join(".freshell/input-manifest.json")).unwrap(); let result=if fresh_route { - fresh.clone().create_agent(HostedRestCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) + fresh.clone().create_agent(RestLaneCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None,..Default::default()}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) } else { controller.launch(ManagedTerminalLaunch {spec:freshell_platform::SpawnSpec {program:"opencode".into(),args:vec![],env_overrides:BTreeMap::new(),cwd:Some(workspace.to_string_lossy().into_owned()),cols:80,rows:24},env:BTreeMap::new(),terminal_id:seed.clone(),stream_id:seed.clone(),mode:"opencode".into(),resume_session_id:None,provider_model:None,provider_reasoning_effort:None,provider_sandbox:None,provider_permission_mode:None,view_tab_id:None,view_pane_id:None,create_request_id:Some(seed.clone())}).await.map(|_|()).map_err(|error| error.to_string()) }; diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index deefc88ad..3ef8b1f41 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -135,6 +135,7 @@ fn binding_row_at( // Delta-r4 Finding 1: unattributed fixture rows are legacy-shaped // (no attribution-time key); `with_attribution` stamps one. last_attributed_at: None, + turn_interrupted_at: None, plugins: None, } } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index ae9280269..342cc1e98 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -10,6 +10,7 @@ use freshell_agent_runtime::host_actor::{ FreshAgentProfile, FreshAgentTransport, OperationAck, OperationFailure, OperationFailureKind, TransportStart, }; +use freshell_agent_runtime::snapshot_text::render_snapshot_text; use freshell_codex::launch_plan::CodexSidecarLaunchContext; use freshell_freshagent::rollback_record::{RollbackDirection, RollbackModeReq, RollbackRequest}; use freshell_freshagent::{FreshAgentState, FreshClaudeState, FreshCodexState, FreshOpencodeState}; @@ -162,13 +163,15 @@ impl HostedTransport { async fn with_claude_sidecar_environment( provider: FreshProvider, environment: Vec<(String, String)>, + context: Option<&ProviderLaunchContext>, ) -> Arc { - let (broadcast_tx, broadcast_rx) = broadcast::channel(1024); - let broadcast_tx = Arc::new(broadcast_tx); - let state = ProviderState::Claude( - FreshClaudeState::new(Arc::clone(&broadcast_tx)).with_sidecar_environment(environment), - ); - Self::from_parts(provider, state, broadcast_tx, broadcast_rx) + let mut transport = Self::new_with_context(provider, context).await; + let hosted = Arc::get_mut(&mut transport).expect("new transport has one owner"); + let ProviderState::Claude(state) = &mut hosted.state else { + panic!("the fixture requires a Claude runtime"); + }; + *state = state.clone().with_sidecar_environment(environment); + transport } async fn new_with_context( @@ -1464,55 +1467,6 @@ fn find_operation_session_id(value: &Value) -> Option { } } -fn render_snapshot_text(snapshot: &Value, max_bytes: usize) -> (String, bool) { - let mut output = String::new(); - if let Some(turns) = snapshot.get("turns").and_then(Value::as_array) { - for turn in turns { - let role = turn - .get("role") - .and_then(Value::as_str) - .unwrap_or("assistant"); - let mut body = Vec::new(); - if let Some(items) = turn.get("items").and_then(Value::as_array) { - for item in items { - if let Some(text) = item.get("text").and_then(Value::as_str) { - body.push(text); - } else if let Some(command) = item.get("command").and_then(Value::as_str) { - body.push(command); - } else if let Some(result) = item.get("output").and_then(Value::as_str) { - body.push(result); - } - } - } - if body.is_empty() { - // An item-less turn carrying only the durable error module - // (a claude API error or failed local command) reads as its - // error; anything else falls back to the summary. - if let Some(message) = turn.pointer("/error/message").and_then(Value::as_str) { - body.push(message); - } else if let Some(summary) = turn.get("summary").and_then(Value::as_str) { - body.push(summary); - } - } - if !body.is_empty() { - output.push_str(role); - output.push_str(": "); - output.push_str(&body.join("\n")); - output.push('\n'); - } - } - } - if output.len() <= max_bytes { - return (output, false); - } - let mut boundary = max_bytes; - while boundary > 0 && !output.is_char_boundary(boundary) { - boundary -= 1; - } - output.truncate(boundary); - (output, true) -} - fn parse_send_outcome(value: &Value) -> Option<(String, bool)> { let event_type = value.get("type")?.as_str()?; let request_id = value.get("requestId").and_then(|value| match value { @@ -1585,36 +1539,6 @@ mod tests { #[tokio::test] async fn managed_provider_fresh_claude_sends_only_fixed_hosted_mcp_selection() { let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("sidecar.mjs"); - let receipt = dir.path().join("create.json"); - std::fs::write(&script, r#" -import {createInterface} from 'node:readline'; -import {writeFileSync} from 'node:fs'; -const lines=createInterface({input:process.stdin}); -lines.on('line', line=>{ - const req=JSON.parse(line); - if(req.type==='create'){ - writeFileSync(process.env.FRESHELL_TEST_MCP_RECEIPT,JSON.stringify(req)); - process.stdout.write(JSON.stringify({type:'created',requestId:req.requestId,sessionId:'owned-mcp-fixture',protocol:2})+'\n'); - if(req.model!=='deferred-identity')process.stdout.write(JSON.stringify({type:'sdk.session.init',sessionId:'owned-mcp-fixture',cliSessionId:req.boot?.resume??'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',model:'fixture',cwd:req.cwd,tools:[],slashCommands:[]})+'\n'); - } - if(req.type==='shutdown')process.exit(0); -}); -"#).unwrap(); - let keys = [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FRESHELL_TEST_MCP_RECEIPT", - ]; - let _env = SnapshotProviderEnv( - keys.iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - ); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_MCP_RECEIPT", &receipt); let context = ProviderLaunchContext { preparation: ProviderPreparation::Claude { mcp_args: vec!["--mcp-config".into(), ".claude/freshell-mcp.json".into()], @@ -1629,60 +1553,54 @@ lines.on('line', line=>{ (true, true, None), (true, false, Some("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")), ] { - let transport = HostedTransport::new_with_context( - FreshProvider::Claude, + let conversations: Vec<&str> = expected_identity.into_iter().collect(); + let claude = SimulatedClaude::start_with( + &conversations, selected.then_some(&context), + vec![("FRESHELL_HOSTED_FRESH_AGENT".into(), "claude".into())], ) .await; - let profile = FreshAgentProfile { - provider: FreshProvider::Claude, - runtime_variant: "freshclaude".into(), - cwd: dir.path().to_string_lossy().into(), - model: deferred_identity.then(|| "deferred-identity".into()), - effort: None, - permission_mode: None, - sandbox: None, - provider_store_id: "owned-store".into(), - native_session_id: expected_identity.map(str::to_owned), - plugins: None, - model_selection: None, - session_ref: None, - provider_launch_context: selected.then_some(context.clone()), - provider_secret_references: vec![], - }; + let mut profile = claude.profile(expected_identity); + profile.model = deferred_identity.then(|| "deferred-identity".into()); + profile.provider_launch_context = selected.then_some(context.clone()); let started = - tokio::time::timeout(Duration::from_secs(1), transport.start(&profile)).await; - let observed: Value = - serde_json::from_slice(&std::fs::read(&receipt).unwrap()).unwrap(); - transport.stop().await.unwrap(); - let started = started - .expect("Claude create cannot wait for the first model turn") - .unwrap(); + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("Claude create cannot wait for the first model turn") + .unwrap(); + let queries = claude.ledger_rows("query"); + assert_eq!(queries.len(), 1, "one actual SDK query boots the runtime"); + let observed = &queries[0]["options"]; if let Some(expected) = expected_identity { - // A resume is named by `created` (the sidecar answers it once the - // CLI booted that conversation), not by a later turn's init. assert_eq!(started.native_session_id.as_deref(), Some(expected)); - assert_eq!( - observed["boot"], - json!({ "kind": "resume", "resume": expected }), - "the create boots the resumed conversation" - ); + assert_eq!(observed["resume"], expected); } if deferred_identity { assert_eq!( started.native_session_id.as_deref(), - observed["sessionId"].as_str(), - "fresh native identity is known before any prompt or init" + observed["sessionId"].as_str() ); } - assert_eq!( - observed["hostedMcp"], selected, - "only accepted hosted selection enables the SDK recipe" - ); assert!( - observed.get("mcpServers").is_none(), - "arbitrary MCP maps are never sent over private create IPC" + claude.ledger_rows("prompt").is_empty(), + "identity never needs a prompt" ); + if selected { + assert_eq!( + observed["mcpServers"], + json!({ + "freshell": {"command":"node", "args":["/opt/freshell-mcp/server.js"]} + }), + "accepted hosted selection reaches the real SDK as the fixed recipe" + ); + assert!(observed["mcpServers"]["freshell"].get("env").is_none()); + } else { + assert!( + observed.get("mcpServers").is_none(), + "unselected context enables no recipe" + ); + } + Arc::clone(&claude.transport).stop().await.unwrap(); } } @@ -1697,6 +1615,14 @@ lines.on('line', line=>{ impl SimulatedClaude { async fn start(conversations: &[&str]) -> Self { + Self::start_with(conversations, None, vec![]).await + } + + async fn start_with( + conversations: &[&str], + context: Option<&ProviderLaunchContext>, + extra_environment: Vec<(String, String)>, + ) -> Self { let root = tempfile::tempdir().unwrap(); let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let home = root.path().join("home"); @@ -1712,8 +1638,20 @@ lines.on('line', line=>{ } let ledger = root.path().join("sim-ledger.jsonl"); let text = |path: &std::path::Path| path.to_string_lossy().into_owned(); - let environment = vec![ + let tmp = root.path().join("tmp"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(root.path().join("active"), "").unwrap(); + let mut environment = vec![ ("HOME".to_string(), text(&home)), + ("TMPDIR".into(), text(&tmp)), + ( + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), + text(&root.path().join("active")), + ), + ( + "FRESHELL_TEST_CLAUDE_RESPOND_LOG".into(), + text(&root.path().join("requests.jsonl")), + ), ( "PATH".to_string(), std::env::var("PATH").unwrap_or_default(), @@ -1722,13 +1660,17 @@ lines.on('line', line=>{ ("CLAUDE_CONFIG_DIR".to_string(), text(&config)), ( "FRESHELL_CLAUDE_SDK_QUERY_MODULE".to_string(), - text(&repo.join("test/fixtures/claude-cli-sim/query.mjs")), + freshell_platform::opencode_plugin::plugin_file_spec( + &repo.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + ), ), ("FRESHELL_CLAUDE_SIM_LEDGER".to_string(), text(&ledger)), ]; + environment.extend(extra_environment); let transport = HostedTransport::with_claude_sidecar_environment( FreshProvider::Claude, environment, + context, ) .await; Self { @@ -2063,7 +2005,7 @@ lines.on('line', line=>{ } /// What one hosted Claude configure produced: the actor's answer, the - /// durable profile afterwards, every configure line the scripted sidecar + /// durable profile afterwards, every configure line the production sidecar /// received, the provider event named `settled_on` once it reached the /// host journal, and the whole journal at that point. struct HostedClaudeConfigure { @@ -2078,56 +2020,35 @@ lines.on('line', line=>{ } /// Runs one configure through the real `FreshClaudeState` behind a hosted - /// actor whose profile starts on sonnet/low. The scripted sidecar answers - /// the configure line with `receipt`, a JavaScript expression over `req`. + /// actor whose profile starts on sonnet/low. The real sidecar applies SDK + /// settings; the refusal case injects an error at the SDK setModel boundary. async fn hosted_claude_configure( settings: freshell_runtime_protocol::FreshAgentTurnSettings, - receipt: &str, + reject_model: bool, settled_on: &str, ) -> HostedClaudeConfigure { let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("sidecar.mjs"); - let receipt_log = dir.path().join("configure.jsonl"); - std::fs::write( - &script, - format!( - r#" -import {{createInterface}} from 'node:readline'; -import {{appendFileSync}} from 'node:fs'; -const lines=createInterface({{input:process.stdin}}); -const emit=frame=>process.stdout.write(JSON.stringify(frame)+'\n'); -lines.on('line', line=>{{ - const req=JSON.parse(line); - if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:req.sessionId??'owned-configure',protocol:2}}); - if(req.type==='configure'){{ - appendFileSync(process.env.FRESHELL_TEST_CONFIGURE_RECEIPT,JSON.stringify(req)+'\n'); - emit({receipt}); - }} - if(req.type==='shutdown')process.exit(0); -}}); -"# - ), + let claude = SimulatedClaude::start_with( + &[], + None, + if reject_model { + vec![("FRESHELL_TEST_CLAUDE_REJECT_MODEL".into(), "opus".into())] + } else { + vec![] + }, ) - .unwrap(); - let keys = [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FRESHELL_TEST_CONFIGURE_RECEIPT", - ]; - let _env = SnapshotProviderEnv( - keys.iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - ); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CONFIGURE_RECEIPT", &receipt_log); - let transport = HostedTransport::new(FreshProvider::Claude).await; + .await; + let transport = claude.transport.clone(); let profile = FreshAgentProfile { provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), - cwd: dir.path().to_string_lossy().into(), + cwd: claude + .root + .path() + .join("workspace") + .to_string_lossy() + .into(), model: Some("sonnet".into()), effort: Some("low".into()), permission_mode: Some("default".into()), @@ -2171,10 +2092,11 @@ lines.on('line', line=>{{ .into_iter() .map(|entry| entry.event) .collect(); - let applied = std::fs::read_to_string(&receipt_log) + let applied = std::fs::read_to_string(claude.root.path().join("requests.jsonl")) .unwrap_or_default() .lines() - .map(|line| serde_json::from_str(line).unwrap()) + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|request| request["type"] == "configure") .collect(); Arc::clone(&transport).stop().await.unwrap(); HostedClaudeConfigure { @@ -2186,9 +2108,6 @@ lines.on('line', line=>{{ } } - const CLAUDE_CONFIGURED_OK: &str = - "{type:'sdk.configured',sessionId:req.sessionId,requestId:req.requestId,ok:true,settings:req.settings}"; - #[tokio::test] async fn hosted_claude_configure_applies_live_and_relays_the_session_metadata_frame() { let run = hosted_claude_configure( @@ -2197,7 +2116,7 @@ lines.on('line', line=>{{ effort: Some("high".into()), ..Default::default() }, - CLAUDE_CONFIGURED_OK, + false, "freshAgent.session.metadata", ) .await; @@ -2236,7 +2155,7 @@ lines.on('line', line=>{{ model: Some("opus".into()), ..Default::default() }, - CLAUDE_CONFIGURED_OK, + false, "freshAgent.session.metadata", ) .await; @@ -2273,7 +2192,7 @@ lines.on('line', line=>{{ effort: Some("high".into()), ..Default::default() }, - "{type:'sdk.configured',sessionId:req.sessionId,requestId:req.requestId,ok:false,message:'Model or effort is unavailable'}", + true, "freshAgent.error", ) .await; @@ -4017,39 +3936,4 @@ lines.on('line', line=>{{ Some("new-native".into()) ); } - - #[test] - fn snapshot_text_renderer_is_plain_and_utf8_bounded() { - let snapshot = json!({"turns":[ - {"role":"user","summary":"ignored","items":[{"kind":"text","text":"héllo"}]}, - {"role":"assistant","summary":"world","items":[]} - ]}); - let (full, truncated) = render_snapshot_text(&snapshot, 100); - assert_eq!(full, "user: héllo\nassistant: world\n"); - assert!(!truncated); - let (short, truncated) = render_snapshot_text(&snapshot, 8); - assert!(short.len() <= 8); - assert!(short.is_char_boundary(short.len())); - assert!(truncated); - } - - #[test] - fn snapshot_text_renders_error_only_turns_by_their_error_message() { - // Real claude snapshots (pinned CLI 2.1.237 goldens): a failed /compact - // and an API error are item-less turns carrying only the error module. - for (golden, expected) in [ - ( - include_str!("../../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json"), - "system: Error during compaction: API Error: 400 compaction refused by fake\n", - ), - ( - include_str!("../../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json"), - "assistant: API Error: 400 fixture bad request\n", - ), - ] { - let snapshot: Value = serde_json::from_str(golden).unwrap(); - let (text, _) = render_snapshot_text(&snapshot, 1 << 20); - assert!(text.contains(expected), "{text}"); - } - } } diff --git a/crates/freshell-ws/Cargo.toml b/crates/freshell-ws/Cargo.toml index 2f8503ad1..d76159b58 100644 --- a/crates/freshell-ws/Cargo.toml +++ b/crates/freshell-ws/Cargo.toml @@ -107,6 +107,7 @@ sha2 = "0.10" libc = "0.2" [dev-dependencies] +async-trait = "0.1" # The claude rigs (tests/common/claude_rig.rs) give every sidecar their own # isolated environment through FreshClaudeState::with_sidecar_environment, a seam # that only exists with this feature (dev builds only; production never has it). diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 1f84fe4b7..63ab8e401 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -947,6 +947,12 @@ pub struct BindingRow { /// excludes such rows exactly like unattributed ones. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_attributed_at: Option, + /// A freshclaude turn that was still owed when the server shut down. + /// The next successful create/attach consumes this marker and reports the + /// interruption once. Optional under LEDGER_VERSION 1 so older rows keep + /// loading unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_interrupted_at: Option, } /// Evidence that identity establishment was in flight (G1: never a binding). @@ -2296,6 +2302,7 @@ impl PaneLedger { device_id, tab_key, last_attributed_at, + turn_interrupted_at: None, }; self.write_binding(root, index, &row)?; // new bound row FIRST (pinned) @@ -2679,12 +2686,28 @@ impl PaneLedger { } } let created_at = existing.map(|r| r.created_at).unwrap_or(w.now_ms); + // A superseding conversation keeps its pane's create lineage, so a + // replay after reset or rollback still finds the current session. + let superseded_parent = w + .supersedes + .filter(|old| *old != w.session_id) + .and_then(|old| { + index + .bindings + .get(&(w.provider.to_string(), old.to_string())) + .cloned() + }); // Advisory field: keep the existing row's value when the new write // has none (latest-observed semantics, D4). let create_request_id = w .create_request_id .map(str::to_string) - .or_else(|| existing.and_then(|r| r.create_request_id.clone())); + .or_else(|| existing.and_then(|r| r.create_request_id.clone())) + .or_else(|| { + superseded_parent + .as_ref() + .and_then(|r| r.create_request_id.clone()) + }); // Lineage (delta-r7-round-3, F1): the fresh-agent row's origin // mirrors its pane key — the same computed value — so the coverage // join against either key is identical to the pre-F1 crid arm on @@ -2738,15 +2761,6 @@ impl PaneLedger { // documented in the terminal body (focused-ep4-r5 Finding 2a); no // sequence counter is built for it. The retire/link below is // unchanged by the policy; this block is stamps+time only. - let superseded_parent = w - .supersedes - .filter(|old| *old != w.session_id) - .and_then(|old| { - index - .bindings - .get(&(w.provider.to_string(), old.to_string())) - .cloned() - }); let inherit = existing.or(superseded_parent.as_ref()); let prior_attribution = inherit.and_then(|r| r.last_attributed_at); let apply = match w.provenance { @@ -2815,6 +2829,9 @@ impl PaneLedger { permission_mode: w.permission_mode.map(str::to_string), effort: w.effort.map(str::to_string), plugins: w.plugins.map(<[String]>::to_vec), + // A rebind during recovery must not erase a shutdown marker + // before a create or attach can acknowledge the interruption. + turn_interrupted_at: existing.and_then(|row| row.turn_interrupted_at), // Claude core design §4.1: the genesis is not part of the // settings snapshot. Only the write that mints a pending session // sets it and only the transcript's observation clears it; every @@ -4630,6 +4647,63 @@ impl PaneLedger { Ok(true) } + /// Record that a live fresh-agent turn was interrupted by server shutdown. + /// This updates only the optional marker on an existing bound fresh-agent + /// row; it never creates or revives an identity row. `Ok(false)` means the + /// row was unavailable or no longer bound. + pub fn mark_fresh_agent_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> std::io::Result { + let Some(root) = &self.root else { + return Ok(false); + }; + let mut index = self.guard(); + let key = (provider.to_string(), session_id.to_string()); + let Some(mut row) = index.bindings.get(&key).cloned() else { + return Ok(false); + }; + if row.pane_kind.as_deref() != Some("fresh-agent") || row.state != RowState::Bound { + return Ok(false); + } + if row.turn_interrupted_at == Some(at_ms) { + return Ok(true); + } + row.turn_interrupted_at = Some(at_ms); + self.write_binding(root, &mut index, &row)?; + Ok(true) + } + + /// Atomically read and clear the restart marker on an existing bound + /// fresh-agent row. The marker is returned only after the cleared row is + /// durably written, so callers can emit one notice after their create or + /// attach acknowledgement without racing another acknowledgement. + pub fn take_fresh_agent_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> std::io::Result> { + let Some(root) = &self.root else { + return Ok(None); + }; + let mut index = self.guard(); + let key = (provider.to_string(), session_id.to_string()); + let Some(mut row) = index.bindings.get(&key).cloned() else { + return Ok(None); + }; + if row.pane_kind.as_deref() != Some("fresh-agent") || row.state != RowState::Bound { + return Ok(None); + } + let Some(at_ms) = row.turn_interrupted_at else { + return Ok(None); + }; + row.turn_interrupted_at = None; + self.write_binding(root, &mut index, &row)?; + Ok(Some(at_ms)) + } + /// Fresh-agent Task 3 (`was_recorded` rekeying): true iff a FRESH-AGENT /// binding row carrying a SETTINGS-BEARING snapshot exists for this key — /// at least one of model/sandbox/permission_mode/effort/cwd/plugins set (the exact diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index e3fbc4ef2..4db3edc69 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -4826,6 +4826,7 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { device_id: None, tab_key: None, last_attributed_at: None, + turn_interrupted_at: None, plugins: None, }; write_row_atomic( @@ -5220,6 +5221,42 @@ fn old_terminal_rows_without_settings_fields_still_deserialize() { let row: BindingRow = serde_json::from_str(json).expect("old row must parse"); assert_eq!(row.pane_kind, None); assert_eq!(row.model, None); + assert_eq!(row.turn_interrupted_at, None); +} + +#[test] +fn fresh_agent_restart_marker_survives_reload_and_is_consumed_atomically() { + let root = temp_root("turn-interrupted"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&fa_write("claude", "session-restart", 1_000)) + .expect("fresh-agent binding"); + assert!(ledger + .mark_fresh_agent_turn_interrupted("claude", "session-restart", 2_000) + .expect("mark turn interrupted")); + + let reloaded = PaneLedger::new(Some(root.clone())); + let marked = reloaded + .load_binding("claude", "session-restart") + .expect("binding after reload"); + assert_eq!(marked.turn_interrupted_at, Some(2_000)); + assert_eq!( + marked.updated_at, 1_000, + "the marker is not an identity rebind" + ); + assert_eq!( + reloaded + .take_fresh_agent_turn_interrupted("claude", "session-restart") + .expect("consume turn marker"), + Some(2_000) + ); + + let consumed = PaneLedger::new(Some(root.clone())) + .load_binding("claude", "session-restart") + .expect("binding after marker consumption reload"); + assert_eq!(consumed.turn_interrupted_at, None); + assert_eq!(consumed.updated_at, 1_000); + std::fs::remove_dir_all(&root).ok(); } #[test] @@ -8485,6 +8522,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { device_id: None, tab_key: None, last_attributed_at: None, + turn_interrupted_at: None, plugins: None, } } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index f959013ee..1410d3243 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -9734,8 +9734,16 @@ async fn handle_pane_closed(closed: &PaneClosed, state: &WsState, ws_tx: &mut Ws }) }; match result { - Ok(()) => send(ws_tx, &answer(true, None)).await, + Ok(()) => { + state + .layout + .release_pane_publication(&closed.create_request_id); + send(ws_tx, &answer(true, None)).await + } Err(err) if err.is_persisted() => { + state + .layout + .release_pane_publication(&closed.create_request_id); // The record IS durable despite the reported error — the close // evidence stands, so the answer is success; loud, structured, // never silent. diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index 60430885f..735cccf8e 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -9,7 +9,7 @@ //! restore:true and CLAUDE_ARGV capture -> argv contains ["--resume","B"]. //! Phase 3 (hijack): second live claude pane bound to "C"; drop a signal //! for pane1 claiming "C" -> refused; both panes' meta unchanged (A13). -//! Phase 4 (cross-kind, D7): a LIVE freshclaude sidecar owns "D" (fake +//! Phase 4 (cross-kind, D7): a LIVE freshclaude sidecar owns "D" (real //! sidecar, in-memory session map only -- no ledger row); a signal claiming //! "D" for a terminal pane -> refused (live-sidecar session-map probe). //! Phase 5 (multi-instance, P4): a signal naming a terminal id unknown to @@ -155,6 +155,7 @@ fn registry_resume_id( #[cfg(unix)] async fn spawn_server_returning_state( cli_commands: Vec, + claude_environment: Vec<(String, String)>, ) -> ( String, freshell_terminal::TerminalRegistry, @@ -196,7 +197,8 @@ async fn spawn_server_returning_state( }, fresh_claude: { let fresh_claude = - freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) + .with_sidecar_environment(claude_environment); fresh_claude.set_session_naming(sink.clone()); fresh_claude }, @@ -318,72 +320,6 @@ async fn send_create(ws: &mut common::TestWs, body: serde_json::Value) -> serde_ common::next_frame_of_type(ws, "terminal.created").await } -/// Minimal fake claude sidecar speaking the newline-JSON protocol: answers -/// `create` with `created` + `sdk.session.init` (echoing the resume boot's id as -/// the durable cliSessionId), exits on `shutdown`.#[cfg(unix)] -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#"import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - 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 === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script, with -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` pointed at it, PLUS an -/// empty claude store with `CLAUDE_CONFIG_DIR` pointed at it (so the test -/// never touches the real home). This file is `#[cfg(unix)]` and its single -/// test fn owns process env, so no env lock is needed. -#[cfg(unix)] -struct FakeClaudeEnv { - dir: std::path::PathBuf, -} -#[cfg(unix)] -impl FakeClaudeEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-rebind-{}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let store = dir.join("claude-store"); - std::fs::create_dir_all(&store).expect("create claude store dir"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -#[cfg(unix)] -impl Drop for FakeClaudeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - /// Unified agent names (phases 8-10): plant /// `/projects//.jsonl` — the exact direct-layout /// file `locate_transcript_selected` accepts as VERIFIED durability evidence @@ -437,8 +373,12 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { std::fs::create_dir_all(&signal_root).expect("signal root"); let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(signal_root.clone()); - let (url, registry, state, sink) = - spawn_server_returning_state(vec![claude_capture_spec()]).await; + let claude_environment = common::claude_sim_environment::ClaudeSimEnvironment::new(); + let (url, registry, state, sink) = spawn_server_returning_state( + vec![claude_capture_spec()], + claude_environment.environment(), + ) + .await; let (mut ws, _inventory) = common::connect_and_capture_inventory(&url).await; // ── Phase 1 -- mid-session rebind: fresh claude pane (identity A via the @@ -607,31 +547,32 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { .expect("terminalId") .to_string(); - let _fake_env = FakeClaudeEnv::install(); - let sidecar_sid = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; ws.send(WsMessage::Text( json!({ "type": "freshAgent.create", "requestId": "req-live-owner", "sessionType": "freshclaude", "provider": "claude", - "cwd": "/tmp", - "sessionRef": { "provider": "claude", "sessionId": sidecar_sid }, + "cwd": claude_environment.workspace(), }) .to_string(), )) .await .expect("send freshAgent.create"); - assert!( - frame_seen_within(&mut ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == "req-live-owner" - }) - .await, - "fake sidecar must come live" - ); - // The resume path inserts cli_index[S] synchronously before `created` - // (claude.rs:436), so the probe is authoritative now: - assert!(state.fresh_claude.has_live_session(sidecar_sid).await); + let created = common::next_frame_of_type(&mut ws, "freshAgent.created").await; + assert_eq!(created["requestId"], "req-live-owner", "{created}"); + let sidecar_sid = created["sessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&sidecar_sid).is_ok()); + assert_eq!(created["sessionRef"]["sessionId"], sidecar_sid); + assert!(state.fresh_claude.has_live_session(&sidecar_sid).await); + let boot = claude_environment.rows("boot").into_iter().next().unwrap(); + assert_eq!(boot["sessionId"], sidecar_sid); + assert_eq!(claude_environment.rows("query").len(), 1); + let sidecar_processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); // Forge a SessionStart signal from the phase-4 pane claiming the // sidecar-owned session. @@ -646,7 +587,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { // Refusal proof: the pane's identity did not move... assert_ne!( registry_resume_id(®istry, &tid4).as_deref(), - Some(sidecar_sid), + Some(sidecar_sid.as_str()), "a live-sidecar-owned session must never be claimed by a terminal pane" ); // ...no association frame was emitted for it... @@ -867,7 +808,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { // The CLI reports a NEW session (B2) whose transcript already exists on // disk — the signal lane's verified first-bind. The naming phases own - // CLAUDE_CONFIG_DIR from here (phase 4's fake sidecar is long done). + // CLAUDE_CONFIG_DIR from here; the sidecar keeps its isolated per-state store. let naming_store = tempfile::tempdir().expect("naming transcript store"); std::env::set_var("CLAUDE_CONFIG_DIR", naming_store.path()); let b2 = "88888888-9999-4aaa-8bbb-cccccccc0001"; @@ -1161,7 +1102,13 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { must re-stamp the persisted pre-durable handle" ); - state.fresh_claude.shutdown().await; // reap the fake node child + state.fresh_claude.shutdown().await; + for process in sidecar_processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "owned sidecar/CLI survived shutdown" + ); + } registry.kill(&tid2); registry.kill(&tid3); diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 5c7931dbb..e1a7bd14b 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -63,6 +63,11 @@ pub enum RigKind { /// local claude lane, no handoff route). #[derive(Debug, Clone, Default)] pub struct RigOptions { + /// Exercise the hosted kernel's in-memory lifecycle without a coordinator + /// or identity sink; native files and dead-runtime memory are authoritative. + pub unwired_runtime: bool, + /// REST create/split/send/capture over the shared Claude runtime and layout. + pub rest_api: bool, /// Extra sidecar environment (for example simulator knobs such as /// `FRESHELL_CLAUDE_SIM_PROGRAM`); a key here replaces the rig's own. pub env: Vec<(String, String)>, @@ -70,6 +75,9 @@ pub struct RigOptions { /// claude lane's identity sink (`common::ledger_sink`), the way main.rs wires /// it: sessions leave durable binding rows a later resume reads back. pub durable_ledger: bool, + /// Hold the next shutdown interruption-marker write until the lifecycle + /// test releases it, leaving the real sidecar alive during that window. + pub turn_interrupted_marker_gate: Option>, /// The claude lane runs the way the hosted session host runs it: the lane /// selects the hosted Freshell MCP (`with_hosted_mcp`) and its sidecars see /// `FRESHELL_HOSTED_FRESH_AGENT=claude`, so the sidecar offers the fixed MCP @@ -106,6 +114,9 @@ pub struct ClaudeRig { /// The rig server's claude lane, for the server-side truths a test checks /// (for example `has_live_session`). pub fresh_claude: freshell_freshagent::FreshClaudeState, + pub fresh_codex: freshell_freshagent::FreshCodexState, + /// The same coordinator the WS dispatcher and Claude lane use. + pub ownership: Arc, /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. _provider: Option, /// What the server was started with, for [`Self::restart`]. @@ -170,7 +181,7 @@ impl ClaudeRig { environment.extend([ ( "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), - path_string(&simulator), + freshell_platform::opencode_plugin::plugin_file_spec(&simulator), ), ("FRESHELL_CLAUDE_SIM_LEDGER".into(), path_string(&ledger)), ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "1".into()), @@ -228,6 +239,8 @@ impl ClaudeRig { url, http_url, fresh_claude, + fresh_codex, + ownership, server, } = spawn_rig_server(environment.clone(), &options, pane_ledger.clone()).await; eprintln!( @@ -246,6 +259,8 @@ impl ClaudeRig { claude_config_dir, ledger, fresh_claude, + fresh_codex, + ownership, _provider: provider, environment, options, @@ -271,6 +286,8 @@ impl ClaudeRig { url, http_url, fresh_claude, + fresh_codex, + ownership, server, } = spawn_rig_server(self.environment.clone(), &self.options, pane_ledger.clone()).await; eprintln!( @@ -280,6 +297,8 @@ impl ClaudeRig { self.url = url; self.http_url = http_url; self.fresh_claude = fresh_claude; + self.fresh_codex = fresh_codex; + self.ownership = ownership; self.pane_ledger = pane_ledger; self.server = server; } @@ -311,14 +330,22 @@ impl ClaudeRig { /// `POST ` on the rig's server with the auth token and a JSON body; the /// status and the JSON answer (`Null` when the body is not JSON). pub async fn post_json(&self, path: &str, body: &Value) -> (u16, Value) { + Self::request_json_to("POST", &self.http_url, path, body).await + } + + pub async fn request_json_to( + method: &str, + base_url: &str, + path: &str, + body: &Value, + ) -> (u16, Value) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let host = self - .http_url + let host = base_url .strip_prefix("http://") .expect("the rig serves http"); let payload = body.to_string(); let request = format!( - "POST {path} HTTP/1.1\r\nHost: {host}\r\nx-auth-token: {AUTH_TOKEN}\r\n\ + "{method} {path} HTTP/1.1\r\nHost: {host}\r\nx-auth-token: {AUTH_TOKEN}\r\n\ content-type: application/json\r\ncontent-length: {}\r\nConnection: close\r\n\r\n\ {payload}", payload.len(), @@ -614,6 +641,7 @@ impl ClaudeRig { /// The first sidecar this rig booted dies on its own (SIGKILL of the Node /// process): its session is untracked, the state a reload or a handoff /// resumes from. Returns once `conn` has read the exit. + #[cfg(unix)] pub async fn kill_first_sidecar(&self, conn: &mut RigConnection) { let pid = self.ledger_rows("boot")[0]["pid"] .as_i64() @@ -667,7 +695,18 @@ impl RigConnection { /// The first frame (already read or still to come) matching `predicate`. pub async fn wait_for(&mut self, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { - if let Some(hit) = self.frames.iter().find(|frame| predicate(frame)) { + self.wait_for_since(0, what, predicate).await + } + + /// A request acknowledgement must be newer than that request, even when + /// this connection previously observed the same session and operation. + pub async fn wait_for_since( + &mut self, + start: usize, + what: &str, + predicate: impl Fn(&Value) -> bool, + ) -> Value { + if let Some(hit) = self.frames[start..].iter().find(|frame| predicate(frame)) { return hit.clone(); } let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; @@ -696,6 +735,7 @@ impl RigConnection { /// `extra` fields merged in (a resume, say); returns the answer: /// `freshAgent.created`, `freshAgent.create.failed` or a request `error`. pub async fn request_create(&mut self, request_id: &str, extra: Value) -> Value { + let start = self.frames.len(); let cwd = path_string(&self.workspace); let mut frame = json!({ "type": "freshAgent.create", @@ -708,7 +748,7 @@ impl RigConnection { frame.extend(extra); } self.send(frame).await; - self.wait_for(&format!("the answer to create {request_id}"), |v| { + self.wait_for_since(start, &format!("the answer to create {request_id}"), |v| { v["requestId"] == request_id && matches!( v["type"].as_str(), @@ -840,6 +880,7 @@ impl RigConnection { /// The pane reattaches to the durable `session_id` (a reload, or the first /// attach after a restart); returns once the attach is acknowledged. pub async fn attach(&mut self, session_id: &str) { + let start = self.frames.len(); self.send(json!({ "type": "freshAgent.attach", "provider": "claude", @@ -848,7 +889,7 @@ impl RigConnection { "sessionRef": { "provider": "claude", "sessionId": session_id }, })) .await; - self.wait_for("the attach acknowledgement", |v| { + self.wait_for_since(start, "the attach acknowledgement", |v| { v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.snapshot" && v["event"]["sessionId"] == session_id @@ -874,6 +915,7 @@ impl RigConnection { /// `freshAgent.kill`, then wait for `freshAgent.killed{success:true}`. pub async fn kill(&mut self, session_id: &str) { + let start = self.frames.len(); self.send(json!({ "type": "freshAgent.kill", "provider": "claude", @@ -882,7 +924,9 @@ impl RigConnection { })) .await; let killed = self - .wait_for("freshAgent.killed", |v| v["type"] == "freshAgent.killed") + .wait_for_since(start, "freshAgent.killed", |v| { + v["type"] == "freshAgent.killed" && v["sessionId"] == session_id + }) .await; assert_eq!(killed["success"], true, "{killed}"); } @@ -974,6 +1018,8 @@ struct RigServer { url: String, http_url: String, fresh_claude: freshell_freshagent::FreshClaudeState, + fresh_codex: freshell_freshagent::FreshCodexState, + ownership: Arc, server: tokio::task::JoinHandle<()>, } @@ -1006,26 +1052,46 @@ async fn spawn_rig_server( Some(epoch) => freshell_ownership::RuntimeOwnershipRegistry::with_epoch(epoch), None => freshell_ownership::RuntimeOwnershipRegistry::new(), }); + let layout = if options.rest_api { + let home = environment + .iter() + .find(|(key, _)| key == "HOME") + .expect("rig HOME") + .1 + .clone(); + freshell_freshagent::layout_store::LayoutStore::with_persistence( + PathBuf::from(home).join(".freshell/rest-layout.json"), + ) + } else { + Default::default() + }; let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .with_sidecar_environment(environment) .with_hosted_mcp(options.hosted_claude); - fresh_claude.set_ownership(Arc::clone(&ownership)); - if let Some(ledger) = pane_ledger.as_ref() { - fresh_claude.set_identity_sink(Arc::new(super::ledger_sink::LedgerSink::with_ownership( + if !options.unwired_runtime { + fresh_claude.set_ownership(Arc::clone(&ownership)); + } + if let Some(ledger) = pane_ledger.as_ref().filter(|_| !options.unwired_runtime) { + let mut sink = super::ledger_sink::LedgerSink::with_ownership( Arc::clone(ledger), Some(Arc::clone(&ownership)), - ))); + ); + if let Some(gate) = options.turn_interrupted_marker_gate.clone() { + sink = sink.with_turn_interrupted_marker_gate(gate); + } + fresh_claude.set_identity_sink(Arc::new(sink)); } let lane = fresh_claude.clone(); let fresh_agent_state = freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), Arc::clone(&broadcast_tx), - ); + ) + .with_layout(layout.clone()); let registry = freshell_terminal::TerminalRegistry::new(); let cli_commands: Arc> = Arc::new(Vec::new()); let state = WsState { - layout: Default::default(), + layout, terminal_meta: Default::default(), pane_ledger: pane_ledger .clone() @@ -1087,6 +1153,25 @@ async fn spawn_rig_server( ); let mut router = freshell_ws::router(state.clone()).merge(freshell_freshagent::snapshot::router(snapshots)); + if options.rest_api { + let enabled = state.fresh_codex.clone(); + fresh_agent_state + .set_claude_rest_lane(Arc::new( + freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + lane.clone(), + Arc::new(move || enabled.is_enabled()), + ), + )) + .expect("install the rig's local Claude REST lane"); + let defaults = state.handshake_settings.clone(); + fresh_agent_state + .set_rest_create_settings_reader(Arc::new(move || { + let defaults = defaults.clone(); + Box::pin(async move { defaults.read().await.fresh_agent.clone() }) + })) + .expect("install the rig's REST defaults reader"); + router = router.merge(freshell_freshagent::router(fresh_agent_state.clone())); + } if options.handoff { let runner = Arc::new(freshell_freshagent::SessionHandoffRunner::new( Arc::clone(&auth_token), @@ -1130,6 +1215,8 @@ async fn spawn_rig_server( url: format!("ws://{addr}/ws"), http_url: format!("http://{addr}"), fresh_claude: lane, + fresh_codex: state.fresh_codex.clone(), + ownership, server, } } diff --git a/crates/freshell-ws/tests/common/claude_sim_environment.rs b/crates/freshell-ws/tests/common/claude_sim_environment.rs new file mode 100644 index 000000000..9c7e1bf83 --- /dev/null +++ b/crates/freshell-ws/tests/common/claude_sim_environment.rs @@ -0,0 +1,101 @@ +//! An isolated production-sidecar environment for tests with custom WS wiring. +//! SDK recording and shutdown live at the query boundary; no wire frames are faked. + +use serde_json::Value; +use std::path::{Path, PathBuf}; + +pub struct ClaudeSimEnvironment { + dir: PathBuf, +} + +impl ClaudeSimEnvironment { + pub fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("freshell-claude-sdk-{}", uuid::Uuid::new_v4())); + for child in ["home", "workspace", "tmp"] { + std::fs::create_dir_all(dir.join(child)).unwrap(); + } + std::fs::write(dir.join("active"), "").unwrap(); + Self { dir } + } + + pub fn workspace(&self) -> PathBuf { + self.dir.join("workspace") + } + + pub fn config_dir(&self) -> PathBuf { + self.dir.join("home/.claude") + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + /// A saved conversation in the native JSONL format, for cold-resume tests. + pub fn seed_transcript(&self, id: &str) { + self.seed_transcript_at(id, &self.workspace()); + } + + pub fn seed_transcript_at(&self, id: &str, cwd: &Path) { + let project = self.config_dir().join("projects/fixture"); + std::fs::create_dir_all(&project).unwrap(); + let row = serde_json::json!({ + "type":"user", "sessionId":id, "uuid":uuid::Uuid::new_v4().to_string(), + "parentUuid":null, "cwd":cwd, "isSidechain":false, + "message":{"role":"user", "content":[{"type":"text", "text":"saved conversation"}]} + }); + std::fs::write(project.join(format!("{id}.jsonl")), format!("{row}\n")).unwrap(); + } + + pub fn environment(&self) -> Vec<(String, String)> { + let adapter = super::claude_rig::repo_root() + .join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"); + vec![ + ( + "HOME".into(), + self.dir.join("home").to_string_lossy().into(), + ), + ("PATH".into(), std::env::var("PATH").unwrap_or_default()), + ( + "TMPDIR".into(), + self.dir.join("tmp").to_string_lossy().into(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + self.dir.join("home/.claude").to_string_lossy().into(), + ), + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + freshell_platform::opencode_plugin::plugin_file_spec(&adapter), + ), + ( + "FRESHELL_CLAUDE_SIM_LEDGER".into(), + self.dir.join("ledger.jsonl").to_string_lossy().into(), + ), + ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "0.01".into()), + ( + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), + self.dir.join("active").to_string_lossy().into(), + ), + ] + } + + pub fn rows(&self, kind: &str) -> Vec { + std::fs::read_to_string(self.dir.join("ledger.jsonl")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("simulator row")) + .filter(|row| row["kind"] == kind) + .collect() + } +} + +impl Drop for ClaudeSimEnvironment { + fn drop(&mut self) { + // The shared SDK adapter closes its query when the guard ends or this + // unique directory disappears; cleanup never needs a PID signal. + let _ = std::fs::write(self.dir.join("active.stop"), ""); + let _ = std::fs::remove_dir_all(&self.dir); + } +} diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index 9c411434f..a1aaa540b 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -12,11 +12,47 @@ use std::sync::Arc; +/// Deterministic hold point for the shutdown-marker write in the lifecycle +/// integration test. The sidecar keeps running while this awaited write is +/// held, so the test can prove how a late CLI result races server shutdown. +#[derive(Debug)] +pub struct TurnInterruptedMarkerGate { + entered: tokio::sync::Notify, + release: tokio::sync::Semaphore, +} + +impl TurnInterruptedMarkerGate { + pub fn new() -> Self { + Self { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Semaphore::new(0), + } + } + + pub async fn wait_until_entered(&self) { + self.entered.notified().await; + } + + pub fn release(&self) { + self.release.add_permits(1); + } + + async fn hold(&self) -> std::io::Result<()> { + self.entered.notify_one(); + self.release + .acquire() + .await + .map_err(std::io::Error::other)? + .forget(); + Ok(()) + } +} + use freshell_freshagent::identity_sink::SinkCloseError; use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, ProvenanceUpdate, RollbackRecord, SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, - SinkWrite, + SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; use freshell_ws::pane_ledger::{ ClaimCommitOutcome, CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger, ProvenancePolicy, @@ -28,6 +64,7 @@ pub struct LedgerSink { /// The ownership coordinator an authoritative write is re-validated /// against (the one the lanes writing through this sink are wired to). ownership: Option>, + turn_interrupted_marker_gate: Option>, } impl LedgerSink { @@ -36,6 +73,7 @@ impl LedgerSink { Self { ledger, ownership: None, + turn_interrupted_marker_gate: None, } } @@ -45,7 +83,19 @@ impl LedgerSink { ledger: Arc, ownership: Option>, ) -> Self { - Self { ledger, ownership } + Self { + ledger, + ownership, + turn_interrupted_marker_gate: None, + } + } + + pub fn with_turn_interrupted_marker_gate( + mut self, + gate: Arc, + ) -> Self { + self.turn_interrupted_marker_gate = Some(gate); + self } } @@ -202,6 +252,43 @@ impl PaneIdentitySink for LedgerSink { }) } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let gate = self.turn_interrupted_marker_gate.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + if let Some(gate) = gate { + gate.hold().await?; + } + tokio::task::spawn_blocking(move || { + ledger.mark_fresh_agent_turn_interrupted(&provider, &session_id, at_ms) + }) + .await + .map_err(std::io::Error::other)? + }) + } + + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.take_fresh_agent_turn_interrupted(&provider, &session_id) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn load_settings(&self, provider: &str, session_id: &str) -> Option { let row = self.ledger.load_binding(provider, session_id)?; if row.pane_kind.as_deref() != Some("fresh-agent") { diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 3bc61b782..3daff9b7d 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -18,6 +18,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use freshell_ws::WsState; pub mod claude_rig; +pub mod claude_sim_environment; pub mod ledger_sink; pub mod log_capture; pub mod opencode_home; diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index e64cb699d..7b95535d7 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -17,7 +17,7 @@ //! is refused with the D7 guard's EXISTING rejection frame //! (`error { code: "RESTORE_UNAVAILABLE" }`), same as a live PTY. //! -//! Harness: the lease-suite fake claude sidecar (request-log knob) + the common +//! Harness: the production Claude sidecar over the contract CLI simulator + the common //! sleeper CLI spec so terminal claude creates genuinely spawn a Running PTY. //! Run via `scripts/sandbox-test.sh` per the destructive-suite convention of the //! sibling lease suite (shared file-level ruling; the kata-b8ke Task 7 races @@ -40,91 +40,15 @@ use freshell_freshagent::{FreshAgentBindingUpsert, PaneIdentitySink}; use freshell_ws::pane_ledger::PaneLedger; use freshell_ws::WsState; +use common::claude_sim_environment::ClaudeSimEnvironment; use common::ledger_sink::LedgerSink; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; /// Serializes the tests in this file: they mutate process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FAKE_SIDECAR_*`). +/// (Codex/OpenCode process fixtures and terminal spawn controls). static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -// ── fake claude sidecar (request-log knob only; duplicated per-file per convention) ── - -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - 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 === 'shutdown') { - process.exit(0) - } -}) -"#; - -struct FakeSidecarEnv { - dir: std::path::PathBuf, -} - -impl FakeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-cross-kind-liveness-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } - } - - fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") - .collect() - } -} - -impl Drop for FakeSidecarEnv { - fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } - let _ = std::fs::remove_dir_all(&self.dir); - } -} - fn uuid_like_suffix() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() @@ -483,6 +407,18 @@ async fn build_ws_state_with_probe( WsState, freshell_terminal::TerminalRegistry, freshell_freshagent::FreshAgentState, +) { + build_ws_state_with_probe_and_env(cli_commands, probe, None).await +} + +async fn build_ws_state_with_probe_and_env( + cli_commands: Vec, + probe: std::sync::Arc, + environment: Option<&ClaudeSimEnvironment>, +) -> ( + WsState, + freshell_terminal::TerminalRegistry, + freshell_freshagent::FreshAgentState, ) { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); @@ -515,6 +451,9 @@ async fn build_ws_state_with_probe( }; let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment.environment()); + } fresh_claude.set_terminal_liveness(Arc::clone(&terminal_liveness)); let mut fresh_codex = freshell_freshagent::FreshCodexState::new( Arc::clone(&auth_token), @@ -597,8 +536,18 @@ async fn build_ws_state_with_probe( /// and the `WsState` clone (kata b8ke Task 3: the shared ownership /// coordinator is minted HERE, exactly like `main.rs` injects it). async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry, WsState) { - let (state, registry, _fresh_agent_state) = - build_ws_state(vec![sleeper_cli_spec("claude")]).await; + spawn_server_with_claude(None).await +} + +async fn spawn_server_with_claude( + environment: Option<&ClaudeSimEnvironment>, +) -> (String, freshell_terminal::TerminalRegistry, WsState) { + let (state, registry, _fresh_agent_state) = build_ws_state_with_probe_and_env( + vec![sleeper_cli_spec("claude")], + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; let router = freshell_ws::router(state.clone()); // Ephemeral loopback port only -- NEVER the self-hosted 3001/3002 ports. @@ -642,10 +591,29 @@ async fn spawn_merged_server() -> MergedHarness { /// minted (the states are cloned into it). async fn spawn_merged_server_with_hooks( hooks: Arc, +) -> MergedHarness { + spawn_merged_server_with_hooks_and_claude(hooks, None).await +} + +async fn spawn_merged_server_with_claude(environment: &ClaudeSimEnvironment) -> MergedHarness { + spawn_merged_server_with_hooks_and_claude( + Arc::new(freshell_freshagent::HandoffTestHooks::default()), + Some(environment), + ) + .await +} + +async fn spawn_merged_server_with_hooks_and_claude( + hooks: Arc, + environment: Option<&ClaudeSimEnvironment>, ) -> MergedHarness { let cli_commands = Arc::new(vec![sleeper_cli_spec("claude"), sleeper_cli_spec("codex")]); - let (state, registry, fresh_agent_state) = - build_ws_state(cli_commands.iter().cloned().collect()).await; + let (state, registry, fresh_agent_state) = build_ws_state_with_probe_and_env( + cli_commands.iter().cloned().collect(), + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; // The snapshot REST door shares the WS door's state slices (main.rs's // `SnapshotState::new` wiring: same auth token, same codex/claude @@ -715,10 +683,20 @@ async fn spawn_merged_server_with_hooks( /// exists to guard). The terminal lane shares the same real ledger /// through the WsState. async fn spawn_merged_server_with_ledger() -> (MergedHarness, Arc) { + spawn_merged_server_with_ledger_and_claude(None).await +} + +async fn spawn_merged_server_with_ledger_and_claude( + environment: Option<&ClaudeSimEnvironment>, +) -> (MergedHarness, Arc) { let hooks = Arc::new(freshell_freshagent::HandoffTestHooks::default()); let cli_commands = Arc::new(vec![sleeper_cli_spec("claude"), sleeper_cli_spec("codex")]); - let (state, registry, fresh_agent_state) = - build_ws_state(cli_commands.iter().cloned().collect()).await; + let (state, registry, fresh_agent_state) = build_ws_state_with_probe_and_env( + cli_commands.iter().cloned().collect(), + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; // The REAL ledger (tempdir-rooted) replaces the disabled one BEFORE any // write can flow; the fresh states write through the same ledger via the @@ -1108,9 +1086,9 @@ fn live_pty_count_for_session( #[tokio::test] async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); - let (url, _registry, _ws_state) = spawn_server().await; + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; // 1. A fresh claude terminal reaches Running, owning preallocated session S. @@ -1170,9 +1148,9 @@ async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { // 3. ZERO sidecar spawns. assert!( - env.create_rows().is_empty(), + env.rows("query").is_empty(), "no sidecar may spawn while the terminal owns the session: {:?}", - env.create_rows() + env.rows("query") ); } @@ -1186,9 +1164,9 @@ async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { #[tokio::test] async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); - let (url, _registry, _ws_state) = spawn_server().await; + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; // 1. A fresh claude terminal reaches Running, owning preallocated session S. @@ -1245,9 +1223,9 @@ async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_ // 3. ZERO sidecar spawns. assert!( - env.create_rows().is_empty(), + env.rows("query").is_empty(), "no sidecar may spawn while the terminal owns the session: {:?}", - env.create_rows() + env.rows("query") ); } @@ -1257,13 +1235,14 @@ async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_ #[tokio::test] async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); let durable = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let (url, registry, _ws_state) = spawn_server().await; + env.seed_transcript_at(durable, std::path::Path::new("/tmp")); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; - // 1. A fresh-agent resume of S goes live (the fake sidecar answers `created`). + // 1. A fresh-agent resume of S goes live (the production sidecar boots the saved conversation). // (ejh6 Task 7: sessionRef-only -- a legacy `resumeSessionId` carry is now // rejected at the raw-Value guard, which would break this setup create.) send_json( @@ -1282,7 +1261,7 @@ async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { v["type"] == "freshAgent.created" && v["requestId"] == "req-fa-owner-1" }) .await; - assert_eq!(env.create_rows().len(), 1, "the sidecar owns S now"); + assert_eq!(env.rows("query").len(), 1, "the sidecar owns S now"); // 2. A terminal.create restoring the SAME session id (the D7 direct // wire-sessionRef rung) must be refused -- the sidecar is the one writer. @@ -1334,13 +1313,14 @@ async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { #[tokio::test] async fn d7_cross_kind_refusal_omits_live_terminal_id() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); let durable = "abababab-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let (url, registry, _ws_state) = spawn_server().await; + env.seed_transcript_at(durable, std::path::Path::new("/tmp")); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; - // 1. A fresh-agent resume of S goes live (the fake sidecar answers `created`). + // 1. A fresh-agent resume of S goes live (the production sidecar boots the saved conversation). send_json( &mut ws, &json!({ @@ -1357,7 +1337,7 @@ async fn d7_cross_kind_refusal_omits_live_terminal_id() { v["type"] == "freshAgent.created" && v["requestId"] == "req-fa-owner-d7" }) .await; - assert_eq!(env.create_rows().len(), 1, "the sidecar owns S now"); + assert_eq!(env.rows("query").len(), 1, "the sidecar owns S now"); // 2. The D7 cross-kind refusal: RESTORE_UNAVAILABLE, message names the // session, and NO liveTerminalId (no terminal owns the session). @@ -1612,10 +1592,11 @@ async fn a_successful_terminal_create_does_not_drop_its_ticket_unarmed() { #[tokio::test] async fn fresh_agent_create_and_kill_drive_the_shared_coordinator() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("coord-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); send_json( &mut ws, &json!({ @@ -1664,9 +1645,10 @@ async fn fresh_agent_create_and_kill_drive_the_shared_coordinator() { #[tokio::test] async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_writer() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, registry, _ws_state) = spawn_server().await; - let sid = format!("race-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); let mut ws_term = connect(&url).await; let mut ws_fresh = connect(&url).await; @@ -1692,9 +1674,9 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr // two separate <=1 assertions (one sidecar + one PTY would pass those). let live_writers = || { let creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["resume"].as_str() == Some(sid.as_str())) .count(); let ptys = live_pty_count_for_session(®istry, "claude", &sid); creates + ptys @@ -1774,9 +1756,9 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr // independent: even a transient second writer that spawned and was torn // down between two sampler iterations leaves its Created event behind. let sidecar_creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["resume"].as_str() == Some(sid.as_str())) .count(); assert!( pty_spawns.load(std::sync::atomic::Ordering::SeqCst) + sidecar_creates <= 1, @@ -1789,10 +1771,11 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr #[tokio::test] async fn terminal_create_refusal_names_the_fresh_agent_owner_kind_and_generation() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("typed-owner-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // Establish a live freshclaude owner first. send_json( &mut ws, @@ -1856,9 +1839,10 @@ async fn terminal_create_refusal_names_the_fresh_agent_owner_kind_and_generation #[tokio::test] async fn non_negotiated_terminal_create_is_coordinator_fenced() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; - let sid = format!("raw-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // A negotiated connection establishes the fresh owner. let mut ws_owner = connect(&url).await; send_json( @@ -1985,10 +1969,11 @@ async fn non_negotiated_terminal_create_is_coordinator_fenced() { #[tokio::test] async fn ready_frame_replays_current_runtime_owners() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("replay-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); send_json( &mut ws, &json!({ @@ -2829,9 +2814,10 @@ async fn snapshot_get_for_opencode_never_spawns_the_shared_serve() { #[tokio::test] async fn fresh_agent_to_terminal_handoff_is_atomic_and_broadcast() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let mut h = spawn_merged_server().await; - let sid = format!("ho-e2e-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let mut h = spawn_merged_server_with_claude(&sidecar).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // Fresh owner. send_json( &mut h.ws, @@ -2997,23 +2983,11 @@ async fn fresh_agent_to_terminal_handoff_is_atomic_and_broadcast() { #[tokio::test] async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = - std::env::temp_dir().join(format!("freshell-handoff-ep5-r2-{}", uuid_like_suffix())); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-rev-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner: an ordinary WS terminal binding (claude mode) // over the durable id — the coordinator's Live{Terminal} and the @@ -3096,9 +3070,7 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() // 4. THE REAL LEDGER: the recovery row became the fresh-agent // target's — pane_kind fresh-agent, the terminal's binding // superseded (pre-ep5-r2 the row stayed the dead terminal's). - // The target's session-init adoption write is ASYNC to the - // runner's commit (the sidecar's sdk.session.init lands after the - // handoff answered) — bounded-poll for the flip. + // Observe the target's authoritative binding over the old terminal row. let flipped = tokio::time::timeout(Duration::from_secs(20), async { loop { let done = pane_ledger @@ -3128,12 +3100,9 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() "the terminal's binding is superseded: {row:?}" ); - // Cleanup: the sidecar target dies with the harness (the fake - // sidecar is a short-lived script under the dropped env), and the - // terminal row's PTY is reaped through the registry. + // The fixture guard closes the real SDK query on drop; the terminal + // row's PTY is reaped through the registry. h.ws_state.registry.kill(&terminal_id); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r3 F1 (e2e): a SUPERSEDED handoff's stale @@ -3150,23 +3119,11 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() #[tokio::test] async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = - std::env::temp_dir().join(format!("freshell-handoff-ep5-r3-{}", uuid_like_suffix())); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-sup-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner: an ordinary WS terminal binding (claude mode) // over the durable id — the coordinator's Live{Terminal} and the @@ -3338,8 +3295,6 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() // the second terminal's PTY is reaped through the registry. h.ws_state.registry.kill(&second_terminal_id); let _ = first_terminal_id; - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r4 F1 (e2e): the FAILED-TRANSITION REPAIR, end to end @@ -3357,25 +3312,11 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() #[tokio::test] async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = std::env::temp_dir().join(format!( - "freshell-handoff-ep5-r4-repair-{}", - uuid_like_suffix() - )); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-rep-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner + 2. the FIRST handoff to the FRESH target — // the r2 success flow: the target's authoritative binding lands @@ -3509,8 +3450,6 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { // Cleanup: the first terminal's row was reaped by the handoff; the // fresh target's sidecar dies with the harness. let _ = terminal_id; - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r5 F1 (e2e): an ORDINARY codex cold resume with @@ -3530,10 +3469,9 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { #[tokio::test] async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The isolated codex home + the fake app-server (the lease-suite - // pattern): the resume spawns a real sidecar subprocess through - // CODEX_CMD. + // The isolated codex home + the fake app-server (the lease-suite + // pattern): the resume spawns a real sidecar subprocess through + // CODEX_CMD. let codex_home = tempfile::tempdir().expect("isolated codex home"); let prev_codex_home = std::env::var_os("CODEX_HOME"); let prev_allow = std::env::var_os("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES"); diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index 4cebff344..63efbce28 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -6,6 +6,9 @@ //! it is unreachable from the wire until the dispatch arm exists. Harness duplicated //! from that file per the repo's per-test-file convention. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::sync::Arc; use std::time::Duration; @@ -18,131 +21,6 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; -/// Serializes every test in this file that mutates process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR`), -/// mirroring `freshagent_claude_kill_interrupt.rs`'s convention for the same hazard. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -// ── fake claude sidecar (resume flavor: created + sdk.session.init + sdk.status) ── - -/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) speaking -/// 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 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. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || sessionId - 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.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'pong' }] }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script, with `FRESHELL_CLAUDE_SIDECAR`/ -/// `FRESHELL_CLAUDE_NODE` pointed at it, PLUS a seeded claude transcript store with -/// `CLAUDE_CONFIG_DIR` pointed at it. Caller must hold [`CLAUDE_ENV_LOCK`] for the -/// lifetime of the returned guard. -struct FakeClaudeResumeEnv { - dir: std::path::PathBuf, -} -impl FakeClaudeResumeEnv { - fn install(durable: &str) -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-resume-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - // Seed the transcript store: one user line carrying an EXISTING cwd ("/tmp"), - // so the resume request goes by durable UUID + original cwd (ledger A15). - let store = dir.join("claude-store"); - let project = store.join("projects").join("-t"); - std::fs::create_dir_all(&project).expect("create transcript project dir"); - std::fs::write( - project.join(format!("{durable}.jsonl")), - r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, - ) - .expect("seed transcript"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -impl FakeClaudeResumeEnv { - /// The JSONL request log the fake sidecar appends every inbound request to - /// (`{ pid, msg }` per line) -- distinct pids == spawn count. - fn request_log_path(&self) -> std::path::PathBuf { - self.dir.join("requests.jsonl") - } - - fn request_log_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.request_log_path()) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("request log row parses")) - .collect() - } -} - -impl Drop for FakeClaudeResumeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - std::env::remove_var("FAKE_SIDECAR_REQUEST_LOG"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - // ── server harness (duplicated from diag01_lifecycle_events.rs's convention, with // `freshAgent.enabled: true` so `freshAgent.create` actually dispatches) ── @@ -166,7 +44,7 @@ fn test_settings_value() -> serde_json::Value { }) } -async fn spawn_server() -> String { +async fn spawn_server(environment: &ClaudeSimEnvironment) -> String { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = @@ -192,7 +70,8 @@ async fn spawn_server() -> String { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) + .with_sidecar_environment(environment.environment()), fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -317,7 +196,8 @@ async fn await_frame( /// (this test then fails with `await_frame` panicking on its timeout budget). #[tokio::test] async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() { - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -347,15 +227,15 @@ async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() /// server spawns a sidecar with a resume boot and emits the idle /// `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID (the /// frozen client persists it unvalidated -- NEVER a nanoid), all under the CLIENT's -/// original session id. Before the fix this attach produced the lost frame instead +/// canonical native session id. Before the fix this attach produced the lost frame instead /// (this test then fails with `await_frame` panicking on its timeout budget). #[tokio::test] async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over_ws() { - let _guard = CLAUDE_ENV_LOCK.lock().await; let durable = "abababab-abab-4bab-8bab-abababababab"; - let _env = FakeClaudeResumeEnv::install(durable); + let env = ClaudeSimEnvironment::new(); + env.seed_transcript(durable); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -379,37 +259,13 @@ async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over assert_eq!(frame["sessionType"], "freshclaude"); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); -} - -/// Makes a directory unreadable (mode 000) and restores it on drop -- declared -/// AFTER the env guard so it drops first and the env guard's `remove_dir_all` -/// can clean up. `None` when the process bypasses permission bits (root), where -/// the EACCES under test cannot be produced. -#[cfg(unix)] -struct UnreadableDir(std::path::PathBuf); -#[cfg(unix)] -impl UnreadableDir { - fn make(dir: &std::path::Path) -> Option { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o000)).unwrap(); - let guard = Self(dir.to_path_buf()); - if std::fs::read_dir(dir).is_ok() { - eprintln!("skipping: euid bypasses permission checks"); - return None; - } - Some(guard) - } -} -#[cfg(unix)] -impl Drop for UnreadableDir { - fn drop(&mut self) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); - } + assert_eq!(env.rows("query").len(), 1); + assert_eq!(env.rows("query")[0]["options"]["resume"], durable); + assert_eq!(env.rows("boot")[0]["sessionId"], durable); } /// G28: an attach by durable UUID while the transcript store cannot be READ -/// (EACCES on `projects/`) must not be adjudicated "lost". The server cannot +/// (the projects path cannot be traversed) must not be adjudicated "lost". The server cannot /// prove the transcript gone, so it answers the transient /// `CLAUDE_ATTACH_RESUME_FAILED` error -- never the `INVALID_SESSION_ID` frame /// the client folds into `markSessionLost` and starts a recovery for -- and it @@ -417,14 +273,14 @@ impl Drop for UnreadableDir { #[cfg(unix)] #[tokio::test] async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { - let _guard = CLAUDE_ENV_LOCK.lock().await; let durable = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let env = FakeClaudeResumeEnv::install(durable); - let Some(_locked) = UnreadableDir::make(&env.dir.join("claude-store").join("projects")) else { - return; - }; + let env = ClaudeSimEnvironment::new(); + std::fs::create_dir_all(env.config_dir()).unwrap(); + // A loop produces a real traversal error even when the sandbox user may + // bypass Unix permission bits. Missing/non-directory paths are clean misses. + std::os::unix::fs::symlink("projects", env.config_dir().join("projects")).unwrap(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -455,11 +311,7 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { "the attach answers with the transient resume failure: {frame}" ); - let creates = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + let creates = env.rows("query").len(); assert_eq!( creates, 0, "no sidecar may spawn against an unchecked store" @@ -471,7 +323,8 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { /// unit tests bypass) or the client builds the wrong session locator. #[tokio::test] async fn kilroy_attach_for_untracked_session_emits_lost_session_frame_over_ws() { - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -495,18 +348,14 @@ async fn kilroy_attach_for_untracked_session_emits_lost_session_frame_over_ws() assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } -// ── Task 10b: claude durable→live resolution (attach rebind + ack, send routing) ── -// -// claude keys live sessions by a sidecar-minted placeholder nanoid, while the reconcile -// attach verdict names the DURABLE ref. On base, `attach{durable}` on a live session is -// a silent no-op and `send{durable}` misses the sessions map (SESSION_NOT_FOUND) while -// events keep broadcasting under the placeholder — stranding the pane the fold rebound. - -/// An unrelated saved conversation; fresh creates still choose their own UUID. -const SEEDED_TRANSCRIPT_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +// Native session identity is established at create, before the CLI's first init. +// Attach, send and events must all keep addressing that same conversation. -/// Create a native session and verify the sidecar repeats its acknowledged UUID. -async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { +async fn create_live_claude_session( + ws: &mut TestWs, + env: &ClaudeSimEnvironment, + request_id: &str, +) -> String { send_json( ws, &serde_json::json!({ @@ -514,7 +363,7 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String "requestId": request_id, "sessionType": "freshclaude", "provider": "claude", - "cwd": "/tmp", + "cwd": env.workspace(), }), ) .await; @@ -522,29 +371,24 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String v["type"] == "freshAgent.created" && v["requestId"] == request_id }) .await; - let placeholder = created["sessionId"] + let native_id = created["sessionId"] .as_str() .expect("created carries sessionId") .to_string(); - let init = await_frame(ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" - }) - .await; - assert_eq!(init["event"]["cliSessionId"], placeholder); - placeholder + assert!(uuid::Uuid::parse_str(&native_id).is_ok(), "{created}"); + assert_eq!(created["sessionRef"]["sessionId"], native_id); + assert_eq!(env.rows("boot")[0]["sessionId"], native_id); + native_id } -/// Attach by the DURABLE id on a LIVE session must REBIND (alias) + ACK — at least one -/// frame stamped with the durable id must reach the attaching connection. On base the -/// cli_index-hit arm is a silent no-op (this test then fails on `await_frame`'s budget). +/// A warm attach acknowledges the same native conversation without a second query. #[tokio::test] -async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); +async fn attach_by_native_id_on_a_live_session_acks() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let durable = create_live_claude_session(&mut ws, "req-10b-attach").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-attach").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -552,47 +396,39 @@ async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": durable, + "sessionId": native_id, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": durable }, + "sessionRef": { "provider": "claude", "sessionId": native_id }, }), ) .await; let frame = await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == durable + v["sessionId"] == native_id && v["event"]["type"] == "freshAgent.session.snapshot" }) .await; assert_eq!(frame["type"], "freshAgent.event"); - // No second sidecar: exactly ONE create row in the fake's request log. - let creates = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + // No second sidecar: exactly one actual SDK query. + let creates = env.rows("query").len(); assert_eq!(creates, 1, "attach-to-live must not spawn a second sidecar"); } -/// `freshAgent.send` addressed by the DURABLE id must resolve through `cli_index` to the -/// live session — the sidecar receives the send line and `freshAgent.send.accepted` -/// lands. On base the sessions-map miss broadcasts SESSION_NOT_FOUND and no accepted -/// frame ever arrives (this test then fails on `await_frame`'s budget). +/// A send addressed by the native ID reaches the already live SDK query exactly once. #[tokio::test] -async fn send_by_durable_id_routes_to_the_live_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); +async fn send_by_native_id_routes_to_the_live_session() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let durable = create_live_claude_session(&mut ws, "req-10b-send").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-send").await; send_json( &mut ws, &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": durable, + "sessionId": native_id, "sessionType": "freshclaude", "text": "ping", "requestId": "send-10b", @@ -604,33 +440,36 @@ async fn send_by_durable_id_routes_to_the_live_session() { v["type"] == "freshAgent.send.accepted" && v["requestId"] == "send-10b" }) .await; - assert_eq!(accepted["sessionId"], durable); + assert_eq!(accepted["sessionId"], native_id); - // 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). + // An actual SDK assistant frame proves the sidecar processed the routed send. await_frame(&mut ws, Duration::from_secs(10), |v| { v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.assistant" }) .await; - // The fake sidecar actually received the send line (routing, not just the ack). - let sends = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "send") - .count(); - assert_eq!(sends, 1, "the live sidecar must receive the routed send"); + let prompts = env.rows("prompt"); + assert_eq!( + prompts.len(), + 1, + "the SDK must receive exactly one routed send" + ); + assert_eq!(prompts[0]["sessionId"], native_id); + assert_eq!(prompts[0]["uuid"], accepted["submittedTurnId"]); + assert_eq!( + prompts[0]["content"], + serde_json::json!([{"type":"text", "text":"ping"}]) + ); } -/// A second page's attach keeps subsequent events on the same native UUID. +/// Event envelopes after a warm attach retain the same native session identity. #[tokio::test] -async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); +async fn events_after_native_attach_are_stamped_with_the_native_id() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let durable = create_live_claude_session(&mut ws, "req-10b-events").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-events").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -638,25 +477,25 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": durable, + "sessionId": native_id, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": durable }, + "sessionRef": { "provider": "claude", "sessionId": native_id }, }), ) .await; - // The rebind ack gates the ordering (attach handled before the send below). + // The snapshot acknowledgement gates the ordering (attach handled before the send below). await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == durable + v["sessionId"] == native_id && v["event"]["type"] == "freshAgent.session.snapshot" }) .await; - // Drive the fake to emit a content frame (it answers every `send` with one). + // Run an actual turn after attach and observe its assistant event. send_json( &mut ws2, &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": durable, + "sessionId": native_id, "sessionType": "freshclaude", "text": "poke", "requestId": "send-10b-events", @@ -669,8 +508,8 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { }) .await; assert_eq!( - event["sessionId"], durable, - "attaching preserves the native envelope UUID" + event["sessionId"], native_id, + "post-attach envelopes must be stamped with the native id" ); } @@ -680,9 +519,8 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { /// rejection rides the freshAgent.error event channel keyed by sessionId. #[tokio::test] async fn legacy_reject_freshagent_attach() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install("legacy-durable-id"); - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; // ejh6 presence (finding 9): rejection fires for any string, including "". @@ -716,12 +554,7 @@ async fn legacy_reject_freshagent_attach() { "{label}: frozen text: {err}" ); } - // V3: this file has NO create_rows(); use request_log_rows() and filter for create rows. - let create_count = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + let create_count = env.rows("query").len(); assert_eq!( create_count, 0, "no sidecar may spawn for a rejected legacy attach" diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index 0df275ed1..0b4c6069f 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -1,684 +1,310 @@ -//! A new freshclaude/kilroy pane's messages run, sent the way the browser sends -//! them, on a server whose ownership coordinator is wired the way -//! `crates/freshell-server/src/main.rs` wires it (one -//! `RuntimeOwnershipRegistry` shared by the WS state and the claude lane). -//! -//! The browser sends every message with a checkpoint intent -//! (`FreshAgentView.tsx`'s `sendFreshAgentSendFrame`), and once a -//! `session.runtimeOwner` frame named the pane's durable session id it also -//! carries that record's observed `(epoch, generation)` pair. `handle_send` -//! fences such a send through the coordinator. Creation establishes the native -//! UUID and its owner before acknowledgement. The CLI repeats that identity in -//! `system/init` (`sdk.session.init`), either after create or on the first prompt; -//! checkpointed sends must work with both reporting orders. -//! -//! The claude sidecar is a scripted fake (the production -//! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` overrides): no SDK, no -//! network. It logs every prompt it receives, so "the message ran" is observed -//! at the process boundary, not inferred from a frame. +//! Browser-shaped checkpointed sends through the production sidecar and CLI simulator. +//! Creation owns the native UUID before SDK init. Exercise sends before the +//! first init, after a real warm-up turn, and across coordinator fencing. mod common; -use common::{TestWs, AUTH_TOKEN, FRAME_BUDGET}; - -use std::path::PathBuf; -use std::sync::Arc; - -use futures_util::{SinkExt, StreamExt}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind}; use serde_json::{json, Value}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -use freshell_ws::WsState; - -/// Serializes every test in this file: they set the process-global sidecar -/// env vars the claude lane reads at spawn time. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -// ── fake claude sidecar ───────────────────────────────────────────────────── - -/// The production newline-JSON sidecar protocol, reduced to create / send / -/// interrupt / shutdown. The session is reported (`sdk.session.init`, the CLI's -/// `system/init`) at the start of every turn, as the real CLI does, and -/// `FRESHELL_TEST_CLAUDE_INIT_AT` picks whether it is also reported earlier: -/// - `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 (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' - -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 -const emit = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') - -function reportSession(sessionId, st) { - emit({ type: 'sdk.session.init', sessionId, cliSessionId: st.cliSessionId, model: 'fake-model', cwd: process.cwd(), tools: [] }) -} - -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('close', () => process.exit(0)) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId || `fake-claude-${process.pid}-${counter}` - const st = { cliSessionId: msg.boot?.resume || sessionId } - sessions.set(sessionId, st) - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - if (initAt === 'create') reportSession(sessionId, st) - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'send') { - const st = sessions.get(msg.sessionId) - if (!st) return - if (sendLog) fs.appendFileSync(sendLog, `${msg.text}\n`) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) - 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' }) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) - } else if (msg.type === 'interrupt') { - 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) - } -}) -"#; - -/// When the fake sidecar reports the session. -#[derive(Clone, Copy)] -enum InitAt { - /// As the first prompt runs (the real CLI). - FirstPrompt, - /// Right after create (the e2e fake sidecar). - Create, -} - -/// The installed fake sidecar: script + prompt log in a temp dir, env vars set. -/// The caller holds [`CLAUDE_ENV_LOCK`] for its lifetime. -struct FakeSidecar { - _dir: tempfile::TempDir, - send_log: PathBuf, -} - -impl FakeSidecar { - fn install(init_at: InitAt) -> Self { - let dir = tempfile::tempdir().expect("fake sidecar temp dir"); - let script = dir.path().join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let send_log = dir.path().join("sends.log"); - std::fs::write(&send_log, "").expect("init send log"); - let init_at = match init_at { - InitAt::FirstPrompt => "first-prompt", - InitAt::Create => "create", - }; - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_INIT_AT", init_at); - std::env::set_var("FRESHELL_TEST_CLAUDE_SEND_LOG", &send_log); - Self { - _dir: dir, - send_log, - } - } - /// Every prompt the sidecar received, in order. - fn prompts(&self) -> Vec { - std::fs::read_to_string(&self.send_log) - .unwrap_or_default() - .lines() - .map(str::to_string) - .collect() - } -} - -impl Drop for FakeSidecar { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INIT_AT"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_SEND_LOG"); - } -} - -// ── server: the ownership coordinator wired like main.rs ──────────────────── - -fn settings_value() -> Value { - let mut settings = common::test_settings_value(); - settings["freshAgent"]["enabled"] = json!(true); - settings -} - -/// A real axum server on an ephemeral loopback port (never 3001/3002) whose -/// claude lane and WS state share ONE ownership coordinator, as in main.rs. -async fn spawn_server(ownership: Arc) -> String { - let auth_token = Arc::new(AUTH_TOKEN.to_string()); - let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(256).0); - let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); - fresh_claude.set_ownership(Arc::clone(&ownership)); - - let state = WsState { - pane_ledger: Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), - layout: Default::default(), - identity: freshell_ws::identity::TerminalIdentityRegistry::new(), - terminal_meta: Default::default(), - auth_token: Arc::clone(&auth_token), - server_instance_id: Arc::new("srv-test".to_string()), - boot_id: Arc::new("boot-test".to_string()), - settings: Arc::new(serde_json::from_value(settings_value()).expect("valid settings")), - handshake_settings: Arc::new(tokio::sync::RwLock::new( - serde_json::from_value(settings_value()).expect("valid settings"), - )), - broadcast_tx: Arc::clone(&broadcast_tx), - auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, - auto_resume_cancels: Default::default(), - fresh_codex: freshell_freshagent::FreshCodexState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - json!({ "freshAgent": { "enabled": true } }), - ), - fresh_claude, - fresh_opencode: freshell_freshagent::FreshOpencodeState::new( - freshell_freshagent::FreshAgentState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - ), - ), - registry: freshell_terminal::TerminalRegistry::new(), - tabs: freshell_ws::tabs::TabsRegistry::new(), - screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), - subagent_interest: Default::default(), - host_stats: Default::default(), - checkpoint_send: Default::default(), - terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - cli_commands: Arc::new(Vec::new()), - shutdown: Arc::new(tokio::sync::Notify::new()), - ping_interval_ms: 30_000, - hello_timeout_ms: 5_000, - allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), - ws_max_payload_bytes: 16 * 1024 * 1024, - term09: freshell_ws::backpressure::Term09Config::default(), - create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), - spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), - shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), - create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), - config_fallback: None, - opencode_locator: None, - codex_locator: None, - activity: None, - session_existence: Arc::new(freshell_ws::existence::NoIndexProbe::default()), - reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, - fresh_agent_respawn_counts: Default::default(), - ownership: Some(ownership), - }; - - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind ephemeral loopback port"); - let addr = listener.local_addr().expect("local addr"); - let router = freshell_ws::router(state); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - format!("ws://{addr}/ws") +fn completions(conn: &RigConnection, id: &str) -> Vec { + conn.frames + .iter() + .filter(|frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.turn.complete" + }) + .map(|frame| frame["event"]["at"].as_i64().expect("numeric edge at")) + .collect() } -// ── a browser-shaped connection that keeps every frame it read ────────────── - -struct Conn { - ws: TestWs, - frames: Vec, +fn prompts(rig: &ClaudeRig, id: &str) -> Vec { + rig.ledger_rows("prompt") + .into_iter() + .filter(|row| row["sessionId"] == id) + .collect() } -impl Conn { - async fn connect(url: &str) -> Self { - let (mut ws, _) = tokio_tungstenite::connect_async(url) - .await - .expect("ws connect"); - ws.send(WsMessage::Text( - json!({ - "type": "hello", - "token": AUTH_TOKEN, - "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, - }) - .to_string(), - )) - .await - .expect("send hello"); - let mut conn = Self { - ws, - frames: Vec::new(), - }; - conn.wait_for("the ready handshake frame", |v| v["type"] == "ready") - .await; - conn - } - - async fn send(&mut self, frame: Value) { - self.ws - .send(WsMessage::Text(frame.to_string())) - .await - .expect("send frame"); - } - - /// Read frames until `done` holds over every frame read so far. - async fn wait_until(&mut self, what: &str, done: impl Fn(&[Value]) -> bool) { - let deadline = tokio::time::Instant::now() + FRAME_BUDGET; - while !done(&self.frames) { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - let msg = tokio::time::timeout(remaining, self.ws.next()) - .await - .unwrap_or_else(|_| { - panic!("timed out waiting for {what}; frames: {:#?}", self.frames) - }) - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { continue }; - self.frames - .push(serde_json::from_str(&text).expect("json frame")); - } - } - - /// The first frame (already read or read now) matching `pred`. - async fn wait_for(&mut self, what: &str, pred: impl Fn(&Value) -> bool) -> Value { - self.wait_until(what, |frames| frames.iter().any(&pred)) - .await; - self.frames - .iter() - .find(|v| pred(v)) - .cloned() - .expect("the frame wait_until saw") - } - - async fn create(&mut self, request_id: &str) -> String { - self.send(json!({ - "type": "freshAgent.create", - "requestId": request_id, - "provider": "claude", - "sessionType": "freshclaude", - })) - .await; - let created = self - .wait_for("freshAgent.created", |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; - created["sessionId"] - .as_str() - .expect("created carries a session id") - .to_string() - } - - fn durable_id(&self, session_id: &str) -> Option { - durable_id(&self.frames, session_id) - } - - fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { - observed_owner(&self.frames, session_id) - } - - /// A `freshAgent.send` shaped like the browser's: a checkpoint intent, and - /// the given observed owner pair when there is one. - async fn send_checkpointed( - &mut self, - session_id: &str, - request_id: &str, - text: &str, - observed: Option<(u64, u64)>, - ) { - let mut frame = json!({ - "type": "freshAgent.send", - "provider": "claude", - "sessionType": "freshclaude", - "sessionId": session_id, - "requestId": request_id, - "checkpointIntent": { "logicalAttemptId": format!("attempt-{request_id}") }, - "text": text, - }); - if let Some((epoch, generation)) = observed { - frame["observedEpoch"] = json!(epoch); - frame["observedGeneration"] = json!(generation); - } - self.send(frame).await; - } - - /// The browser's send: the observed pair rides along once it holds one. - async fn send_like_the_browser(&mut self, session_id: &str, request_id: &str, text: &str) { - let observed = self.observed_owner(session_id); - self.send_checkpointed(session_id, request_id, text, observed) - .await; - } - - /// The request's outcome: its `freshAgent.send.accepted` or its `error`. - async fn send_outcome(&mut self, request_id: &str) -> Value { - self.wait_for(&format!("{request_id} to be accepted or refused"), |v| { - v["requestId"] == request_id - && (v["type"] == "error" || v["type"] == "freshAgent.send.accepted") - }) - .await - } - - /// The request ran: it was accepted (never refused) and the session's - /// `turn`-th turn-complete edge arrived. - async fn expect_send_runs(&mut self, session_id: &str, request_id: &str, turn: usize) { - let outcome = self.send_outcome(request_id).await; - assert_eq!( - outcome["type"], "freshAgent.send.accepted", - "{request_id} must run, not be refused: {outcome}" - ); - self.wait_until(&format!("turn-complete edge #{turn}"), |frames| { - turn_completes(frames, session_id).len() >= turn - }) - .await; - } - - /// Kill the session and read through its `freshAgent.killed`, so every frame - /// its turns produced has been read before the counts are taken. - async fn kill(&mut self, session_id: &str) { - self.send(json!({ - "type": "freshAgent.kill", - "provider": "claude", - "sessionType": "freshclaude", - "sessionId": session_id, - })) - .await; - self.wait_for("freshAgent.killed", |v| { - v["type"] == "freshAgent.killed" && v["sessionId"] == session_id +async fn expect_turn( + rig: &ClaudeRig, + conn: &mut RigConnection, + id: &str, + start: usize, + request: &str, + text: &str, + turn: usize, +) { + let outcome = conn + .wait_for_since(start, "the send outcome", |frame| { + frame["requestId"] == request + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) }) .await; - } - - fn events_of(&self, session_id: &str, event_type: &str) -> usize { - self.frames - .iter() - .filter(|v| { - v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == event_type - }) - .count() - } -} - -/// The browser learns the native identity from create before the CLI reports it. -fn durable_id(frames: &[Value], session_id: &str) -> Option { - frames - .iter() - .find_map(|v| { - (v["type"] == "freshAgent.created" && v["sessionId"] == session_id) - .then(|| v["sessionRef"]["sessionId"].as_str().map(str::to_string)) - .flatten() - }) - .or_else(|| { - frames.iter().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.session.init") - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) - }) -} - -/// The owner pair the browser holds for the pane: the latest -/// `session.runtimeOwner` frame for the pane's durable id. -fn observed_owner(frames: &[Value], session_id: &str) -> Option<(u64, u64)> { - let durable = durable_id(frames, session_id)?; - frames.iter().rev().find_map(|v| { - (v["type"] == "session.runtimeOwner" - && v["provider"] == "claude" - && v["sessionId"] == durable.as_str()) - .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) - .flatten() + assert_eq!(outcome["type"], "freshAgent.send.accepted", "{outcome}"); + conn.wait_for_since(start, "the completed turn", |frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + conn.wait_for_since(start, "the real SDK result", |frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.result" }) + .await; + let seen = prompts(rig, id); + assert_eq!( + seen.len(), + turn, + "every accepted turn ran exactly once: {seen:?}" + ); + assert_eq!(seen.last().unwrap()["text"], text); + assert_eq!( + seen.last().unwrap()["uuid"], + outcome["submittedTurnId"], + "the accepted command reached the actual SDK input stream" + ); + assert_eq!(completions(conn, id).len(), turn); } -/// The `at` of every `freshAgent.turn.complete` edge for the session, in order. -fn turn_completes(frames: &[Value], session_id: &str) -> Vec { - frames - .iter() - .filter(|v| { - v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.turn.complete" - }) - .map(|v| v["event"]["at"].as_u64().expect("numeric at")) - .collect() +async fn send_fenced( + conn: &mut RigConnection, + id: &str, + request: &str, + text: &str, + observed: (u64, u64), +) -> usize { + let start = conn.frames.len(); + conn.send( + json!({"type":"freshAgent.send", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":request, "text":text, + "checkpointIntent":{"logicalAttemptId":format!("attempt-{request}")}, + "observedEpoch":observed.0, "observedGeneration":observed.1}), + ) + .await; + start } -// ── tests ─────────────────────────────────────────────────────────────────── - -/// Three messages sent the way the browser sends them each run once: each -/// reaches the sidecar, is accepted, and rings exactly one turn-complete edge. -/// The CLI reports the session again at the start of every turn, so the later -/// messages are sent after a repeated report of the session they belong to. -async fn a_new_panes_messages_each_run_once(init_at: InitAt) { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(init_at); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - conn.wait_until("the created session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; - if matches!(init_at, InitAt::FirstPrompt) { - assert_eq!( - conn.events_of(&session_id, "freshAgent.session.init"), - 0, - "the first checkpointed send is fenced before any CLI init" - ); - } - let messages = ["first message", "second message", "third message"]; - for (index, text) in messages.iter().enumerate() { - let turn = index + 1; - let request_id = format!("send-{turn}"); - conn.send_like_the_browser(&session_id, &request_id, text) +async fn messages_each_run_once(after_init: bool) { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-checkpointed").await; + assert!( + conn.owner_pair(&id).is_some(), + "create announced ownership before SDK init" + ); + assert_eq!(conn.events_seen("freshAgent.session.init"), 0); + let mut expected = Vec::new(); + if after_init { + let start = conn.frames.len(); + conn.send_text(&id, "warm-up", "initialize the existing conversation") .await; - conn.expect_send_runs(&session_id, &request_id, turn).await; - // The first prompt made the CLI report the session (in either order), so - // the browser now holds the durable id's owner pair and sends it. - assert!( - conn.observed_owner(&session_id).is_some(), - "the coordinator announced the reported session's owner: {:#?}", - conn.frames - ); + expect_turn( + &rig, + &mut conn, + &id, + start, + "warm-up", + "initialize the existing conversation", + 1, + ) + .await; + assert_eq!(conn.events_seen("freshAgent.session.init"), 1); + expected.push("initialize the existing conversation"); } - - conn.kill(&session_id).await; - assert_eq!( - sidecar.prompts(), - messages.map(str::to_string).to_vec(), - "each message reached the sidecar exactly once" - ); - let edges = turn_completes(&conn.frames, &session_id); + for (index, text) in ["first message", "second message", "third message"] + .into_iter() + .enumerate() + { + let request = format!("send-{index}"); + let start = conn.frames.len(); + conn.send_checkpointed_text(&id, &request, text).await; + expected.push(text); + expect_turn(&rig, &mut conn, &id, start, &request, text, expected.len()).await; + assert!(conn.owner_pair(&id).is_some()); + } + conn.kill(&id).await; assert_eq!( - edges.len(), - messages.len(), - "one turn-complete edge per send: {edges:?}" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + expected ); + let edges = completions(&conn, &id); assert!( edges.windows(2).all(|pair| pair[0] < pair[1]), - "edges are strictly increasing: {edges:?}" - ); - assert_eq!( - conn.events_of(&session_id, "freshAgent.result"), - messages.len() + "strictly increasing edges: {edges:?}" ); + assert_eq!(conn.events_seen("freshAgent.result"), expected.len()); + rig.fresh_claude.shutdown().await; } mod checkpointed_sends { use super::*; - /// The first send carries the create-time UUID's owner pair even when the - /// CLI has not emitted its first init yet. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_when_the_session_is_reported_by_the_first_prompt() { - a_new_panes_messages_each_run_once(InitAt::FirstPrompt).await; + messages_each_run_once(false).await; } - /// The e2e fake reports the session at create: every message is sent after - /// the coordinator owns the session under its durable id, with that record's - /// observed pair. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn run_when_the_session_is_reported_at_create() { - a_new_panes_messages_each_run_once(InitAt::Create).await; + async fn run_after_the_cli_has_initialized_the_existing_conversation() { + messages_each_run_once(true).await; } - /// The fence still guards a reported session, on the record that owns it: a - /// message carrying an owner pair from before a lifecycle transition is - /// refused as stale and never reaches the sidecar; the same message with the - /// re-observed pair runs. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_a_stale_owner_pair_are_refused_once_the_session_is_reported() { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(InitAt::Create); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - conn.wait_until("the reported session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; - let observed = conn - .observed_owner(&session_id) - .expect("observed owner pair"); - let durable = conn.durable_id(&session_id).expect("reported durable id"); - - // A lifecycle transition advances the owner record's generation while the - // session stays live here (a handoff that failed back to this runtime). - let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_handoff( + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-stale-fence").await; + let observed = conn.owner_pair(&id).expect("create-time owner pair"); + let freshell_ownership::BeginOutcome::Granted { generation } = rig.ownership.begin_handoff( "claude", - &durable, + &id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-generation-bump", + "generation-bump", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the generation-bump handoff must be granted") + panic!("the handoff must start") }; - let _ = ownership.fail("claude", &durable, "test-generation-bump", generation, true); - let current = ownership.observe("claude", &durable); + assert!(matches!( + rig.ownership + .fail("claude", &id, "generation-bump", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let current = rig.ownership.observe("claude", &id); assert!( matches!( current.state, freshell_ownership::OwnershipState::Live { .. } - ) && current.generation > observed.1, - "fixture: the record is still live at a newer generation: {current:?}" + ) && current.generation > observed.1 ); - - conn.send_checkpointed(&session_id, "send-stale", "stale message", Some(observed)) + let start = send_fenced(&mut conn, &id, "stale", "stale message", observed).await; + let refused = conn + .wait_for_since(start, "the stale refusal", |frame| { + frame["requestId"] == "stale" + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) + }) .await; - let refusal = conn.send_outcome("send-stale").await; - assert_eq!(refusal["type"], "error", "{refusal}"); - let message = refusal["message"].as_str().unwrap_or_default(); + assert_eq!(refused["type"], "error", "{refused}"); + let message = refused["message"].as_str().unwrap(); assert!( message.starts_with("SESSION_RESERVED") && message.contains(freshell_freshagent::ownership_lane::STALE_OP_GUARD_MESSAGE), - "refused as stale against the record that owns the session: {refusal}" + "{refused}" ); - - conn.send_checkpointed( - &session_id, - "send-current", + let start = send_fenced( + &mut conn, + &id, + "current", "current message", - Some((current.epoch, current.generation)), + (current.epoch, current.generation), ) .await; - conn.expect_send_runs(&session_id, "send-current", 1).await; - - conn.kill(&session_id).await; + expect_turn(&rig, &mut conn, &id, start, "current", "current message", 1).await; + // These fence-only fixtures bypass the handoff runner's retained-stamp + // repair. They own their rig, so shut it down and prove its processes + // exit without pretending the complete handoff adapter ran. + let processes = rig.runtime_processes(&id); + rig.fresh_claude.shutdown().await; + processes + .wait_until_exited("the registry-transition fixture") + .await; assert_eq!( - sidecar.prompts(), - vec!["current message".to_string()], - "the stale message never reached the sidecar" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + ["current message"] ); - assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); + assert_eq!(completions(&conn, &id).len(), 1); } - /// A lifecycle operation can own the newly created native UUID before the - /// first CLI init. Checkpointing must respect it; restoring the known-live - /// prior lets a freshly fenced retry run exactly once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn wait_for_a_lifecycle_operation_on_the_native_uuid_before_the_first_init() { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(InitAt::FirstPrompt); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_handoff( + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-lifecycle-fence").await; + assert_eq!(conn.events_seen("freshAgent.session.init"), 0); + let freshell_ownership::BeginOutcome::Granted { generation } = rig.ownership.begin_handoff( "claude", - &session_id, + &id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-native-handoff", + "native-handoff", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the handoff on the created native session must be granted") + panic!("the native handoff must start") }; - - conn.send_checkpointed( - &session_id, - "send-during", + let start = send_fenced( + &mut conn, + &id, + "during", "message during the handoff", - Some((ownership.boot_epoch(), generation)), + (rig.ownership.boot_epoch(), generation), ) .await; - let refusal = conn.send_outcome("send-during").await; - assert_eq!(refusal["type"], "error", "{refusal}"); - let message = refusal["message"].as_str().unwrap_or_default(); + let refused = conn + .wait_for_since(start, "the lifecycle refusal", |frame| { + frame["requestId"] == "during" + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) + }) + .await; + assert_eq!(refused["type"], "error", "{refused}"); + let message = refused["message"].as_str().unwrap(); assert!( message.starts_with("SESSION_RESERVED") && message .contains(freshell_freshagent::ownership_lane::LIFECYCLE_OP_GUARD_MESSAGE), - "refused while the handoff owns the native UUID: {refusal}" + "{refused}" ); - - assert!( - matches!( - ownership.fail( - "claude", - &session_id, - "test-native-handoff", - generation, - true - ), - freshell_ownership::FailOutcome::RestoredPriorOwner - ), - "fixture: the failed handoff restores the confirmed live prior" - ); - let restored = ownership.observe("claude", &session_id); - conn.send_checkpointed( - &session_id, - "send-after", + assert!(matches!( + rig.ownership + .fail("claude", &id, "native-handoff", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let restored = rig.ownership.observe("claude", &id); + let start = send_fenced( + &mut conn, + &id, + "after", "message after the handoff", - Some((restored.epoch, restored.generation)), + (restored.epoch, restored.generation), ) .await; - conn.expect_send_runs(&session_id, "send-after", 1).await; - - conn.kill(&session_id).await; + expect_turn( + &rig, + &mut conn, + &id, + start, + "after", + "message after the handoff", + 1, + ) + .await; + // These fence-only fixtures bypass the handoff runner's retained-stamp + // repair. They own their rig, so shut it down and prove its processes + // exit without pretending the complete handoff adapter ran. + let processes = rig.runtime_processes(&id); + rig.fresh_claude.shutdown().await; + processes + .wait_until_exited("the registry-transition fixture") + .await; assert_eq!( - sidecar.prompts(), - vec!["message after the handoff".to_string()], - "the message sent during the handoff never reached the sidecar" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + ["message after the handoff"] ); - assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); + assert_eq!(completions(&conn, &id).len(), 1); } } diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index a0bd283ca..d9ad96c6d 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -1,510 +1,105 @@ -//! Integration-level regression coverage for the claude `freshAgent.kill` / -//! `freshAgent.interrupt` dispatch gap (review-confirmed parity bug): the -//! `terminal.rs` `ClientMessage::FreshAgentKill` / `FreshAgentInterrupt` arms only -//! routed `codex` + `opencode` -- a claude-provider frame fell through the `if`/`else -//! if` chain and was silently dropped. `FreshClaudeState::handle_kill` already existed -//! (9eaaf122, unit-tested) but was UNREACHABLE from the real WS dispatch, and its -//! `create_dedup` cache was therefore never evicted for claude sessions. -//! -//! These tests run a REAL axum server (ephemeral loopback port, never 3001/3002) and a -//! REAL `tokio-tungstenite` client -- the same harness convention as -//! `diag01_lifecycle_events.rs`/`keepalive.rs` -- so they exercise the actual -//! `handle_client_text` dispatch rather than calling `FreshClaudeState` methods -//! directly (that unit-level coverage already exists in `claude.rs`). The claude -//! sidecar itself is swapped for a minimal scripted fake via the SAME -//! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` production env-var overrides -//! `spawn_sidecar()` already honors -- no network, no cost, no real SDK. -//! -//! The load-bearing assertion (kill): a `freshAgent.create` resent with the SAME -//! `requestId` immediately after an explicit `freshAgent.kill` for the session it -//! created must mint a **fresh** session id, not replay the killed one -- proof that -//! the kill dispatch actually reached `FreshClaudeState::handle_kill` (which evicts the -//! create-dedup cache), not just that a `freshAgent.killed` frame happened to arrive. -//! -//! The interrupt coverage mirrors what legacy does -//! (`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 -//! 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 -//! unknown session. +//! WebSocket kill and interrupt dispatch through the production Claude sidecar. +//! The contract simulator supplies the SDK boundary and records actual CLI +//! controls; every test uses an isolated rig and the real WS dispatcher. -use std::io::Write; -use std::sync::Arc; +use common::claude_rig::{ClaudeRig, RigKind, RIG_FRAME_BUDGET}; +use serde_json::json; use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; -use serde_json::Value; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -use freshell_ws::WsState; - -const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; +mod common; -/// Serializes every test in this file, all of which mutate the process-global -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` env vars (mirrors `claude.rs`'s -/// own `CLAUDE_ENV_LOCK` convention for the identical hazard). +// Retained for the existing control-outcome cases, which also install log capture. +#[cfg(unix)] static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -// ── fake claude sidecar (production wire protocol only: create/interrupt/shutdown) ── - -/// A minimal scripted fake claude sidecar -- no real `@anthropic-ai/claude-agent-sdk`, -/// no network, no cost. Speaks the SAME newline-JSON protocol `spawn_sidecar()` (the -/// production code, `crates/freshell-freshagent/src/claude.rs`) drives real -/// `crates/freshell-claude-sidecar/index.mjs` with: -/// - `{"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"). -/// - `{"type":"shutdown"}` -> exits (driven by `FreshClaudeState::handle_kill`'s -/// graceful-shutdown request). -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - } else if (msg.type === 'interrupt') { - const log = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG - 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, stopId: msg.stopId, ok: true })) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script + this test's interrupt-log file, -/// with `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE`/ -/// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` pointed at it. Caller must hold -/// [`CLAUDE_ENV_LOCK`] for the lifetime of the returned guard. -struct FakeClaudeSidecarEnv { - dir: std::path::PathBuf, - interrupt_log: std::path::PathBuf, -} -impl FakeClaudeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-sidecar-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let interrupt_log = dir.join("interrupt.log"); - std::fs::write(&interrupt_log, "").expect("init interrupt log"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); - Self { dir, interrupt_log } - } - - fn interrupt_log_contents(&self) -> String { - std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() - } -} -impl Drop for FakeClaudeSidecarEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - -// ── server harness (duplicated from diag01_lifecycle_events.rs's convention, with -// `freshAgent.enabled: true` so `freshAgent.create` actually dispatches) ── - -fn test_settings_value() -> serde_json::Value { - serde_json::json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": true, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } - }) -} - -async fn spawn_server() -> String { - let auth_token = Arc::new(AUTH_TOKEN.to_string()); - let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); - let settings = - Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); - - let state = WsState { - pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), - layout: Default::default(), - identity: freshell_ws::identity::TerminalIdentityRegistry::new(), - terminal_meta: Default::default(), - auth_token: Arc::clone(&auth_token), - server_instance_id: Arc::new("srv-test".to_string()), - boot_id: Arc::new("boot-test".to_string()), - settings, - handshake_settings: Arc::new(tokio::sync::RwLock::new( - serde_json::from_value(test_settings_value()).expect("valid settings fixture"), - )), - broadcast_tx: Arc::clone(&broadcast_tx), - auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, - auto_resume_cancels: Default::default(), - fresh_codex: freshell_freshagent::FreshCodexState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - serde_json::json!({ "freshAgent": { "enabled": true } }), - ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), - fresh_opencode: freshell_freshagent::FreshOpencodeState::new( - freshell_freshagent::FreshAgentState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - ), - ), - registry: freshell_terminal::TerminalRegistry::new(), - tabs: freshell_ws::tabs::TabsRegistry::new(), - screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), - subagent_interest: Default::default(), - host_stats: Default::default(), - checkpoint_send: Default::default(), - terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - cli_commands: Arc::new(Vec::new()), - shutdown: Arc::new(tokio::sync::Notify::new()), - ping_interval_ms: 30_000, - hello_timeout_ms: 5_000, - allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), - ws_max_payload_bytes: 16 * 1024 * 1024, - term09: freshell_ws::backpressure::Term09Config::default(), - create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), - spawn_gate: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), - shutdown_started: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - create_dedupe: std::sync::Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), - config_fallback: None, - opencode_locator: None, - codex_locator: None, - activity: None, - session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), - reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, - fresh_agent_respawn_counts: Default::default(), - ownership: None, - }; - - let router = freshell_ws::router(state); - // Ephemeral loopback port only -- NEVER the self-hosted 3001/3002 ports. - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind ephemeral loopback port"); - let addr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - - format!("ws://{addr}/ws") -} - -type TestWs = - tokio_tungstenite::WebSocketStream>; - -async fn connect_and_complete_handshake(url: &str) -> TestWs { - let (mut ws, _resp) = tokio_tungstenite::connect_async(url) - .await - .expect("ws connect"); - ws.send(WsMessage::Text( - serde_json::json!({ - "type": "hello", - "token": AUTH_TOKEN, - "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, - }) - .to_string(), - )) - .await - .expect("send hello"); - - // Drain the handshake frames (ready + whatever else precedes it) until `ready`. - loop { - let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await - .expect("handshake message within timeout") - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { - continue; - }; - let value: Value = serde_json::from_str(&text).unwrap(); - if value["type"] == "ready" { - break; - } - } - ws -} - -async fn send_json(ws: &mut TestWs, value: &Value) { - ws.send(WsMessage::Text(value.to_string())) - .await - .expect("send frame"); -} - -/// Drain frames until one matching `predicate` arrives (or the budget expires). -async fn await_frame( - ws: &mut TestWs, - budget: Duration, - predicate: impl Fn(&Value) -> bool, -) -> Value { - tokio::time::timeout(budget, async { - loop { - let msg = ws - .next() - .await - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { - continue; - }; - let value: Value = serde_json::from_str(&text).unwrap(); - if predicate(&value) { - return value; - } - } - }) - .await - .expect("expected frame did not arrive within budget") -} - -fn create_frame(request_id: &str) -> Value { - serde_json::json!({ - "type": "freshAgent.create", - "requestId": request_id, - "sessionType": "freshclaude", - "provider": "claude", - }) -} - -/// **RED before the terminal.rs fix**: a claude-provider `freshAgent.kill` frame falls -/// through the dispatch's `if is_codex_provider(...) else if ...Opencode` chain with no -/// claude arm, so `FreshClaudeState::handle_kill` never runs. Two observable -/// consequences prove the arm now fires (or don't, pre-fix): -/// 1. a `freshAgent.killed{success:true}` frame is broadcast at all (pre-fix: never -/// arrives -> this test times out), and -/// 2. THE LOAD-BEARING ASSERTION: a duplicate `freshAgent.create` resent with the SAME -/// `requestId` right after the kill mints a genuinely fresh session id, proving -/// `handle_kill`'s `create_dedup.clear_for_session` ran (pre-fix: the create-dedup -/// cache was never touched, so the duplicate replays the OLD session id verbatim -- -/// wrong even though no timeout occurs). -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_kill_frame_reaches_handle_kill_and_evicts_the_create_dedup_cache() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - let request_id = "req-claude-ws-kill-dedup"; - send_json(&mut ws, &create_frame(request_id)).await; - let created = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; - let first_session_id = created["sessionId"].as_str().unwrap().to_string(); - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.kill", - "provider": "claude", - "sessionId": first_session_id, - "sessionType": "freshclaude", - }), - ) - .await; - // Consequence 1: the `killed` broadcast must arrive at all -- pre-fix, this branch - // is never taken (silent no-op) and the test times out here. - let killed = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.killed" && v["sessionId"] == first_session_id - }) - .await; - assert_eq!(killed["success"], true, "{killed}"); - - // Consequence 2 (load-bearing): resend the SAME requestId. If the kill genuinely - // evicted the create-dedup cache, this mints a FRESH session id, not a replay of - // `first_session_id`. - send_json(&mut ws, &create_frame(request_id)).await; - let recreated = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let request = "req-claude-ws-kill-dedup"; + let first = conn.create(request).await; + let processes = rig.runtime_processes(&first); + conn.kill(&first).await; + processes + .wait_until_exited("the WS-killed sidecar and CLI") + .await; + let recreated = conn.request_create(request, json!({})).await; + assert_eq!(recreated["type"], "freshAgent.created", "{recreated}"); assert_ne!( - recreated["sessionId"], first_session_id, - "a duplicate create after an explicit claude kill must mint a fresh session, \ - not replay the killed one -- the create_dedup cache was not evicted: {recreated}" + recreated["sessionId"], first, + "kill must evict create dedup, so the same request creates a fresh runtime" ); + assert_eq!(rig.ledger_rows("query").len(), 2); + rig.fresh_claude.shutdown().await; } -/// **RED before the terminal.rs fix**: a claude-provider `freshAgent.interrupt` frame -/// falls through the same dispatch gap. This mirrors what legacy does -/// (`adapters/claude/adapter.ts:163-168` -> `server/sdk-bridge.ts:785-793`'s -/// `sp.query.interrupt().catch(warn)`, fire-and-forget with NO confirmation frame on -/// success): the observable proxy for "the interrupt actually reached the session -/// machinery" is the fake sidecar's interrupt log, since there is no `query.interrupt()` -/// broadcast to assert on (matching legacy's silence on success). -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_interrupt_frame_reaches_the_sidecar_for_a_known_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - let request_id = "req-claude-ws-interrupt"; - send_json(&mut ws, &create_frame(request_id)).await; - let created = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .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" + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("req-claude-ws-interrupt").await; + conn.send_text(&id, "req-claude-ws-send", "HANG").await; + let accepted = conn + .wait_for("the accepted turn", |frame| { + frame["type"] == "freshAgent.send.accepted" + && frame["requestId"] == "req-claude-ws-send" + }) + .await; + tokio::time::timeout(RIG_FRAME_BUDGET, async { + while !rig + .ledger_rows("prompt") + .iter() + .any(|row| row["sessionId"] == id && row["uuid"] == accepted["submittedTurnId"]) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } }) - .await; - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.interrupt", - "provider": "claude", - "sessionId": session_id, - "sessionType": "freshclaude", - }), + .await + .expect("the accepted command reached the simulated CLI"); + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"req-claude-ws-stop"}), ) .await; - - // Poll the fake sidecar's interrupt log (best-effort bounded wait -- the dispatch is - // a detached `tokio::spawn` task, same pattern as create/send/kill). Pre-fix: the - // claude branch never runs, so the sidecar never receives the interrupt request and - // this loop exhausts its budget with an empty log. - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - if sidecar.interrupt_log_contents().contains(&session_id) { - break; - } - if tokio::time::Instant::now() >= deadline { - panic!( - "the claude sidecar never received the interrupt request for {session_id} \ - within budget; log contents: {:?}", - sidecar.interrupt_log_contents() - ); - } - tokio::time::sleep(Duration::from_millis(20)).await; - } + conn.wait_for_event("freshAgent.result").await; + let controls = rig.ledger_rows("interrupt"); + assert_eq!( + controls.len(), + 1, + "the production sidecar called SDK interrupt: {controls:?}" + ); + assert_eq!(controls[0]["running"], true); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&id).await["status"], + "idle" + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 0, + "the user's own Stop ends the turn silently" + ); + rig.fresh_claude.shutdown().await; } -/// Legacy's `mapMissingResult` throw for an unavailable session -/// (`adapter.ts:163-168`, surfaced by `ws-handler.ts:3513-3514`'s `catch` as an -/// `INTERNAL_ERROR` frame) mirrored: an interrupt for a session id this process never -/// created must produce an `error` frame, not silently vanish. -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_interrupt_frame_errors_for_an_unknown_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.interrupt", - "provider": "claude", - "sessionId": "does-not-exist", - "sessionType": "freshclaude", - }), + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":"does-not-exist"}), ) .await; - - let frame = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["event"]["type"] == "freshAgent.error" - }) - .await; - assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID", "{frame}"); - assert_eq!(frame["sessionId"], "does-not-exist"); -} - -/// Sanity guard: writing to a closed log path never panics the harness (keeps clippy -/// happy about the unused `Write` import staying meaningful if the log-append strategy -/// changes; exercises the same append path production code takes). -#[test] -fn interrupt_log_append_is_plain_fs_no_special_handling_needed() { - let dir = std::env::temp_dir().join(format!( - "freshell-interrupt-log-sanity-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).unwrap(); - let log = dir.join("interrupt.log"); - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log) - .unwrap(); - writeln!(f, "sanity").unwrap(); - assert!(std::fs::read_to_string(&log).unwrap().contains("sanity")); - let _ = std::fs::remove_dir_all(&dir); + let failure = conn.wait_for_event("freshAgent.error").await; + assert_eq!(failure["event"]["code"], "INVALID_SESSION_ID", "{failure}"); + assert_eq!(failure["sessionId"], "does-not-exist"); + assert!( + rig.ledger_rows("query").is_empty(), + "an unknown Stop creates no runtime" + ); + rig.fresh_claude.shutdown().await; } -#[cfg(unix)] -mod common; - #[cfg(unix)] mod control_outcomes { use super::*; diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index f9c9f4231..b364dec26 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -23,9 +23,11 @@ mod common; +use std::sync::Arc; use std::time::Duration; use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::ledger_sink::TurnInterruptedMarkerGate; use serde_json::{json, Value}; /// How long a test keeps reading after the exit report before it counts edges: the @@ -536,6 +538,208 @@ async fn a_resume_create_answers_created_with_its_session_ref_once_the_cli_boote assert!(rig.ledger_rows("prompt").len() == 1, "no prompt was needed"); } +/// A restart records an owed turn in the persisted binding row. The first +/// successful attach after the ledger is reloaded reports that interruption +/// once, after its snapshot acknowledgement, and does not manufacture an +/// attention edge. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_owed_turn_is_reported_once_after_attach_across_a_server_restart() { + let program = json!({ "rules": [{ "on": "msg:prompt", "emit": [ + { "kind": "completion" }, + ] }] }) + .to_string(); + let marker_gate = Arc::new(TurnInterruptedMarkerGate::new()); + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + env: vec![("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program)], + turn_interrupted_marker_gate: Some(marker_gate.clone()), + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session_id = first.create("create-restart-busy").await; + first + .send_text( + &session_id, + "send-restart-busy", + "a turn interrupted by restart", + ) + .await; + first.wait_for_event("freshAgent.session.init").await; + + let row_before_shutdown = rig + .pane_ledger + .as_ref() + .expect("durable ledger enabled") + .load_binding("claude", &session_id) + .expect("fresh-agent binding exists before shutdown"); + assert!( + serde_json::to_value(&row_before_shutdown) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "a live turn is not marked interrupted before shutdown" + ); + + // Park shutdown inside its real persisted-marker write, then let the + // simulator finish the turn. The consumer must already be quiesced, so a + // result arriving during disk I/O cannot publish a completion edge while + // shutdown still persists the restart marker. + let shutdown_state = rig.fresh_claude.clone(); + let shutdown = tokio::spawn(async move { shutdown_state.shutdown().await }); + tokio::time::timeout(Duration::from_secs(5), marker_gate.wait_until_entered()) + .await + .expect("shutdown reaches the held marker write"); + let result_deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !rig + .ledger_rows("result") + .iter() + .any(|row| row["sessionId"] == session_id) + { + assert!( + tokio::time::Instant::now() < result_deadline, + "the simulated turn did not reach its result while the marker write was held" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + first.read_for(Duration::from_millis(100)).await; + let completions_while_marker_write_held = first.events_seen("freshAgent.turn.complete"); + marker_gate.release(); + shutdown.await.expect("Claude shutdown task joins"); + assert_eq!( + completions_while_marker_write_held, 0, + "a CLI result arriving during the marker write cannot publish a completion edge" + ); + + rig.restart().await; + + let row_after_reload = rig + .pane_ledger + .as_ref() + .expect("restarted rig reloads its durable ledger") + .load_binding("claude", &session_id) + .expect("fresh-agent binding survives restart"); + assert!( + serde_json::to_value(&row_after_reload) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_some_and(|value| value.as_i64().is_some()), + "shutdown persisted the owed turn marker before tearing down the sidecar" + ); + + let mut after = rig.connect().await; + after.attach(&session_id).await; + let notice = after + .wait_for("restart interruption notice", |frame| { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART" + }) + .await; + assert_eq!(notice["sessionId"], session_id, "{notice}"); + assert_eq!(notice["event"]["sessionId"], session_id, "{notice}"); + let acknowledgement_at = after + .frames + .iter() + .position(|frame| { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.session.snapshot" + && frame["event"]["sessionId"] == session_id + }) + .expect("the attach was acknowledged"); + let notice_at = after + .frames + .iter() + .position(|frame| frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .expect("the restart notice was delivered"); + assert!( + acknowledgement_at < notice_at, + "the interruption notice follows the attach acknowledgement: {:?}", + after.frames + ); + assert_eq!(after.events_seen("freshAgent.turn.complete"), 0); + assert_eq!(after.events_seen("freshAgent.turn.waiting"), 0); + + let cleared = rig + .pane_ledger + .as_ref() + .expect("durable ledger remains available") + .load_binding("claude", &session_id) + .expect("binding remains after resume"); + assert!( + serde_json::to_value(&cleared) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "delivering the restart notice consumes its persisted marker" + ); + + after.attach(&session_id).await; + after.read_for(QUIET).await; + assert_eq!( + after + .frames + .iter() + .filter(|frame| frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .count(), + 1, + "a second attach does not repeat the consumed notice: {:?}", + after.frames + ); +} + +/// An idle runtime has no owed work at shutdown, so a later attach does not +/// report an interruption. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_idle_runtime_does_not_receive_a_restart_interruption_notice() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session_id = first.create("create-restart-idle").await; + + rig.restart().await; + let row_after_reload = rig + .pane_ledger + .as_ref() + .expect("durable ledger enabled") + .load_binding("claude", &session_id) + .expect("fresh-agent binding survives restart"); + assert!( + serde_json::to_value(&row_after_reload) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "shutdown does not mark an idle session" + ); + + let mut after = rig.connect().await; + after.attach(&session_id).await; + after.read_for(QUIET).await; + assert_eq!( + after + .frames + .iter() + .filter(|frame| frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .count(), + 0, + "an idle session does not get an interruption notice: {:?}", + after.frames + ); + assert_eq!(after.events_seen("freshAgent.turn.complete"), 0); + assert_eq!(after.events_seen("freshAgent.turn.waiting"), 0); +} + // ── Registration: a session is in the map before its consumer reads a line ──────── /// How long the server is made to take between starting a session's consumer and diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index 7b3e98bc5..bb84211f0 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -55,6 +55,7 @@ async fn rig_with( hosted_claude: hosted, env: vec![("KILROY_ENABLED".into(), "1".into())], freshell_mcp: Some(Arc::clone(&cell)), + ..RigOptions::default() }, ) .await; diff --git a/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs b/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs new file mode 100644 index 000000000..959105239 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs @@ -0,0 +1,75 @@ +//! A hosted kernel follows native conversation resets across runtime recovery. + +#![cfg(unix)] // This process-crash regression uses the rig's SIGKILL probe. + +mod common; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; +use serde_json::Value; + +async fn reset(conn: &mut RigConnection, old: &str, request: &str) -> String { + let start = conn.frames.len(); + conn.send_text(old, request, "/clear").await; + let materialized = conn + .wait_for_since(start, "the reset identity", |frame| { + frame["type"] == "freshAgent.session.materialized" && frame["previousSessionId"] == old + }) + .await; + let new = materialized["sessionId"].as_str().unwrap().to_string(); + conn.wait_for_since(start, "the reset command settling", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + new +} + +async fn send_and_result(conn: &mut RigConnection, id: &str, request: &str) -> Value { + let start = conn.frames.len(); + conn.send_text(id, request, request).await; + conn.wait_for_since(start, "the submitted turn result", |frame| { + frame["event"]["type"] == "freshAgent.result" + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unwired_native_lineage_survives_two_resets_and_runtime_recovery() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + unwired_runtime: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let a = conn.create("native-lineage-create").await; + rig.converse(&mut conn, &a, &["original dialogue"]).await; + let b = reset(&mut conn, &a, "first-native-reset").await; + rig.converse(&mut conn, &b, &["middle dialogue"]).await; + let c = reset(&mut conn, &b, "second-native-reset").await; + rig.converse(&mut conn, &c, &["current dialogue"]).await; + assert_eq!(rig.ledger_rows("boot").len(), 1); + rig.kill_first_sidecar(&mut conn).await; + rig.converse(&mut conn, &c, &["recover current conversation"]) + .await; + assert_eq!(rig.ledger_rows("boot").len(), 2); + + let late_a = send_and_result(&mut conn, &a, "late-first-identity-send").await; + assert_eq!( + late_a["sessionId"], c, + "the old UUID must address the recovered writer" + ); + let late_b = send_and_result(&mut conn, &b, "late-second-identity-send").await; + assert_eq!(late_b["sessionId"], c); + assert_eq!( + rig.ledger_rows("boot").len(), + 2, + "late requests never reopen historical writers" + ); + conn.kill(&a).await; + assert!(!rig.fresh_claude.has_live_session(&c).await); + rig.fresh_claude.shutdown().await; +} diff --git a/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs b/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs new file mode 100644 index 000000000..5245ed482 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs @@ -0,0 +1,163 @@ +//! Every incoming native id follows durable supersession after a server restart. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ownership::{BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind}; +use freshell_ws::pane_ledger::{RetiredReason, RowState}; +use serde_json::json; +use std::process::Stdio; + +async fn persisted_reset_after_restart() -> (ClaudeRig, String, String) { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("persisted-native-close-create").await; + rig.converse(&mut conn, &old, &["saved before the native reset"]) + .await; + let start = conn.frames.len(); + conn.send_text(&old, "persisted-native-clear", "/clear") + .await; + let materialized = conn + .wait_for_since(start, "the native reset", |frame| { + frame["type"] == "freshAgent.session.materialized" && frame["previousSessionId"] == old + }) + .await; + let current = materialized["sessionId"].as_str().unwrap().to_string(); + assert_ne!(old, current); + conn.wait_for_since(start, "the cleared command settling", |frame| { + frame["sessionId"] == current + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + rig.converse(&mut conn, ¤t, &["saved in the current conversation"]) + .await; + rig.restart().await; + let resolution = rig + .pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", &old) + .unwrap(); + assert!(resolution.corrected); + assert_eq!(resolution.row.session_id, current); + assert!(!rig.fresh_claude.has_live_session(&old).await); + assert!(!rig.fresh_claude.has_live_session(¤t).await); + (rig, old, current) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_prior_native_id_after_restart_closes_its_reattached_current_runtime() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut conn = rig.connect().await; + conn.attach(¤t).await; + assert!(rig.fresh_claude.has_live_session(¤t).await); + let processes = rig.runtime_processes(¤t); + conn.kill(&old).await; + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", ¤t).unwrap(); + let live = rig.fresh_claude.has_live_session(¤t).await; + assert!( + !live, + "late kill({old}) left current {current} running; row={row:?}, current fence={:?}", + ledger.kill_tombstone_at("claude", ¤t) + ); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + assert!(ledger.kill_tombstone_at("claude", ¤t).is_some()); + processes + .wait_until_exited("closing a superseded native UUID") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_prior_native_close_after_restart_refuses_the_current_terminal_owner() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut terminal = tokio::process::Command::new("node") + .args(["-e", "setInterval(() => {}, 1000)"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let op = "terminal-after-native-reset"; + let BeginOutcome::Granted { generation } = rig.ownership.begin_start( + "claude", + ¤t, + RuntimeOwnerKind::Terminal, + op, + None, + "test", + freshell_ownership::now_epoch_ms(), + ) else { + panic!("the restarted coordinator has no owner") + }; + let owner = OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some(op.into()), + live_session_key: None, + pid: terminal.id(), + ownership_id: Some(op.into()), + }; + assert!(matches!( + rig.ownership + .commit_live("claude", ¤t, op, generation, owner), + CommitOutcome::Committed + )); + let before = rig.ownership.observe("claude", ¤t); + let mut conn = rig.connect().await; + let start = conn.frames.len(); + conn.send(json!({"type":"freshAgent.kill", "provider":"claude", "sessionType":"freshclaude", "sessionId":old})).await; + let answer = conn + .wait_for_since(start, "the late native close answer", |frame| { + frame["type"] == "freshAgent.killed" && frame["sessionId"] == old + }) + .await; + assert_eq!(answer["success"], false, "{answer}"); + assert_eq!(answer["code"], "FOREIGN_OWNER", "{answer}"); + assert_eq!( + rig.ownership.observe("claude", ¤t).state, + before.state + ); + assert!( + terminal.try_wait().unwrap().is_none(), + "the terminal writer survives" + ); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger.load_binding("claude", ¤t).unwrap().state, + RowState::Bound + ); + for id in [&old, ¤t] { + assert!(ledger.kill_tombstone_at("claude", id).is_none()); + } + terminal.start_kill().unwrap(); + terminal.wait().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_prior_native_id_after_restart_closes_the_untracked_bound_terminus() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut conn = rig.connect().await; + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(¤t).await); + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", ¤t).unwrap(); + assert_eq!( + row.state, + RowState::Retired, + "a closed conversation is not a recovery offer: {row:?}" + ); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + for id in [&old, ¤t] { + assert!(ledger.kill_tombstone_at("claude", id).is_some()); + } +} diff --git a/crates/freshell-ws/tests/freshagent_claude_reset.rs b/crates/freshell-ws/tests/freshagent_claude_reset.rs new file mode 100644 index 000000000..6099ba764 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_reset.rs @@ -0,0 +1,217 @@ +//! A conversation reset changes the durable identity of one live runtime. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use serde_json::json; +use std::time::Duration; + +fn dialogue_rows(text: &str) -> Vec { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .filter(|row: &serde_json::Value| row["type"] == "user" || row["type"] == "assistant") + .collect() +} + +async fn conversation_reset(kind: RigKind, drop_announcement: bool) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + env: if drop_announcement { + vec![( + "FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET".into(), + "1".into(), + )] + } else { + vec![] + }, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("create-reset").await; + rig.converse(&mut conn, &old, &["before clear"]).await; + let original_path = rig.wait_for_transcript(&old).await; + let original_text = std::fs::read_to_string(&original_path).unwrap(); + let before = conn.frames.len(); + + conn.send_text(&old, "clear-command", "/clear").await; + let init = conn + .wait_for("the post-clear init", |frame| { + frame["event"]["type"] == "freshAgent.session.init" + && frame["event"]["cliSessionId"] != old + }) + .await; + let new = init["event"]["cliSessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&new).is_ok() && new != old); + let materialized = conn.frames[before..] + .iter() + .find(|frame| frame["type"] == "freshAgent.session.materialized") + .expect("the pane must follow the reset before receiving the new init"); + assert_eq!(materialized["previousSessionId"], old); + assert_eq!(materialized["sessionId"], new); + assert_eq!( + materialized["sessionRef"], + json!({"provider":"claude", "sessionId":new}) + ); + assert_eq!( + init["sessionId"], new, + "the init is stamped with the new id" + ); + + conn.wait_for("the cleared turn's result", |frame| { + frame["sessionId"] == new && frame["event"]["type"] == "freshAgent.result" + }) + .await; + conn.wait_for("the clear command retired under the new id", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + assert_eq!(rig.fresh_claude.session_busy(&new).await, Some(false)); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger + .load_binding("claude", &old) + .unwrap() + .superseded_by + .unwrap() + .session_id, + new, + ); + assert!(ledger.load_binding("claude", &new).is_some()); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-reset") + .unwrap() + .session_id, + new, + "replaying the create must follow the same conversation reset", + ); + let (status, snapshot) = rig.snapshot(&new).await; + assert_eq!(status, 200, "{snapshot}"); + assert!(!snapshot.to_string().contains("before clear")); + + let results_before = conn.events_seen("freshAgent.result"); + let after_reset = conn.frames.len(); + conn.send_text(&new, "after-reset", "after clear").await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let frames = &conn.frames[after_reset..]; + let completed = frames.iter().any(|frame| { + frame["sessionId"] == new && frame["event"]["type"] == "freshAgent.turn.complete" + }); + let idle = frames.iter().any(|frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }); + if completed && idle { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the new conversation never finished its next turn" + ); + conn.read_for(Duration::from_millis(50)).await; + } + assert_eq!(conn.events_seen("freshAgent.result"), results_before + 1); + assert_eq!(rig.fresh_claude.session_busy(&new).await, Some(false)); + // The CLI appends queue bookkeeping to the old file during /clear. Its + // conversation messages remain intact; the next turn belongs only to B. + assert_eq!( + dialogue_rows(&std::fs::read_to_string(original_path).unwrap()), + dialogue_rows(&original_text), + ); + assert_eq!( + conn.frames[before..] + .iter() + .filter(|frame| frame["type"] == "freshAgent.session.materialized") + .count(), + 1, + "the per-turn init after reset does not rekey again", + ); + // A request issued before /clear still targets this runtime. Teardown + // must release the moved lease, so B can subsequently be resumed. + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(&new).await); + let mut resumed = rig.connect().await; + resumed.attach(&new).await; + rig.converse(&mut resumed, &new, &["after reset and resume"]) + .await; + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_moves_the_live_record_binding_and_turn_to_the_new_uuid() { + conversation_reset(RigKind::Sim, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pinned_cli_clear_then_send_keeps_the_new_conversation_working() { + conversation_reset(RigKind::Cli, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_unannounced_init_identity_change_keeps_following_the_healthy_runtime() { + conversation_reset(RigKind::Sim, true).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resumed_runtime_also_follows_an_unannounced_conversation_reset() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + env: vec![( + "FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET".into(), + "1".into(), + )], + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("create-before-resumed-reset").await; + rig.converse(&mut conn, &old, &["before resumed reset"]) + .await; + conn.kill(&old).await; + conn.attach(&old).await; + let start = conn.frames.len(); + conn.send_text(&old, "resumed-clear", "/clear").await; + let init = conn + .wait_for("resumed runtime reset init", |frame| { + frame["event"]["type"] == "freshAgent.session.init" + && frame["event"]["cliSessionId"] + .as_str() + .is_some_and(|id| id != old) + }) + .await; + let new = init["event"]["cliSessionId"].as_str().unwrap().to_string(); + assert_eq!( + init["sessionId"], new, + "a resumed consumer follows the native UUID" + ); + assert!( + conn.frames[start..].iter().any(|frame| { + frame["type"] == "freshAgent.session.materialized" + && frame["previousSessionId"] == old + && frame["sessionId"] == new + }), + "the identity correction precedes the new init" + ); + conn.wait_for("clear retires on the resumed identity", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + rig.converse(&mut conn, &new, &["after resumed reset"]) + .await; + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(&new).await); + rig.fresh_claude.shutdown().await; +} diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 336a9f992..1a8710520 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -5,16 +5,17 @@ //! naming the winner's durable session, no spawn). Expired holders are TREE-killed //! (child + ownership sweep) before any release. //! -//! Harness duplicated from `freshagent_claude_attach.rs` per the repo's per-test-file -//! convention, with the Step 1a fake-sidecar knobs: `FAKE_SIDECAR_CREATE_DELAY_MS` -//! (slow spawn), `FAKE_SIDECAR_FAIL_ONCE_MARKER` (first create exits nonzero), -//! `FAKE_SIDECAR_REQUEST_LOG` (JSONL `{pid, msg}` per inbound request — distinct pids == -//! spawn count), `FAKE_SIDECAR_SPAWN_GRANDCHILD` (tagged decoy grandchild for the -//! kill-sweep test). The codex leg points `CODEX_CMD` at the e2e fake app-server. +//! Claude sessions run the production sidecar over the contract simulator. SDK +//! initialization gates hold the winner while a competitor arrives; first-launch +//! crashes and an owned detached descendant exercise failure and expiry cleanup. +//! The Codex/OpenCode fixtures retain their provider-specific transport seams. //! //! DESTRUCTIVE SUITE: the expired-lease path kills real processes by recorded pid — //! run via `scripts/sandbox-test.sh "cargo test -p freshell-ws --test freshagent_session_lease"`. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::sync::Arc; use std::time::Duration; @@ -28,7 +29,7 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; /// Serializes every test in this file that mutates process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FAKE_SIDECAR_*` / `CODEX_CMD` / lease TTL), +/// (`CODEX_CMD` / `OPENCODE_CMD` / lease TTL), /// mirroring `freshagent_claude_attach.rs`'s convention for the same hazard. static LEASE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -114,104 +115,79 @@ fn isolated_codex_env_restores_every_mutated_variable_during_unwind() { assert_eq!(after_unwind, original); } -// ── fake claude sidecar with the Step 1a knobs ────────────────────────────────────── - -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' -import { spawn } from 'node:child_process' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} +// Production sidecar over the contract simulator; pauses/crashes stay at SDK init. +struct LeaseClaudeRuntime { + runtime: ClaudeSimEnvironment, + prior_ttl: Option, } -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - const failOnce = process.env.FAKE_SIDECAR_FAIL_ONCE_MARKER || '' - if (failOnce && !fs.existsSync(failOnce)) { - fs.writeFileSync(failOnce, '1') - process.exit(1) - } - if (process.env.FAKE_SIDECAR_SPAWN_GRANDCHILD === '1') { - // Detached decoy grandchild inheriting env (so /proc//environ carries the - // ownership tag the kill sweep scans for). It IGNORES SIGTERM (an ignored signal - // stays ignored across exec), so the claude lane's own one-shot SIGTERM reap of - // its tagged tree cannot end it: only the expired-lease sweep's SIGKILL - // escalation can. It is logged only once the shell has armed the ignore. - const gc = spawn('sh', ['-c', 'trap "" TERM; echo armed; exec sleep 300 >/dev/null'], { - detached: true, - stdio: ['ignore', 'pipe', 'ignore'], - }) - gc.stdout.once('data', () => { - gc.stdout.destroy() - logReq({ type: 'grandchild', grandchildPid: gc.pid }) - }) - gc.unref() - } - counter += 1 - const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` - const reply = () => { - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || sessionId - 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') +impl LeaseClaudeRuntime { + fn new() -> Self { + Self { + runtime: ClaudeSimEnvironment::new(), + prior_ttl: std::env::var_os("FRESHELL_FRESH_AGENT_LEASE_TTL_MS"), + } } - const delayMs = Number(process.env.FAKE_SIDECAR_CREATE_DELAY_MS || 0) - if (delayMs > 0) setTimeout(reply, delayMs) - else reply() - } else if (msg.type === 'send') { - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'busy' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; -/// Fresh temp dir holding the knob-extended fake sidecar; installs the env vars. -/// Caller must hold [`LEASE_ENV_LOCK`] for the guard's lifetime. -struct FakeLeaseSidecarEnv { - dir: std::path::PathBuf, -} + fn hold_initialization(&self) { + std::fs::write(self.runtime.dir().join("initialize-gate"), "").unwrap(); + } -impl FakeLeaseSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-lease-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } + fn release_initialization(&self) { + std::fs::remove_file(self.runtime.dir().join("initialize-gate")).unwrap(); } - fn request_log_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("request log row parses")) - .collect() + fn environment(&self) -> Vec<(String, String)> { + let adapter = common::claude_rig::repo_root() + .join("test/integration/providers/claude-sdk-lease-runtime.mjs"); + let mut environment = self.runtime.environment(); + environment.retain(|(key, _)| key != "FRESHELL_CLAUDE_SDK_QUERY_MODULE"); + environment.extend([ + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + freshell_platform::opencode_plugin::plugin_file_spec(&adapter), + ), + ( + "FRESHELL_TEST_CLAUDE_FIRST_LAUNCH".into(), + self.runtime + .dir() + .join("first-launch") + .to_string_lossy() + .into(), + ), + ( + "FRESHELL_TEST_CLAUDE_INITIALIZATION_GATE".into(), + self.runtime + .dir() + .join("initialize-gate") + .to_string_lossy() + .into(), + ), + ]); + if self.runtime.dir().join("crash-first").exists() { + environment.push(("FRESHELL_TEST_CLAUDE_CRASH_FIRST".into(), "1".into())); + } + if self.runtime.dir().join("spawn-decoy").exists() { + environment.push(("FRESHELL_TEST_CLAUDE_DECOY".into(), "1".into())); + } + environment } fn create_rows(&self) -> Vec { - self.request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .collect() + self.runtime.rows("query") + } + + async fn await_row(&self, kind: &str) -> Value { + tokio::time::timeout(common::FRAME_BUDGET, async { + loop { + if let Some(row) = self.runtime.rows(kind).into_iter().next() { + return row; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("the real SDK did not record {kind}")) } /// SIGKILL every decoy grandchild this env's sidecars logged that is still @@ -219,19 +195,16 @@ impl FakeLeaseSidecarEnv { /// single-pid kill of its sidecar), so nothing else stops it when a test /// ends before the ownership sweep ran — or when a later sidecar spawned /// one of its own. Each pid is pinned through a pidfd and must carry this - /// env's unique request-log path in `/proc//environ`, so a recycled + /// env's unique runtime guard path in `/proc//environ`, so a recycled /// pid is never signalled. #[cfg(target_os = "linux")] fn kill_logged_decoys(&self) { let tag = format!( - "FAKE_SIDECAR_REQUEST_LOG={}", - self.dir.join("requests.jsonl").display() + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD={}", + self.runtime.dir().join("active").display() ); - for row in self.request_log_rows() { - if row["msg"]["type"] != "grandchild" { - continue; - } - let Some(pid) = row["msg"]["grandchildPid"].as_i64() else { + for row in self.runtime.rows("decoy") { + let Some(pid) = row["childPid"].as_i64() else { continue; }; // SAFETY: plain syscalls on an owned descriptor; the pidfd is @@ -257,39 +230,16 @@ impl FakeLeaseSidecarEnv { unsafe { libc::close(pidfd) }; } } - - /// Poll the request log until `pred` matches a row (or the budget expires). - async fn await_log_row(&self, budget: Duration, pred: impl Fn(&Value) -> bool) -> Value { - let deadline = tokio::time::Instant::now() + budget; - loop { - if let Some(row) = self.request_log_rows().into_iter().find(&pred) { - return row; - } - assert!( - tokio::time::Instant::now() < deadline, - "request-log row did not appear within budget" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - } - } } -impl Drop for FakeLeaseSidecarEnv { +impl Drop for LeaseClaudeRuntime { fn drop(&mut self) { #[cfg(target_os = "linux")] self.kill_logged_decoys(); - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - "FAKE_SIDECAR_CREATE_DELAY_MS", - "FAKE_SIDECAR_FAIL_ONCE_MARKER", - "FAKE_SIDECAR_SPAWN_GRANDCHILD", - "FRESHELL_FRESH_AGENT_LEASE_TTL_MS", - ] { - std::env::remove_var(var); + match &self.prior_ttl { + Some(value) => std::env::set_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS", value), + None => std::env::remove_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS"), } - let _ = std::fs::remove_dir_all(&self.dir); } } @@ -325,11 +275,22 @@ fn test_settings_value() -> serde_json::Value { } async fn spawn_server() -> String { + spawn_server_with_claude_environment(None).await +} + +async fn spawn_server_with_claude_environment( + environment: Option>, +) -> String { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); + let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } + let state = WsState { layout: Default::default(), terminal_meta: Default::default(), @@ -350,7 +311,7 @@ async fn spawn_server() -> String { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude, fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -490,19 +451,19 @@ fn claude_create_resume(request_id: &str, durable: &str) -> Value { #[tokio::test] async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); // second create lands mid-resume + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d1d1d1d1-d1d1-4d1d-8d1d-d1d1d1d1d1d1"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; send_json(&mut ws_a, &claude_create_resume("req-lease-a", durable)).await; // Gate on the sidecar RECEIVING the create (the winner provably holds the lease and // is mid-delay) before the loser fires. - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; send_json(&mut ws_b, &claude_create_resume("req-lease-b", durable)).await; let failed = await_frame(&mut ws_b, Duration::from_secs(10), |v| { @@ -512,16 +473,16 @@ async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { assert_eq!(failed["code"], "SESSION_RESERVED"); assert_eq!(failed["retryable"], true); + env.release_initialization(); // The winner binds. let created = await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-lease-a" }) .await; - assert!(created["sessionId"].is_string()); + assert_eq!(created["sessionId"], durable); // Loser re-sends the SAME create (~the client's 1s floor): the ADOPT arm answers it // naming the winner's durable session -- and NO second sidecar spawned. - tokio::time::sleep(Duration::from_millis(200)).await; send_json(&mut ws_b, &claude_create_resume("req-lease-b", durable)).await; let adopted = await_frame(&mut ws_b, Duration::from_secs(10), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-lease-b" @@ -549,12 +510,12 @@ async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { #[tokio::test] async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - let marker = env.dir.join("fail-once-marker"); - std::env::set_var("FAKE_SIDECAR_FAIL_ONCE_MARKER", &marker); + let env = LeaseClaudeRuntime::new(); + std::fs::write(env.runtime.dir().join("crash-first"), "").unwrap(); let durable = "d2d2d2d2-d2d2-4d2d-8d2d-d2d2d2d2d2d2"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; @@ -575,7 +536,17 @@ async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { v["type"] == "freshAgent.created" && v["requestId"] == "req-die-b" }) .await; - assert!(created["sessionId"].is_string()); + assert_eq!(created["sessionId"], durable); + let queries = env.create_rows(); + assert_eq!( + queries.len(), + 2, + "failed and replacement SDK queries: {queries:?}" + ); + assert_ne!(queries[0]["pid"], queries[1]["pid"]); + assert!(queries + .iter() + .all(|row| row["options"]["resume"] == durable)); } /// DESIGN DECISION (owner ratification flagged in the plan's "Validated decisions"): @@ -588,17 +559,17 @@ async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { #[tokio::test] async fn lease_applies_to_legacy_clients_and_retry_converges() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d3d3d3d3-d3d3-4d3d-8d3d-d3d3d3d3d3d3"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, false).await; // legacy: no capabilities in hello let mut ws_b = connect(&url, false).await; send_json(&mut ws_a, &claude_create_resume("req-legacy-a", durable)).await; - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; send_json(&mut ws_b, &claude_create_resume("req-legacy-b", durable)).await; let failed = await_frame(&mut ws_b, Duration::from_secs(10), |v| { @@ -608,6 +579,7 @@ async fn lease_applies_to_legacy_clients_and_retry_converges() { assert_eq!(failed["code"], "SESSION_RESERVED"); assert_eq!(failed["retryable"], true); + env.release_initialization(); await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-legacy-a" }) @@ -724,36 +696,38 @@ async fn codex_loser_create_resume_adopts_and_never_clobbers_the_winner() { #[tokio::test] async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); + let env = LeaseClaudeRuntime::new(); std::env::set_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS", "100"); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "5000"); - std::env::set_var("FAKE_SIDECAR_SPAWN_GRANDCHILD", "1"); + env.hold_initialization(); + std::fs::write(env.runtime.dir().join("spawn-decoy"), "").unwrap(); let durable = "d4d4d4d4-d4d4-4d4d-8d4d-d4d4d4d4d4d4"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; // Client A claims + spawns (kill handle set), never completes within the 100ms TTL. send_json(&mut ws_a, &claude_create_resume("req-ttl-a", durable)).await; - let grandchild = env - .await_log_row(Duration::from_secs(10), |r| { - r["msg"]["type"] == "grandchild" - }) - .await; - let gpid = grandchild["msg"]["grandchildPid"] - .as_i64() - .expect("grandchild pid logged"); + let grandchild = env.await_row("decoy").await; + let gpid = grandchild["childPid"].as_i64().expect("decoy pid logged"); assert!( std::path::Path::new(&format!("/proc/{gpid}")).exists(), "decoy grandchild must be alive before expiry" ); - // A's sidecar captured its environment at spawn. Client B's replacement sidecar - // must be a plain one: no decoy of its own (nothing would ever reap it — the - // detached `sleep 300` outlived every run) and no artificial create delay on the - // path whose convergence this test times. - std::env::remove_var("FAKE_SIDECAR_SPAWN_GRANDCHILD"); - std::env::remove_var("FAKE_SIDECAR_CREATE_DELAY_MS"); + let boot = env.await_row("boot").await; + assert_eq!(boot["sessionId"], durable); + let old_processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + gpid as u64, + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); + assert!(old_processes + .iter() + .all(common::process_probe::ProcessProbe::running)); + // Faults apply only to the first real query. The replacement performs a + // normal SDK initialization and never spawns another detached decoy. // Let the TTL expire, then client B contends -> ExpiredNeedsKill -> tree kill. tokio::time::sleep(Duration::from_millis(300)).await; send_json(&mut ws_b, &claude_create_resume("req-ttl-b", durable)).await; @@ -770,27 +744,25 @@ async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { outcome["type"], "freshAgent.created", "client B's create must converge after the expired holder's tree kill: {outcome}" ); - assert!(outcome["sessionId"].is_string()); - - // The DECOY GRANDCHILD was dead (ownership sweep / tree-kill) BEFORE client B's - // created landed: the release follows the sweep's confirmation, so it is already - // dead now — no polling. NOTE: a dead-but-unreaped ZOMBIE keeps its /proc entry (it - // reparents to the container's pid 1, which never reaps it), so "dead" is read from - // the stat STATE — gone, Z, or X — not from /proc existence. - let grandchild_dead = |pid: i64| -> bool { - let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { - return true; // gone entirely - }; - matches!( - stat.rsplit(')') - .next() - .and_then(|rest| rest.split_whitespace().next()), - Some("Z") | Some("X") | None - ) - }; + assert_eq!(outcome["sessionId"], durable); + + // All old writers are already dead at the new create ACK: release must + // follow tree confirmation, including the real simulated CLI and decoy. assert!( - grandchild_dead(gpid), - "grandchild survived B's convergence: a raw single-pid kill orphans the writer (V6)" + old_processes + .iter() + .all(common::process_probe::ProcessProbe::exited), + "an old sidecar/CLI/decoy survived replacement ACK" + ); + assert_eq!( + env.runtime.rows("decoy").len(), + 1, + "replacement has no extra decoy" + ); + assert_eq!( + env.create_rows().len(), + 2, + "one expired and one replacement query" ); } @@ -803,18 +775,18 @@ async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { #[tokio::test] async fn loser_attach_after_winner_binds_converges_to_the_live_session() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d5d5d5d5-d5d5-4d5d-8d5d-d5d5d5d5d5d5"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; // Winner: create-with-resume, slow spawn (holds the lease mid-flight). send_json(&mut ws_a, &claude_create_resume("req-att-a", durable)).await; - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; // Loser: attach for the SAME durable id mid-spawn. let attach = serde_json::json!({ @@ -837,6 +809,7 @@ async fn loser_attach_after_winner_binds_converges_to_the_live_session() { ); // Winner binds. + env.release_initialization(); await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-att-a" }) @@ -1070,8 +1043,8 @@ async fn opencode_attach_resume_is_serialized_without_touching_the_shared_sideca #[tokio::test] async fn legacy_reject_freshagent_create() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - let url = spawn_server().await; + let env = LeaseClaudeRuntime::new(); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws = connect(&url, true).await; // ejh6 presence (finding 9): rejection fires for any string, including "". diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 301ac9976..3cd8e30af 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -13,6 +13,9 @@ //! in-request dedupe, the respawn cap + reset-on-live, and the G3 //! supersession-chain reader rule end-to-end through the real ledger. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -28,12 +31,6 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; -/// Serializes every test in this file that mutates the process-global -/// `FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR` -/// env vars, mirroring `freshagent_claude_attach.rs`'s convention for the -/// same hazard. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - // ── scripted disk-truth probe ───────────────────────────────────────────────── #[derive(Default)] @@ -66,89 +63,6 @@ impl freshell_ws::existence::SessionExistenceProbe for StubProbe { } } -// ── fake claude sidecar (donor: freshagent_claude_attach.rs) ────────────────── - -/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) -/// speaking 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 (or a fixed durable UUID the -/// tests control) as the durable `cliSessionId`, then `sdk.status idle`; on -/// `shutdown` it exits. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - 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 === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// The fixed durable `cliSessionId` the fake sidecar mints on a plain (non -/// resume) `create` — the id the live-session tests key their probe on. -const FAKE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - -/// A fresh temp dir holding the fake sidecar script, with -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` pointed at it, PLUS an -/// empty claude store with `CLAUDE_CONFIG_DIR` pointed at it (so no test ever -/// touches the real home). Caller must hold [`CLAUDE_ENV_LOCK`] for the -/// lifetime of the returned guard. -struct FakeClaudeEnv { - dir: std::path::PathBuf, -} -impl FakeClaudeEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-reconcile-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let store = dir.join("claude-store"); - std::fs::create_dir_all(&store).expect("create claude store dir"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -impl Drop for FakeClaudeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - fn test_settings_value() -> serde_json::Value { serde_json::json!({ "ai": {}, @@ -192,6 +106,13 @@ struct Server { /// so tests can seed and assert deterministically (the §9.1 headless /// convention). async fn spawn_server_with_probe(probe: Arc) -> Server { + spawn_server_with_claude_environment(probe, None).await +} + +async fn spawn_server_with_claude_environment( + probe: Arc, + environment: Option>, +) -> Server { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = @@ -204,6 +125,11 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { ))); let respawn_counts: Arc>> = Arc::default(); + let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } + let state = WsState { layout: Default::default(), terminal_meta: Default::default(), @@ -224,7 +150,7 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude, fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -389,28 +315,71 @@ async fn await_frame( .expect("expected frame did not arrive within budget") } -/// Drive a claude `freshAgent.create` through the fake sidecar and return the -/// durable `cliSessionId` from the `freshAgent.session.init` event. -async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { - ws.send(WsMessage::Text( - serde_json::json!({ - "type": "freshAgent.create", - "requestId": request_id, - "sessionType": "freshclaude", - "provider": "claude", - }) - .to_string(), - )) - .await - .expect("send freshAgent.create"); - let init = await_frame(ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" +/// Create or resume through the production sidecar. Native identity is established +/// before ACK; the CLI emits its init only after an actual prompt (T3). +async fn create_live_claude_session( + ws: &mut TestWs, + environment: &ClaudeSimEnvironment, + request_id: &str, + resume: Option<&str>, +) -> String { + let mut request = serde_json::json!({ + "type": "freshAgent.create", "requestId": request_id, + "sessionType": "freshclaude", "provider": "claude", + "cwd": environment.workspace(), + }); + if let Some(id) = resume { + request["sessionRef"] = serde_json::json!({"provider":"claude", "sessionId":id}); + } + ws.send(WsMessage::Text(request.to_string())).await.unwrap(); + let created = await_frame(ws, common::FRAME_BUDGET, |v| { + v["requestId"] == request_id + && matches!( + v["type"].as_str(), + Some("freshAgent.created" | "freshAgent.create.failed" | "error") + ) }) .await; - init["event"]["cliSessionId"] - .as_str() - .expect("session.init carries the durable cliSessionId") - .to_string() + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let id = created["sessionId"].as_str().unwrap().to_string(); + assert!( + uuid::Uuid::parse_str(&id).is_ok(), + "native conversation UUID: {created}" + ); + assert_eq!(created["sessionRef"]["sessionId"], id); + assert!(environment + .rows("boot") + .iter() + .any(|row| row["sessionId"] == id)); + id +} + +async fn kill_live_claude_session(ws: &mut TestWs, environment: &ClaudeSimEnvironment, id: &str) { + let boot = environment + .rows("boot") + .into_iter() + .rev() + .find(|row| row["sessionId"] == id) + .unwrap(); + let processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); + ws.send(WsMessage::Text(serde_json::json!({ + "type":"freshAgent.kill", "provider":"claude", "sessionType":"freshclaude", "sessionId":id + }).to_string())).await.unwrap(); + let killed = await_frame(ws, common::FRAME_BUDGET, |v| { + v["type"] == "freshAgent.killed" && v["sessionId"] == id + }) + .await; + assert_eq!(killed["success"], true, "{killed}"); + for process in processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "owned runtime survived kill" + ); + } } // --- negotiation --------------------------------------------------------------- @@ -499,14 +468,16 @@ async fn fresh_agent_verdicts_cover_the_four_states() { #[tokio::test] async fn live_fresh_agent_session_gets_attach() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeEnv::install(); - - let server = spawn_server().await; + let environment = ClaudeSimEnvironment::new(); + let server = spawn_server_with_claude_environment( + Arc::new(StubProbe::default()), + Some(environment.environment()), + ) + .await; let (mut ws, _ready) = connect(&server.url, true, true).await; - let cli_session_id = create_live_claude_session(&mut ws, "req-live-attach").await; - assert_eq!(cli_session_id, FAKE_DURABLE_ID); + let cli_session_id = + create_live_claude_session(&mut ws, &environment, "req-live-attach", None).await; let verdicts = reconcile_request( &mut ws, @@ -519,6 +490,7 @@ async fn live_fresh_agent_session_gets_attach() { assert_eq!(verdicts[0]["verdict"], "attach"); assert_eq!(verdicts[0]["sessionRef"]["sessionId"], cli_session_id); assert!(verdicts[0].get("terminalId").is_none()); + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; } /// WAVE-B B1xB4 seam pin (V9 3.6): a mixed request whose TERMINAL pane @@ -648,16 +620,29 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { // Reset-on-live (V2/A7): a successful respawn is OBSERVED as the session // going live; the counter must clear so healthy sessions are never // exhausted by reconnect/reload storms. - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeEnv::install(); - - let probe = std::sync::Arc::new(StubProbe::default()); + let environment = ClaudeSimEnvironment::new(); + let probe = Arc::new(StubProbe::default()); + let server = + spawn_server_with_claude_environment(Arc::clone(&probe), Some(environment.environment())) + .await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let cli_session_id = + create_live_claude_session(&mut ws, &environment, "req-counter-original", None).await; + // Materialize a real saved conversation, then close it so reconcile sees a + // resumable session with no live runtime before the subsequent resume. + ws.send(WsMessage::Text(serde_json::json!({ + "type":"freshAgent.send", "requestId":"counter-first-turn", "provider":"claude", + "sessionType":"freshclaude", "sessionId":cli_session_id, "text":"remember this conversation" + }).to_string())).await.unwrap(); + await_frame(&mut ws, common::FRAME_BUDGET, |v| { + v["sessionId"] == cli_session_id && v["event"]["type"] == "freshAgent.result" + }) + .await; + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; probe.answers.lock().unwrap().insert( - ("claude".into(), FAKE_DURABLE_ID.into()), + ("claude".into(), cli_session_id.clone()), SessionExistence::Present, ); - let server = spawn_server_with_probe(probe).await; - let (mut ws, _ready) = connect(&server.url, true, true).await; // TWO reconcile requests while the session is NOT yet live → both respawn. for i in 0..2 { @@ -665,7 +650,7 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { &mut ws, serde_json::json!([ { "paneKey": "p", "kind": "fresh-agent", - "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + "sessionRef": {"provider": "claude", "sessionId": cli_session_id} } ]), ) .await; @@ -676,22 +661,27 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { .respawn_counts .lock() .unwrap() - .get(&("claude".to_string(), FAKE_DURABLE_ID.to_string())) + .get(&("claude".to_string(), cli_session_id.clone())) .copied(), Some(2), "two respawn answers burned two" ); - // Drive freshAgent.create through the fake sidecar so has_live_session - // becomes true for the durable id. - let cli_session_id = create_live_claude_session(&mut ws, "req-reset-on-live").await; - assert_eq!(cli_session_id, FAKE_DURABLE_ID); + // Resume the same saved conversation through the real SDK boundary. + let resumed = create_live_claude_session( + &mut ws, + &environment, + "req-reset-on-live", + Some(&cli_session_id), + ) + .await; + assert_eq!(resumed, cli_session_id); let verdicts = reconcile_request( &mut ws, serde_json::json!([ { "paneKey": "p", "kind": "fresh-agent", - "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + "sessionRef": {"provider": "claude", "sessionId": cli_session_id} } ]), ) .await; @@ -701,9 +691,10 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { .respawn_counts .lock() .unwrap() - .contains_key(&("claude".to_string(), FAKE_DURABLE_ID.to_string())), + .contains_key(&("claude".to_string(), cli_session_id.clone())), "counter cleared (not merely un-incremented) when presence resolved Live" ); + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; } #[tokio::test] @@ -985,7 +976,7 @@ fn log_capture() -> Arc>> { #[tokio::test] async fn dead_session_verdict_is_warn_logged_with_claimed_identity() { let events = log_capture(); - let marker = uuid_like_suffix(); + let marker = uuid::Uuid::new_v4().to_string(); let pane_key = format!("deadpane-{marker}"); let session_id = format!("gone-{marker}"); let probe = std::sync::Arc::new(StubProbe::default()); diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index cfef38980..8e4b2a766 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -54,6 +54,12 @@ fn unique_temp_dir(label: &str) -> std::path::PathBuf { /// and the REST pane-ops surface resolve the same store), exactly like /// `main.rs`. async fn spawn_merged_server() -> Harness { + spawn_merged_server_with_claude_environment(None).await +} + +async fn spawn_merged_server_with_claude_environment( + environment: Option>, +) -> Harness { let _ = common::isolate_amplifier_home(); let ledger_dir = unique_temp_dir("ledger"); let signal_root = unique_temp_dir("signals"); @@ -76,6 +82,9 @@ async fn spawn_merged_server() -> Harness { let layout_store = freshell_freshagent::layout_store::LayoutStore::default(); let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } fresh_claude.set_ownership(Arc::clone(&ownership)); let fresh_agent_state = freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -566,92 +575,7 @@ async fn rest_claude_resume_create_writes_identity_row_and_ledger_binding() { // ── kata b8ke Task 10: REST + WS + browser-shaped parity on ONE coordinator ── -// The lease-suite fake claude sidecar (request-log knob), duplicated -// per-file per the cross_kind_liveness.rs convention: sets -// FRESHELL_CLAUDE_SIDECAR/FRESHELL_CLAUDE_NODE so a `freshAgent.create` -// over WS genuinely establishes a live freshclaude owner, and logs every -// sidecar request for the no-second-runtime assertions. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - 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 === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// The fake sidecar env (cross_kind_liveness.rs's `FakeSidecarEnv`, copied -/// per the per-file convention). Only the Task 10 parity test below reads -/// the env vars, so no cross-test env locking is needed within THIS file. -struct FakeSidecarEnv { - dir: std::path::PathBuf, -} - -impl FakeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "rest-claude-identity-r10-sidecar-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } - } - - fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") - .collect() - } -} - -impl Drop for FakeSidecarEnv { - fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } - let _ = std::fs::remove_dir_all(&self.dir); - } -} +use common::claude_sim_environment::ClaudeSimEnvironment; /// Read text frames until one with `type == wanted` arrives (bounded). async fn next_frame_of_type(ws: &mut common::TestWs, wanted: &str) -> serde_json::Value { @@ -683,22 +607,23 @@ async fn next_frame_of_type(ws: &mut common::TestWs, wanted: &str) -> serde_json /// second runtime. #[tokio::test(flavor = "multi_thread")] async fn rest_ws_and_browser_paths_share_the_one_coordinator() { - let sidecar = FakeSidecarEnv::install(); - let mut h = spawn_merged_server().await; - let sid = format!("r10-parity-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let mut h = spawn_merged_server_with_claude_environment(Some(sidecar.environment())).await; // 1. A live freshclaude owner for (claude, sid), established over WS. h.ws.send(WsMessage::Text( json!({ "type": "freshAgent.create", "requestId": "req-r10-parity-1", "sessionType": "freshclaude", "provider": "claude", - "sessionRef": { "provider": "claude", "sessionId": sid }, + "cwd": sidecar.workspace().to_string_lossy(), }) .to_string(), )) .await .unwrap(); - let _created = next_frame_of_type(&mut h.ws, "freshAgent.created").await; + let created = next_frame_of_type(&mut h.ws, "freshAgent.created").await; + let sid = created["sessionId"].as_str().unwrap().to_string(); + uuid::Uuid::parse_str(&sid).expect("the native create-time UUID"); // 2. The REST terminal door: POST /api/tabs for the same sessionRef is // the typed 409 carrying the fresh-agent owner fields. @@ -798,11 +723,24 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { .count(); assert_eq!(ptys_for_sid, 0, "no second writer for {sid}"); let sidecar_creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["sessionId"].as_str() == Some(sid.as_str())) .count(); assert_eq!(sidecar_creates, 1, "exactly the one freshclaude owner"); + let boots = sidecar.rows("boot"); + assert_eq!(boots.len(), 1); + assert_eq!(boots[0]["sessionId"], sid); + let processes = [ + boots[0]["pid"].as_u64().unwrap(), + boots[0]["cliPid"].as_u64().unwrap(), + ] + .map(|pid| { + ( + pid, + common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap()), + ) + }); // Tear the freshclaude owner down through its own lane so the sidecar // child does not outlive the harness. @@ -815,6 +753,13 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { )) .await .unwrap(); - let _killed = next_frame_of_type(&mut h.ws, "freshAgent.killed").await; + let killed = next_frame_of_type(&mut h.ws, "freshAgent.killed").await; + assert_eq!(killed["success"], true, "{killed}"); + for (pid, process) in processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "the owned sidecar/CLI process {pid} survived its acknowledged kill" + ); + } cleanup(&h); } diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs new file mode 100644 index 000000000..18c262dd5 --- /dev/null +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -0,0 +1,856 @@ +//! The default REST/MCP door controls the real WebSocket Claude runtime. +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_freshagent::rest_lane::{ + FreshAgentRestLane, RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneSend, + RestLaneSendResult, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn options() -> RigOptions { + RigOptions { + rest_api: true, + durable_ledger: true, + ..Default::default() + } +} + +struct PausedRealCreate { + inner: Arc, + started: tokio::sync::Notify, + finish: tokio::sync::Notify, + receipt: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for PausedRealCreate { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let created = self.inner.clone().create_agent(request.clone()).await?; + *self.receipt.lock().unwrap() = Some((request, created.clone())); + self.started.notify_one(); + self.finish.notified().await; + Ok(created) + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + self.inner.send_agent(request).await + } + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + self.inner.abandon_create(request, created).await + } +} + +#[derive(Clone, Copy)] +enum CancelRealCreate { + Owned, + Adopted, + CloseWriteFailure, +} + +async fn close_after_real_create(case: CancelRealCreate) { + let adopt_existing = matches!(case, CancelRealCreate::Adopted); + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..Default::default() + }, + ) + .await; + let mut browser = rig.connect().await; + let existing = if adopt_existing { + Some(browser.create("existing-visible-runtime").await) + } else { + None + }; + let lane = Arc::new(PausedRealCreate { + inner: Arc::new(freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + rig.fresh_claude.clone(), + Arc::new(|| true), + )), + started: Default::default(), + finish: Default::default(), + receipt: Default::default(), + }); + let state = freshell_freshagent::FreshAgentState::new( + Arc::new(common::AUTH_TOKEN.into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = freshell_freshagent::router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + .unwrap() + }); + let create_base = base.clone(); + let mut body = json!({"agent":"claude", "cwd":rig.workspace.to_str().unwrap()}); + if let Some(id) = existing.as_deref() { + body["sessionRef"] = json!({"provider":"claude", "sessionId":id}); + } + let creating = tokio::spawn(async move { + ClaudeRig::request_json_to("POST", &create_base, "/api/tabs", &body).await + }); + tokio::time::timeout(RIG_FRAME_BUDGET, lane.started.notified()) + .await + .unwrap(); + let (request, receipt) = lane.receipt.lock().unwrap().clone().unwrap(); + assert_eq!( + receipt.cleanup_token.is_none(), + adopt_existing, + "only a newly started runtime belongs to cancellation" + ); + let processes = rig.runtime_processes(&receipt.session_id); + // This is the isolated rig's actual close journal. A regular file at the + // directory path causes a real, root-independent atomic-write failure. + let journal = rig + .workspace + .parent() + .unwrap() + .join("pane-ledger/close-envelopes"); + let saved_journal = journal.with_extension("saved"); + let had_journal = journal.exists(); + if matches!(case, CancelRealCreate::CloseWriteFailure) { + if had_journal { + std::fs::rename(&journal, &saved_journal).unwrap(); + } + std::fs::write(&journal, b"injected close-journal failure").unwrap(); + } + let (status, closed) = ClaudeRig::request_json_to( + "DELETE", + &base, + &format!("/api/tabs/{}", request.tab_id.as_deref().unwrap()), + &json!({}), + ) + .await; + assert_eq!(status, 200, "{closed}"); + lane.finish.notify_one(); + let (status, cancelled) = creating.await.unwrap(); + if matches!(case, CancelRealCreate::CloseWriteFailure) { + assert_eq!(status, 503, "{cancelled}"); + assert_eq!(cancelled["code"], "CLAUDE_CLEANUP_UNCONFIRMED"); + assert_eq!(cancelled["retryable"], true); + assert!( + rig.fresh_claude.has_live_session(&receipt.session_id).await, + "an uncommitted close must not claim the writer was stopped" + ); + std::fs::remove_file(&journal).unwrap(); + if had_journal { + std::fs::rename(saved_journal, &journal).unwrap(); + } + lane.inner.abandon_create(&request, &receipt).await.unwrap(); + } else { + assert_eq!(status, 409, "{cancelled}"); + assert_eq!(cancelled["code"], "PANE_CREATE_CANCELLED"); + } + if adopt_existing { + assert!(rig.fresh_claude.has_live_session(&receipt.session_id).await); + browser + .send_text( + &receipt.session_id, + "still-owned-by-existing-pane", + "the existing writer survives the cancelled adoption", + ) + .await; + browser.wait_for_event("freshAgent.result").await; + assert_eq!(rig.query_options().len(), 1); + } else { + processes + .wait_until_exited("the cancelled REST create's owned runtime") + .await; + assert!(!rig.fresh_claude.has_live_session(&receipt.session_id).await); + } + rig.fresh_claude.shutdown().await; + let _ = shutdown.send(()); + server.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_create_reaps_its_real_sidecar_and_cli() { + close_after_real_create(CancelRealCreate::Owned).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_adoption_keeps_the_existing_real_writer_alive() { + close_after_real_create(CancelRealCreate::Adopted).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_create_reports_failed_cleanup_and_can_retry_after_repair() { + close_after_real_create(CancelRealCreate::CloseWriteFailure).await; +} + +async fn get(rig: &ClaudeRig, path: &str) -> (u16, String) { + let host = rig.http_url.strip_prefix("http://").unwrap(); + let mut stream = tokio::net::TcpStream::connect(host).await.unwrap(); + stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nx-auth-token: {}\r\nConnection: close\r\n\r\n", common::AUTH_TOKEN).as_bytes()).await.unwrap(); + let mut raw = Vec::new(); + tokio::time::timeout(RIG_FRAME_BUDGET, stream.read_to_end(&mut raw)) + .await + .unwrap() + .unwrap(); + let response = String::from_utf8(raw).unwrap(); + let (head, body) = response.split_once("\r\n\r\n").unwrap(); + ( + head.split_whitespace().nth(1).unwrap().parse().unwrap(), + body.to_string(), + ) +} + +async fn create(rig: &ClaudeRig, agent: &str) -> Value { + let (status, body) = rig + .post_json( + "/api/tabs", + &json!({"agent":agent, "cwd":rig.workspace.to_str().unwrap()}), + ) + .await; + assert_eq!(status, 200, "{body}"); + body["data"].clone() +} + +async fn send(rig: &ClaudeRig, pane: &str, text: &str) -> Value { + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":text, "requestId":"rest-request", "timeout":5}), + ) + .await; + assert_eq!(status, 200, "{body}"); + body["data"].clone() +} + +async fn wait_for_saved_turn(rig: &ClaudeRig, id: &str, submitted: &str) { + // The pinned CLI flushes transcript lines after their SDK frames (T3). + // A completed REST send promises turn settlement; capture reads the saved + // history, so observe this exact turn on disk before asserting its text. + let path = rig.wait_for_transcript(id).await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let user = lines + .iter() + .rposition(|line| line["type"] == "user" && line["uuid"] == submitted); + if user.is_some_and(|at| { + lines[at + 1..] + .iter() + .any(|line| line["type"] == "assistant") + }) { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "submitted turn {submitted} never reached {}", + path.display() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_create_send_capture_and_ws_create_replay_share_one_writer() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut observer = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + assert!( + uuid::Uuid::parse_str(id).is_ok(), + "REST exposes the durable UUID immediately: {created}" + ); + assert_eq!( + created["sessionRef"], + json!({"provider":"claude", "sessionId":id}) + ); + let ws_created = observer + .wait_for("REST-created session", |frame| { + frame["type"] == "freshAgent.created" && frame["sessionId"] == id + }) + .await; + let command = observer + .wait_for("REST tab command", |frame| { + frame["type"] == "ui.command" && frame["command"] == "tab.create" + }) + .await; + let content = &command["payload"]["paneContent"]; + assert_eq!(content["createRequestId"], ws_created["requestId"]); + assert_eq!(content["sessionId"], id); + assert_eq!(content["model"], "opus[1m]"); + assert_eq!(content["permissionMode"], "bypassPermissions"); + assert_eq!(content["effort"], "high"); + assert_eq!(content["status"], "connected"); + let mut second_browser = rig.connect().await; + let replay = second_browser + .request_create( + content["createRequestId"].as_str().unwrap(), + json!({"paneId":pane, "tabId":created["tabId"]}), + ) + .await; + assert_eq!(replay["sessionId"], id); + assert_eq!( + rig.query_options().len(), + 1, + "REST and browser deduplicate at the same runtime create ingress" + ); + let (status, empty) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{empty}"); + assert!(empty.is_empty()); + let sent = send(&rig, pane, "héllo\r").await; + assert_eq!(sent["status"], "idle"); + let prompts = rig.ledger_rows("prompt"); + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0]["text"], "héllo"); + assert_eq!(prompts[0]["model"], "opus[1m]"); + assert_eq!(prompts[0]["effort"], "high"); + assert_eq!(prompts[0]["permissionMode"], "bypassPermissions"); + assert_eq!(sent["submittedTurnId"], prompts[0]["uuid"]); + assert_ne!(sent["submittedTurnId"], "rest-request"); + wait_for_saved_turn(&rig, id, sent["submittedTurnId"].as_str().unwrap()).await; + observer + .wait_for("REST turn completion", |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" && frame["sessionId"] == id + }) + .await; + let (status, text) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{text}"); + assert!(text.contains("user: héllo"), "{text}"); + assert!(text.contains("assistant: ok"), "{text}"); + let (status, bounded) = get(&rig, &format!("/api/panes/{pane}/capture?maxBytes=8")).await; + assert_eq!(status, 200); + assert!(bounded.len() <= 8); + assert!(!bounded.is_empty()); + let noop = send(&rig, pane, "\r\n").await; + assert_eq!(noop["status"], "noop"); + assert!(noop.get("submittedTurnId").is_none()); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + send(&rig, pane, "second\r\n").await; + assert_eq!( + rig.ledger_rows("prompt")[1]["text"], + "second\r", + "strip exactly one final line terminator" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_split_uses_kilroy_and_rolls_back_a_disabled_launch() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut observer = rig.connect().await; + let original = create(&rig, "claude").await; + let source = original["paneId"].as_str().unwrap(); + let (status, split) = rig.post_json(&format!("/api/panes/{source}/split"), &json!({"agent":"kilroy", "cwd":rig.workspace.to_str().unwrap(), "direction":"vertical"})).await; + assert_eq!(status, 200, "{split}"); + let pane = split["data"]["paneId"].as_str().unwrap(); + let command = observer + .wait_for("Kilroy split command", |frame| { + frame["type"] == "ui.command" && frame["command"] == "pane.split" + }) + .await; + assert_eq!(command["payload"]["newContent"]["sessionType"], "kilroy"); + assert_eq!(command["payload"]["newContent"]["provider"], "claude"); + assert!(command["payload"]["newContent"] + .get("namingHandle") + .is_none()); + let sent = send(&rig, pane, "kilroy hello").await; + assert_eq!(sent["status"], "idle"); + wait_for_saved_turn( + &rig, + sent["sessionId"].as_str().unwrap(), + sent["submittedTurnId"].as_str().unwrap(), + ) + .await; + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!(capture.contains("kilroy hello"), "{capture}"); + observer.read_for(Duration::from_millis(50)).await; + let announcements = observer + .frames + .iter() + .filter(|frame| frame["type"] == "ui.command") + .count(); + let (_, before) = get(&rig, "/api/layout/snapshot").await; + rig.fresh_codex.set_enabled(false); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{source}/split"), + &json!({"agent":"claude"}), + ) + .await; + assert_eq!(status, 403, "{refused}"); + assert_eq!(refused["code"], "FRESH_AGENT_DISABLED"); + observer.read_for(Duration::from_millis(50)).await; + assert_eq!( + observer + .frames + .iter() + .filter(|frame| frame["type"] == "ui.command") + .count(), + announcements, + "a failed launch announces no pane create or split" + ); + let (_, after) = get(&rig, "/api/layout/snapshot").await; + let before: Value = serde_json::from_str(&before).unwrap(); + let after: Value = serde_json::from_str(&after).unwrap(); + assert_eq!( + after["data"]["layouts"], before["data"]["layouts"], + "failed split leaves the previous layout intact" + ); + let (status, refused) = rig.post_json("/api/tabs", &json!({"agent":"claude"})).await; + assert_eq!(status, 403, "{refused}"); + assert_eq!(rig.query_options().len(), 2); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_controls_a_browser_created_pane_and_capture_survives_server_restart() { + let mut rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let answer = browser + .request_create( + "browser-create", + json!({"tabId":"browser-tab", "paneId":"browser-pane"}), + ) + .await; + assert_eq!(answer["type"], "freshAgent.created", "{answer}"); + let id = answer["sessionId"].as_str().unwrap(); + browser.send(json!({ + "type":"ui.layout.sync", "tabs":[{"id":"browser-tab"}], "activeTabId":"browser-tab", + "layouts":{"browser-tab":{"type":"leaf", "id":"browser-pane", "content":{ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":id, + "sessionRef":{"provider":"claude", "sessionId":id}, "createRequestId":"browser-create", + }}}, "activePane":{"browser-tab":"browser-pane"}, "timestamp":1, + })).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("layout sync ordering barrier", |frame| { + frame["type"] == "pong" + }) + .await; + let sent = send(&rig, "browser-pane", "browser pane via REST").await; + assert_eq!(sent["status"], "idle"); + wait_for_saved_turn(&rig, id, sent["submittedTurnId"].as_str().unwrap()).await; + let (status, before) = get(&rig, "/api/panes/browser-pane/capture").await; + assert_eq!(status, 200, "{before}"); + assert!( + before.contains("browser pane via REST"), + "capture after settlement: {before:?}; prompts: {:?}", + rig.ledger_rows("prompt") + ); + rig.restart().await; + let (status, after) = get(&rig, "/api/panes/browser-pane/capture").await; + assert_eq!(status, 200, "{after}"); + assert_eq!(after, before, "the restored layout resolves durable transcript capture without a REST pane row or live runtime"); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_deadline_is_approx_and_a_runtime_exit_is_a_retryable_conflict() { + let mut config = options(); + config.env.push(( + "FRESHELL_CLAUDE_SIM_PROGRAM".into(), + json!({"rules":[{ + "on":"msg:prompt", "match":{"text":"crash"}, "emit":[{"kind":"crash"}], + }]}) + .to_string(), + )); + let rig = ClaudeRig::start_with_options(RigKind::Sim, config).await; + let created = create(&rig, "claude").await; + let pane = created["paneId"].as_str().unwrap(); + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"HANG", "timeout":1}), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "approx"); + assert_eq!(body["data"]["status"], "approx"); + assert!(body["data"]["submittedTurnId"].as_str().is_some()); + let crashing = create(&rig, "claude").await; + let pane = crashing["paneId"].as_str().unwrap(); + let (status, ended) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"crash", "timeout":10}), + ) + .await; + assert_eq!(status, 409, "{ended}"); + assert_eq!(ended["code"], "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(ended["retryable"], true); + assert!(ended["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_does_not_reopen_an_explicitly_closed_session() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + browser.kill(id).await; + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"a late prompt must stay unsent"}), + ) + .await; + assert_eq!(status, 409, "{refused}"); + assert_eq!(refused["code"], "SESSION_CLOSED"); + assert_eq!(refused["retryable"], false); + assert!(refused.get("retryAfterMs").is_none(), "{refused}"); + assert_eq!(rig.query_options().len(), 1); + assert!(rig.ledger_rows("prompt").is_empty()); + assert!(!rig.fresh_claude.has_live_session(id).await); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_browser_close_before_its_debounce_releases_the_rest_split_publication() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let original = create(&rig, "claude").await; + let source = original["paneId"].as_str().unwrap(); + let tab = original["tabId"].as_str().unwrap(); + let (_, snapshot) = get(&rig, "/api/layout/snapshot").await; + let snapshot: Value = serde_json::from_str(&snapshot).unwrap(); + let before_split = json!({ + "type":"ui.layout.sync", "tabs":snapshot["data"]["tabs"], "activeTabId":tab, + "layouts":snapshot["data"]["layouts"], "activePane":snapshot["data"]["activePane"], "timestamp":1, + }); + browser.send(before_split.clone()).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("initial layout sync", |frame| frame["type"] == "pong") + .await; + let (status, split) = rig + .post_json( + &format!("/api/panes/{source}/split"), + &json!({"agent":"kilroy"}), + ) + .await; + assert_eq!(status, 200, "{split}"); + let pane = split["data"]["paneId"].as_str().unwrap(); + let announced = browser + .wait_for("the unobserved split announcement", |frame| { + frame["command"] == "pane.split" + }) + .await; + let create_id = announced["payload"]["newContent"]["createRequestId"] + .as_str() + .unwrap(); + browser + .send(json!({"type":"pane.closed", "createRequestId":create_id})) + .await; + let closed = browser + .wait_for("the close acknowledgement", |frame| { + frame["type"] == "pane.closed.result" && frame["createRequestId"] == create_id + }) + .await; + assert_eq!(closed["success"], true, "{closed}"); + browser.frames.clear(); + browser.send(before_split).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("closed layout sync", |frame| frame["type"] == "pong") + .await; + let (status, listed) = get(&rig, &format!("/api/panes?tabId={tab}")).await; + assert_eq!(status, 200, "{listed}"); + let listed: Value = serde_json::from_str(&listed).unwrap(); + assert_eq!( + listed["data"]["panes"].as_array().unwrap().len(), + 1, + "{listed}" + ); + assert_eq!(listed["data"]["panes"][0]["id"], source); + assert_ne!(listed["data"]["panes"][0]["id"], pane); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_reports_unreadable_storage_as_retryable_and_can_retry_after_repair() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let first = send(&rig, pane, "the existing saved conversation").await; + wait_for_saved_turn(&rig, id, first["submittedTurnId"].as_str().unwrap()).await; + rig.wait_for_genesis_cleared(id).await; + rig.kill_first_sidecar(&mut browser).await; + let saved = rig.claude_config_dir.with_extension("saved"); + std::fs::rename(&rig.claude_config_dir, &saved).unwrap(); + std::fs::create_dir_all(&rig.claude_config_dir).unwrap(); + // An actual traversal failure remains an error even when tests run as root. + std::os::unix::fs::symlink("projects", rig.claude_config_dir.join("projects")).unwrap(); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"continue after storage is repaired"}), + ) + .await; + assert_eq!(status, 503, "{refused}"); + assert_eq!(refused["code"], "CLAUDE_ATTACH_RESUME_FAILED"); + assert_eq!(refused["retryable"], true); + assert!(refused["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + assert_eq!( + rig.query_options().len(), + 1, + "unchecked history never starts a replacement" + ); + assert_eq!( + rig.ledger_rows("prompt").len(), + 1, + "the refused prompt was not written" + ); + std::fs::rename( + &rig.claude_config_dir, + rig.claude_config_dir.with_extension("unreadable"), + ) + .unwrap(); + std::fs::rename(saved, &rig.claude_config_dir).unwrap(); + let retried = send(&rig, pane, "continue after storage is repaired").await; + assert_eq!(retried["sessionId"], id); + wait_for_saved_turn(&rig, id, retried["submittedTurnId"].as_str().unwrap()).await; + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!( + capture.contains("the existing saved conversation"), + "{capture}" + ); + assert!( + capture.contains("continue after storage is repaired"), + "{capture}" + ); + assert_eq!(rig.query_options().len(), 2); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_reports_a_positively_missing_conversation_as_permanent_not_found() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let first = send(&rig, pane, "the conversation that later goes missing").await; + wait_for_saved_turn(&rig, id, first["submittedTurnId"].as_str().unwrap()).await; + rig.wait_for_genesis_cleared(id).await; + rig.kill_first_sidecar(&mut browser).await; + std::fs::rename( + rig.claude_config_dir.join("projects"), + rig.claude_config_dir.with_extension("projects-saved"), + ) + .unwrap(); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"never replace missing saved history"}), + ) + .await; + assert_eq!(status, 404, "{refused}"); + assert_eq!(refused["code"], "INVALID_SESSION_ID"); + assert_eq!(refused["retryable"], false); + assert!(refused.get("retryAfterMs").is_none(), "{refused}"); + assert_eq!(rig.query_options().len(), 1); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn actual_fenced_send_refusal_reports_its_owner_and_keeps_the_prompt_unsent() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let owner = rig.fresh_claude.ownership_snapshot("claude", id); + assert!( + owner.generation > 0, + "the runtime owns a committed generation" + ); + let route = format!("/api/panes/{pane}/send-keys"); + let (status, refused) = rig.post_json(&route, &json!({ + "data":"stale prompt", "observedEpoch":owner.epoch, "observedGeneration":owner.generation - 1, + })).await; + assert_eq!(status, 409, "{refused}"); + assert_eq!(refused["code"], "SESSION_RESERVED"); + assert_eq!(refused["retryable"], true); + assert_eq!(refused["ownerKind"], "fresh-agent"); + assert_eq!(refused["ownerEpoch"], owner.epoch); + assert_eq!(refused["ownerGeneration"], owner.generation); + assert!(refused["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + assert!(rig.ledger_rows("prompt").is_empty()); + let (status, sent) = rig.post_json(&route, &json!({ + "data":"current prompt", "observedEpoch":owner.epoch, "observedGeneration":owner.generation, "timeout":5, + })).await; + assert_eq!(status, 200, "{sent}"); + assert_eq!(sent["data"]["status"], "idle"); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + assert_eq!(rig.ledger_rows("prompt")[0]["text"], "current prompt"); + rig.fresh_claude.shutdown().await; +} + +async fn clear_rekeys_the_headless_rest_pane(kind: RigKind) { + let rig = ClaudeRig::start_with_options(kind, options()).await; + let created = create(&rig, "claude").await; + let old_id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let route = format!("/api/panes/{pane}/send-keys"); + let first = send(&rig, pane, "before clear conversation").await; + wait_for_saved_turn(&rig, old_id, first["submittedTurnId"].as_str().unwrap()).await; + let (_, old_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert!( + old_capture.contains("before clear conversation"), + "{old_capture}" + ); + let (status, cleared) = rig + .post_json( + &route, + &json!({"data":"/clear", "requestId":"rest-request", "timeout":15}), + ) + .await; + assert_eq!(status, 200, "{cleared}"); + assert_eq!(cleared["data"]["status"], "idle", "{cleared}"); + let new_id = cleared["data"]["sessionId"].as_str().unwrap(); + assert_ne!(new_id, old_id, "clear chooses a new conversation"); + assert!(uuid::Uuid::parse_str(new_id).is_ok()); + assert_eq!( + cleared["data"]["sessionRef"], + json!({"provider":"claude", "sessionId":new_id}) + ); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + let fresh_capture = loop { + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!( + !capture.contains("before clear conversation"), + "the new conversation must never expose its parent's dialogue: {capture}" + ); + if capture.trim() == "user: /clear" { + break capture; + } + assert!( + tokio::time::Instant::now() < deadline, + "the CLI's buffered local-command rows never reached capture: {capture}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!( + fresh_capture.trim(), + "user: /clear", + "the new saved history holds only its own local command" + ); + let next = send(&rig, pane, "after clear conversation").await; + assert_eq!(next["status"], "idle"); + assert_eq!(next["sessionId"], new_id); + wait_for_saved_turn(&rig, new_id, next["submittedTurnId"].as_str().unwrap()).await; + let (status, next_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{next_capture}"); + assert!( + next_capture.contains("after clear conversation"), + "{next_capture}" + ); + assert!( + !next_capture.contains("before clear conversation"), + "{next_capture}" + ); + let old_path = rig.wait_for_transcript(old_id).await; + let old_lines: Vec = std::fs::read_to_string(old_path) + .unwrap() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert!(old_lines.iter().any(|line| line["type"] == "user")); + assert!( + old_lines.iter().any(|line| line["type"] == "assistant"), + "clear preserves the previous conversation" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_sim_rekeys_the_headless_rest_pane_before_capture_and_next_send() { + clear_rekeys_the_headless_rest_pane(RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_pinned_cli_rekeys_the_headless_rest_pane_before_capture_and_next_send() { + clear_rekeys_the_headless_rest_pane(RigKind::Cli).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_timed_out_send_reports_the_accepted_replacement_and_updates_headless_capture() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let created = create(&rig, "claude").await; + let original = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + for text in ["kept timeout conversation", "removed timeout conversation"] { + let sent = send(&rig, pane, text).await; + wait_for_saved_turn(&rig, original, sent["submittedTurnId"].as_str().unwrap()).await; + } + // A protocol client performs the undo but never mirrors a browser layout. + // The REST pane still addresses A when the runtime supersedes it with B. + let mut control = rig.connect().await; + let owner = rig.fresh_claude.ownership_snapshot("claude", original); + control.send(json!({ + "type":"freshAgent.undo", "provider":"claude", "sessionType":"freshclaude", "sessionId":original, + "requestId":"undo-before-timeout", "mode":"step", "observedEpoch":owner.epoch, "observedGeneration":owner.generation, + })).await; + let ack = control + .wait_for("undo replacement", |frame| { + frame["event"]["requestId"] == "undo-before-timeout" + && matches!( + frame["event"]["type"].as_str(), + Some("freshAgent.rolledBack" | "freshAgent.error") + ) + }) + .await; + assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); + let replacement = ack["event"]["newSessionId"].as_str().unwrap(); + assert_ne!(replacement, original); + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"HANG", "timeout":1}), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["data"]["status"], "approx"); + assert_eq!(body["data"]["sessionId"], replacement); + assert_eq!(body["data"]["sessionRef"]["sessionId"], replacement); + let (status, captured) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{captured}"); + assert!(captured.contains("kept timeout conversation"), "{captured}"); + assert!( + !captured.contains("removed timeout conversation"), + "{captured}" + ); + rig.fresh_claude.shutdown().await; +} diff --git a/docs/index.html b/docs/index.html index 58123c64b..4cc28a20f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -477,6 +477,9 @@ .fresh-minimap-tick { position: absolute; left: 0; right: 0; height: 3px; border-radius: 2px; background: rgba(127, 127, 127, 0.45); } .fresh-turn { border-left: 2px solid hsl(var(--border)); padding-left: 10px; margin-bottom: 14px; } .fresh-turn.user { border-left-color: hsl(var(--foreground)); } +.fresh-turn-actions { display: flex; justify-content: flex-end; margin-top: 8px; } +.fresh-turn-action { display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border: 1px solid hsl(var(--border)); border-radius: 5px; background: transparent; color: hsl(var(--muted-foreground)); font: inherit; font-size: 11px; } +.fresh-turn-action:disabled { opacity: .5; } .fresh-role { font-size: 11px; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 6px; } .fresh-copy { font-size: 16px; line-height: 1.25; color: hsl(var(--foreground)); } .fresh-activity { font: 11px 'JetBrains Mono', monospace; color: hsl(var(--muted-foreground)); margin-bottom: 6px; } @@ -1026,6 +1029,7 @@

Task Board

Readsrc/store/store.ts
✓Editsrc/store/store.ts
+
Renamed the slice and its two imports. Running the store tests next, so nothing still points at the old name
diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts index 49778baba..88a3b3002 100644 --- a/scripts/run-standard-tests.ts +++ b/scripts/run-standard-tests.ts @@ -1,6 +1,6 @@ #!/usr/bin/env tsx -import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' import { availableParallelism, constants as osConstants, setPriority } from 'node:os' import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' @@ -418,15 +418,16 @@ function resolveExistingLocalOnlyDirs(): string[] { function dispatchCloudVitest(cloudScript: string, forwardedArgs: string[]): Promise { return new Promise((resolveDispatch, rejectDispatch) => { - execFile( + const child = spawn( cloudScript, ['run', '--cloud', '--config=default', ...forwardedArgs], - { stdio: 'inherit', cwd: PROJECT_ROOT, env: process.env }, - (error) => { - if (error) rejectDispatch(error) - else resolveDispatch() - }, + { stdio: 'inherit', cwd: PROJECT_ROOT, env: process.env, windowsHide: true }, ) + child.once('error', rejectDispatch) + child.once('exit', (code, signal) => { + if (code === 0) resolveDispatch() + else rejectDispatch(new Error(`Cloud Vitest phase exited with code ${code ?? 1}${signal ? ` (${signal})` : ''}`)) + }) }) } diff --git a/scripts/testing/run-rust-tests.ts b/scripts/testing/run-rust-tests.ts index 3064fd983..5676725c9 100644 --- a/scripts/testing/run-rust-tests.ts +++ b/scripts/testing/run-rust-tests.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { createHash } from 'node:crypto' import { spawn, type ChildProcess } from 'node:child_process' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -10,6 +11,8 @@ const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../..') const SANDBOX_CARGO_BUILD_JOBS = 3 const SANDBOX_RUST_TEST_THREADS = 4 const SANDBOX_TOKIO_WORKER_THREADS = 2 +// All worktrees mount at /workspace; choose the namespace on the host first. +const SANDBOX_TARGET_DIRECTORY = `/freshell-test-artifacts/targets/${createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 12)}` function log(severity: 'info' | 'error', event: string, fields: Record = {}): void { const stream = severity === 'error' ? process.stderr : process.stdout @@ -116,6 +119,7 @@ owned_runtime_tmp="$(mktemp -d /freshell-test-artifacts/XXX)" printf '{"severity":"info","event":"rust_test_temp_created","directory":"%s","runtimeDirectory":"%s"}\\n' "$owned_tmp" "$owned_runtime_tmp" # Preserve every test while bounding independent libtest/Tokio runtimes within # the sandbox's existing PID budget. Runtime construction precedes fixture locks. +export CARGO_TARGET_DIR=${shellQuote(SANDBOX_TARGET_DIRECTORY)} export TMPDIR="$owned_runtime_tmp" FRESHELL_TEST_EXECUTABLE_TMP_DIR="$owned_tmp" FRESHELL_RUST_TEST_SANDBOX=0 CARGO_BUILD_JOBS=${SANDBOX_CARGO_BUILD_JOBS} RUST_TEST_THREADS=${SANDBOX_RUST_TEST_THREADS} TOKIO_WORKER_THREADS=${SANDBOX_TOKIO_WORKER_THREADS} node --import tsx /workspace/scripts/testing/run-rust-tests.ts ${argv.map(shellQuote).join(' ')} & runner_pid=$! @@ -125,16 +129,17 @@ wait "$runner_pid"` export async function main(argv: string[] = process.argv.slice(2)): Promise { if (process.env.FRESHELL_RUST_TEST_SANDBOX === '1') { log('info', 'rust_test_sandbox_route_selected', { - mode: 'runtime-suite', cargoBuildJobs: SANDBOX_CARGO_BUILD_JOBS, + mode: 'runtime-suite', cargoTargetDirectory: SANDBOX_TARGET_DIRECTORY, cargoBuildJobs: SANDBOX_CARGO_BUILD_JOBS, rustTestThreads: SANDBOX_RUST_TEST_THREADS, tokioWorkerThreads: SANDBOX_TOKIO_WORKER_THREADS, forwardedArgumentCount: argv.length, }) - // Do not inherit a host binary selection. The unchanged inner runner - // selects its own freshly built /workspace/target/debug server. + // The inner runner selects the server it builds in the sandbox's + // worktree-specific Cargo target directory. const { FRESHELL_SERVER_BIN: _hostBinary, ...env } = process.env return runChild(path.join(PROJECT_ROOT, 'scripts/sandbox-test.sh'), ['--runtime-suite', sandboxCommand(argv)], { ...env, FRESHELL_SANDBOX_OWNER_GUARD: '1' }, true) } - const binary = path.join(PROJECT_ROOT, 'target', 'debug', `freshell-server${process.platform === 'win32' ? '.exe' : ''}`) + const targetDirectory = path.resolve(PROJECT_ROOT, process.env.CARGO_TARGET_DIR || 'target') + const binary = path.join(targetDirectory, 'debug', `freshell-server${process.platform === 'win32' ? '.exe' : ''}`) const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo' const buildExitCode = await runChild(cargo, ['build', '-p', 'freshell-server', '--locked'], process.env) if (buildExitCode !== 0) return buildExitCode diff --git a/scripts/testing/runtime-build-preflight.ts b/scripts/testing/runtime-build-preflight.ts new file mode 100644 index 000000000..1fccc66c5 --- /dev/null +++ b/scripts/testing/runtime-build-preflight.ts @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export const RUNTIME_BUILD_MANIFEST_ENV = 'FRESHELL_RUNTIME_TEST_BUILD_MANIFEST' +export type RuntimeBinary = 'supervisor-test' | 'host-test' | 'supervisor-release' | 'host-release' + | 'supervisor-qualification' | 'server-managed-provider-qualification' | 'server-managed-fresh-agent-fixtures' +export type RuntimeBuildRecipe = 'test-hosts' | 'release-hosts' | 'qualification-supervisor' | 'qualification-server' | 'fresh-fixture-server' +export type RuntimeBuildCommand = { program: string; args: string[] } + +type BuildManifest = { + version: 1 + repoRoot: string + revision: string + binaries: Partial> +} + +const recipes: Record> }> = { + 'test-hosts': { + args: ['-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], + profile: 'debug', outputs: { 'supervisor-test': 'freshell-supervisor', 'host-test': 'freshell-session-host' }, + }, + 'release-hosts': { + args: ['--release', '-p', 'freshell-supervisor', '-p', 'freshell-session-host'], + profile: 'release', outputs: { 'supervisor-release': 'freshell-supervisor', 'host-release': 'freshell-session-host' }, + }, + 'qualification-supervisor': { + args: ['--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification'], + profile: 'release', outputs: { 'supervisor-qualification': 'freshell-supervisor' }, + }, + 'qualification-server': { + args: ['--release', '-p', 'freshell-server', '--features', 'managed-provider-qualification'], + profile: 'release', outputs: { 'server-managed-provider-qualification': 'freshell-server' }, + }, + 'fresh-fixture-server': { + args: ['--release', '-p', 'freshell-server', '--features', 'managed-fresh-agent-fixtures'], + profile: 'release', outputs: { 'server-managed-fresh-agent-fixtures': 'freshell-server' }, + }, +} + +/** Match discovered files, so focused unit runs and cloud images prepare nothing. */ +export function runtimeBuildRecipesForFiles(repoRoot: string, files: string[], env: NodeJS.ProcessEnv): RuntimeBuildRecipe[] { + const selected = new Set(files.map(file => path.relative(repoRoot, file).split(path.sep).join('/'))) + const contract = selected.has('test/integration/server/provider-parity-contract.test.ts') + const terminal = contract || selected.has('test/integration/server/managed-provider-parity.test.ts') + const fresh = contract || selected.has('test/integration/server/fresh-agent-parity.test.ts') + const crash = env.FRESHELL_RUNTIME_PHASE3_STARTUP_CRASH_SMOKE === '1' + && selected.has('test/integration/server/testing/runtime-phase3-startup-crash.real.test.ts') + if (!terminal && !fresh && !crash) return [] + return [ + 'test-hosts', 'release-hosts', + ...(terminal ? ['qualification-supervisor', 'qualification-server'] as const : []), + ...(fresh ? ['fresh-fixture-server'] as const : []), + ] +} + +function revision(repoRoot: string): string { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim() +} + +function readManifest(repoRoot: string, env: NodeJS.ProcessEnv): BuildManifest | undefined { + const file = env[RUNTIME_BUILD_MANIFEST_ENV] + if (!file) return undefined + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as BuildManifest + if (manifest.version !== 1 || manifest.repoRoot !== fs.realpathSync(repoRoot) || manifest.revision !== revision(repoRoot)) { + throw new Error('prepared runtime build manifest belongs to another checkout or revision') + } + return manifest +} + +export function preparedRuntimeBinary(repoRoot: string, binary: RuntimeBinary, env: NodeJS.ProcessEnv = process.env): string | undefined { + const manifest = readManifest(repoRoot, env) + if (!manifest) return undefined + const file = manifest.binaries[binary] + if (!file) throw new Error(`runtime binary variant was not prepared: ${binary}`) + if (!path.isAbsolute(file) || !fs.statSync(file).isFile()) throw new Error(`prepared runtime binary is not a file: ${binary}`) + fs.accessSync(file, fs.constants.X_OK) + return file +} + +export function hasPreparedRuntimeBuilds(repoRoot: string, selected: RuntimeBuildRecipe[], env: NodeJS.ProcessEnv): boolean { + if (!readManifest(repoRoot, env)) return false + for (const recipe of selected) for (const binary of Object.keys(recipes[recipe].outputs) as RuntimeBinary[]) { + preparedRuntimeBinary(repoRoot, binary, env) + } + return true +} + +/** Build before suite hooks and freeze each variant before Cargo overwrites it. */ +export function prepareRuntimeBuilds( + repoRoot: string, + selected: RuntimeBuildRecipe[], + { env = process.env, command = { program: path.join(os.homedir(), '.local/bin/mise'), args: ['exec', 'rust@1.96', '--', 'cargo'] } }: { + env?: NodeJS.ProcessEnv + command?: RuntimeBuildCommand + } = {}, +): { manifestPath: string; cleanup: () => void } { + const root = fs.realpathSync(repoRoot) + const directory = path.join(root, '.runtime-build', `preflight-${randomUUID()}`) + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }) + const manifest: BuildManifest = { version: 1, repoRoot: root, revision: revision(root), binaries: {} } + const target = path.resolve(root, env.CARGO_TARGET_DIR || 'target') + const jobs = Math.max(1, Math.min(3, Number.parseInt(env.CARGO_BUILD_JOBS || '3', 10) || 3)) + const cleanup = () => fs.rmSync(directory, { recursive: true, force: true }) + let activeRecipe: RuntimeBuildRecipe | undefined + try { + for (const name of selected) { + activeRecipe = name + const recipe = recipes[name] + const started = Date.now() + process.stderr.write(`${JSON.stringify({ severity: 'info', event: 'runtime_build.preflight.started', at: new Date().toISOString(), recipe: name })}\n`) + execFileSync(command.program, [...command.args, 'build', '--locked', ...recipe.args], { + cwd: root, env: { ...env, CARGO_BUILD_JOBS: String(jobs) }, stdio: 'inherit', timeout: 900_000, + }) + for (const [binary, crate] of Object.entries(recipe.outputs)) { + const destination = path.join(directory, `${binary}${process.platform === 'win32' ? '.exe' : ''}`) + fs.copyFileSync(path.join(target, recipe.profile, `${crate}${process.platform === 'win32' ? '.exe' : ''}`), destination) + fs.chmodSync(destination, 0o755) + manifest.binaries[binary as RuntimeBinary] = destination + } + process.stderr.write(`${JSON.stringify({ severity: 'info', event: 'runtime_build.preflight.completed', at: new Date().toISOString(), recipe: name, durationMs: Date.now() - started })}\n`) + } + const manifestPath = path.join(directory, 'manifest.json') + fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 }) + return { manifestPath, cleanup } + } catch (error) { + process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'runtime_build.preflight.failed', at: new Date().toISOString(), recipe: activeRecipe, error: String(error) })}\n`) + cleanup() + throw error + } +} diff --git a/scripts/testing/runtime-sandbox.ts b/scripts/testing/runtime-sandbox.ts index 306bc0e68..d5d7eebe3 100644 --- a/scripts/testing/runtime-sandbox.ts +++ b/scripts/testing/runtime-sandbox.ts @@ -7,6 +7,7 @@ import os from 'node:os' import path from 'node:path' import { configuredAmplifierOnecliGrantFiles } from './runtime-amplifier-onecli.js' +import { preparedRuntimeBinary } from './runtime-build-preflight.js' import { parseHelperCreateDelay, RestrictedDockerBroker, type BrokerEvent, type BrokerReceipt } from './runtime-test-broker.js' export const PHASE1_RUNTIME_IMAGE_TAG = 'ubuntu:24.04' @@ -1278,18 +1279,28 @@ export class RuntimeHarness { private buildBinaries(): void { fs.mkdirSync(this.buildDir, { recursive: true }) - const mise = path.join(os.homedir(), '.local', 'bin', 'mise') - execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], { cwd: this.repoRoot, stdio: 'inherit' }) this.testSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-test') this.testHostBinary = path.join(this.buildDir, 'freshell-session-host-test') + this.releaseSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-release') + this.releaseHostBinary = path.join(this.buildDir, 'freshell-session-host-release') + const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-test') + if (prepared) { + for (const [source, destination] of [ + [prepared, this.testSupervisorBinary], + [preparedRuntimeBinary(this.repoRoot, 'host-test')!, this.testHostBinary], + [preparedRuntimeBinary(this.repoRoot, 'supervisor-release')!, this.releaseSupervisorBinary], + [preparedRuntimeBinary(this.repoRoot, 'host-release')!, this.releaseHostBinary], + ]) fs.copyFileSync(source, destination) + return + } + const mise = path.join(os.homedir(), '.local', 'bin', 'mise') + execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], { cwd: this.repoRoot, stdio: 'inherit' }) fs.copyFileSync(path.join(this.repoRoot, 'target/debug/freshell-supervisor'), this.testSupervisorBinary) fs.copyFileSync(path.join(this.repoRoot, 'target/debug/freshell-session-host'), this.testHostBinary) fs.chmodSync(this.testSupervisorBinary, 0o755) fs.chmodSync(this.testHostBinary, 0o755) execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', '-p', 'freshell-session-host'], { cwd: this.repoRoot, stdio: 'inherit' }) - this.releaseSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-release') - this.releaseHostBinary = path.join(this.buildDir, 'freshell-session-host-release') fs.copyFileSync(path.join(this.repoRoot, 'target/release/freshell-supervisor'), this.releaseSupervisorBinary) fs.copyFileSync(path.join(this.repoRoot, 'target/release/freshell-session-host'), this.releaseHostBinary) fs.chmodSync(this.releaseSupervisorBinary, 0o755) diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts index 433576e0b..c9341fd90 100644 --- a/shared/fresh-agent.ts +++ b/shared/fresh-agent.ts @@ -218,8 +218,8 @@ export type FreshAgentIdentityFold = { * with the pane provider, and classify placeholder (a DURABLE locator means * the fold carries a real identity — never stale, so a restoreError on a * genuinely broken durable pane is left alone). - * - Locator absent (the restoreError migration strips it — the incident's - * normalized shape): ANY present scalar identity field (sessionId or + * - Locator absent (including older normalized restoreError payloads whose + * migration stripped it): ANY present scalar identity field (sessionId or * resumeSessionId) classifying placeholder marks the fold stale. * * Returns the previous durable identity tuple — the sessionRef OBJECT @@ -295,6 +295,12 @@ export function migrateLegacyFreshAgentContent { return !!value && typeof value === 'object' @@ -69,6 +70,16 @@ function isPaneContentShape(content: unknown): boolean { const hasSessionRef = content.sessionRef !== undefined && (typeof content.sessionRef === 'object' || !!(content.sessionRef as object)) const hasRestoreError = content.restoreError !== undefined + const sessionRef = isRecord(content.sessionRef) ? content.sessionRef : undefined + const restoreError = isRecord(content.restoreError) ? content.restoreError : undefined + const restoreErrorHasDurableIdentity = hasSessionRef + && hasRestoreError + && restoreError?.reason !== 'invalid_legacy_restore_target' + && sessionRef?.provider === content.provider + && isDurableProviderSessionId( + typeof content.provider === 'string' ? content.provider : undefined, + typeof sessionRef?.sessionId === 'string' ? sessionRef.sessionId : undefined, + ) return !!sessionType && providerValid && typeof content.createRequestId === 'string' @@ -86,7 +97,7 @@ function isPaneContentShape(content: unknown): boolean { && isOptionalString(content.effort) && isSessionRefShape(content.sessionRef) && isRestoreErrorShape(content.restoreError) - && !(hasSessionRef && hasRestoreError) + && (!(hasSessionRef && hasRestoreError) || restoreErrorHasDurableIdentity) && (content.plugins === undefined || (Array.isArray(content.plugins) && content.plugins.every((plugin) => typeof plugin === 'string'))) && (content.settingsDismissed === undefined || typeof content.settingsDismissed === 'boolean') diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 8db8d28e9..8e7600c67 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -194,6 +194,12 @@ function normalizePaneContent( ? previous.createRequestId : undefined const existingRestoreError = sanitizeRestoreError(rawFreshAgent.restoreError) + const restoreErrorSessionRef = sanitizeSessionRef(rawFreshAgent.sessionRef) + const retainedRestoreErrorSessionRef = existingRestoreError?.reason !== 'invalid_legacy_restore_target' + && restoreErrorSessionRef?.provider === input.provider + && isDurableProviderSessionId(input.provider, restoreErrorSessionRef.sessionId) + ? restoreErrorSessionRef + : undefined const style = normalizeFreshAgentStyleOverride((input as { style?: unknown }).style) const pendingLocalEcho = normalizeFreshAgentPendingLocalEcho(rawFreshAgent.pendingLocalEcho) const modelEffortLevels = normalizeFreshAgentModelEffortLevels(rawFreshAgent.modelEffortLevels) @@ -250,7 +256,10 @@ function normalizePaneContent( ...normalizeManagedRuntimeProjection(rawFreshAgent), sessionType: input.sessionType, provider: input.provider, - sessionId: input.sessionId, + sessionId: retainedRestoreErrorSessionRef?.provider === 'claude' + ? retainedRestoreErrorSessionRef.sessionId + : input.sessionId, + ...(retainedRestoreErrorSessionRef ? { sessionRef: retainedRestoreErrorSessionRef } : {}), createRequestId: typeof input.createRequestId === 'string' && input.createRequestId ? input.createRequestId : previousCreateRequestId || nanoid(), diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 473356cc6..be8e12c06 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -28,6 +28,7 @@ import { } from './paneTypes' import { createLogger } from '@/lib/client-logger' import { isValidMachineIdStamp } from '@/lib/machine-identity' +import { isDurableProviderSessionId } from '@shared/session-flavor' export { TABS_STORAGE_KEY, PANES_STORAGE_KEY } @@ -357,6 +358,16 @@ function normalizeFreshAgentContent(content: Record): Record): Record): Record. Speaks the -// newline-JSON stdio protocol from crates/freshell-freshagent/src/claude.rs: -// 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"} -// out: {"type":"created","sessionId",protocol:2} FIRST (any earlier sdk.* line is -// DISCARDED by read_created, claude_handshake.rs; 45s budget claude.rs), -// then sdk.* event lines (renamed sdk.X -> freshAgent.X server-side). -// 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); -// - 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 -// stops the server's consumer. -// FAKE_CLAUDE_SIDECAR_HOLD_TURN=1 -> a send starts running and never -// completes (busy-restart wedge scenario). -// NEW knobs (Task 7): -// - FAKE_CLAUDE_SIDECAR_LOG= -> append request log (JSONL: {pid, t, msg}) -// - FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER= -> first send wedges, rest work -// - a resume boot on create -> cliSessionId uses that (resume continuity) -// - transcripts: create ensures, each send appends to /projects/-fixture/.jsonl -// - FAKE_CLAUDE_SIDECAR_INIT_AT=turn -> report the session (sdk.session.init) -// at the start of every turn and never at create, the REAL CLI's order: it -// emits system/init only once a prompt runs, so a new pane has no durable id -// (and the server no owner record for it) until its first message runs. -// Default (unset or 'create'): report once, right after `created`. -import readline from 'node:readline' -import fs from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { randomUUID } from 'node:crypto' - -const HOLD_TURN = process.env.FAKE_CLAUDE_SIDECAR_HOLD_TURN === '1' -const HOLD_ONCE_MARKER = process.env.FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER -// RANDOM canonical UUID per sidecar PROCESS (council follow-up, PR #562/#563 -// close-out): the old static 44444444-... default made every resume-less -// create in every sidecar process mint the SAME id, so a regression that -// silently lost the durable identity across a restart would be re-stamped -// with the identical constant and collide onto the same transcript file -- -// structurally collision-blind. With a per-process random default, any -// identity-losing bare create after a restart (new sidecar process) mints a -// DIFFERENT id and the identity assertions in the specs go red. The env -// override remains for specs that need a caller-chosen id. -const CLI_SESSION_ID = process.env.FAKE_CLAUDE_SIDECAR_CLI_SESSION_ID ?? randomUUID() -const REQUEST_LOG = process.env.FAKE_CLAUDE_SIDECAR_LOG -const INIT_AT_TURN = process.env.FAKE_CLAUDE_SIDECAR_INIT_AT === 'turn' - -// sessionId (bridge nanoid) -> { cliSessionId (durable uuid), cwd } -const sessions = new Map() - -function emit(obj) { - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function logRequest(msg) { - if (!REQUEST_LOG) return - fs.mkdirSync(path.dirname(REQUEST_LOG), { recursive: true }) - fs.appendFileSync(REQUEST_LOG, `${JSON.stringify({ pid: process.pid, t: Date.now(), msg })}\n`) -} - -function claudeHome() { - // Mirror the REAL CLI's resolution (CLAUDE_CONFIG_DIR first -- ledger A3), then - // the freshell-legacy CLAUDE_HOME the harness sets, then ~/.claude -- the same - // candidate order as the Rust claude_home_candidates(). - return ( - process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_HOME || path.join(os.homedir(), '.claude') - ) -} - -function transcriptPath(cliSessionId) { - const dir = path.join(claudeHome(), 'projects', '-fixture') - fs.mkdirSync(dir, { recursive: true }) - return path.join(dir, `${cliSessionId}.jsonl`) -} - -// 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). -// 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 }] }, - } - fs.appendFileSync(transcriptPath(cliSessionId), `${JSON.stringify(line)}\n`) -} - -function emitSessionInit(sessionId, { cliSessionId, cwd, model }) { - emit({ - type: 'sdk.session.init', - sessionId, - cliSessionId, - model, - cwd, - tools: [], - }) -} - -const rl = readline.createInterface({ input: process.stdin }) -rl.on('line', (line) => { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - logRequest(msg) - if (msg.type === 'create') { - const sessionId = msg.sessionId ?? `fc-e2e-${process.pid}-${Date.now()}` - // Resume continuity: a resumed session keeps its durable id (what the real - // CLI's transcript filename stem provides across restarts). - const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID - const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, { cliSessionId, cwd, model: msg.model ?? 'claude-opus-4-6', effort: msg.effort, permissionMode: msg.permissionMode }) - // Ensure the transcript file EXISTS from create (the attach arm's - // transcript-present gate reads it before any send happens post-restart). - fs.closeSync(fs.openSync(transcriptPath(cliSessionId), 'a')) - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - if (!INIT_AT_TURN) { - emitSessionInit(sessionId, { cliSessionId, cwd, model: msg.model ?? 'claude-opus-4-6' }) - } - if (resumeSessionId) { - emit({ type: 'sdk.session.snapshot', sessionId, messages: [] }) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'configure') { - const session = sessions.get(msg.sessionId) - if (!session) { - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: false, message: 'Session not found' }) - return - } - for (const key of ['model', 'effort', 'permissionMode', 'cwd']) { - if (msg.settings?.[key] != null || (key === 'effort' && msg.settings?.effort === null)) session[key] = msg.settings[key] - } - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: session }) - } else if (msg.type === 'send') { - 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, msg.uuid) - const holdOnce = HOLD_ONCE_MARKER && !fs.existsSync(HOLD_ONCE_MARKER) - if (holdOnce) { - fs.mkdirSync(path.dirname(HOLD_ONCE_MARKER), { recursive: true }) - fs.writeFileSync(HOLD_ONCE_MARKER, '1') - return // wedged: running forever (busy-restart scenario) - } - if (HOLD_TURN) return - const text = process.env.FAKE_CLAUDE_SIDECAR_ECHO_SETTINGS === '1' - ? `Fixture claude turn (${model}, ${effort ?? 'default'}, ${permissionMode ?? 'default'})` - : 'Fixture claude turn' - appendTranscript(cliSessionId, 'assistant', text, cwd) - emit({ - type: 'sdk.assistant', - sessionId: msg.sessionId, - content: [{ type: 'text', text }], - model, - }) - // 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 deleted file mode 100644 index 31901f473..000000000 --- a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env node -// HARNESS-06 fake Kilroy runtime — the harness-level "full Kilroy runtime" -// fixture (NOT the production Kilroy). Speaks the REAL claude-sidecar -// newline-JSON protocol verbatim (crates/freshell-claude-sidecar/index.mjs, -// doc comment lines 9-29) with kilroy flavour, so any harness that can drive a -// fresh-agent claude/kilroy sidecar can drive this one deterministically: -// -// 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,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:[]} -// sdk.session.snapshot {sessionId,messages} (resume only) -// 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.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,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". -// -// Knobs (env): -// 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) -// 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 -// server rings the death of an owed turn) -// -// 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' - -const LOG = process.env.FAKE_KILROY_LOG -const HOLD_TURN = process.env.FAKE_KILROY_HOLD_TURN === '1' -const APPROVAL = process.env.FAKE_KILROY_APPROVAL === '1' -const APPROVAL_DELAY_MS = Number(process.env.FAKE_KILROY_APPROVAL_DELAY_MS ?? 250) -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 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) { - const bytes = randomBytes(size) - let id = '' - for (let i = 0; i < size; i++) id += NANOID_ALPHABET[bytes[i] & 63] - return id -} - -function emit(obj) { - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function logRequest(msg) { - if (!LOG) return - fs.mkdirSync(path.dirname(LOG), { recursive: true }) - fs.appendFileSync(LOG, `${JSON.stringify({ pid: process.pid, t: Date.now(), msg })}\n`) -} - -// sessionId -> { cliSessionId, cwd, pendingResults, sendInFlight, interrupted } -const sessions = new Map() - -/** 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 }) -rl.on('line', (line) => { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - logRequest(msg) - void handle(msg) -}) - -async function handle(msg) { - if (msg.type === 'create') { - const sessionId = msg.sessionId ?? nanoid() - const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID - const cwd = msg.cwd ?? process.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({ - type: 'sdk.session.init', - sessionId, - cliSessionId, - model: msg.model ?? 'claude-opus-4-6', - cwd, - tools: [], - }) - if (resumeSessionId) { - emit({ type: 'sdk.session.snapshot', sessionId, messages: [] }) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - return - } - - if (msg.type === 'send') { - 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) - } - // A turn is owed until its result, so an interrupt arriving mid-render - // ends it. - st.pendingResults += 1 - if (HOLD_TURN) return // wedged mid-turn; interrupt is the only turn exit - - st.sendInFlight = true - try { - if (APPROVAL) { - // 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({ - type: 'sdk.assistant', - sessionId: msg.sessionId, - content: [{ type: 'text', text: `kilroy fixture reply: ${msg.text}` }], - model: 'claude-opus-4-6', - }) - // An in-flight interrupt deferred to this scripted completion: the - // 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) - // The result ends the turn: no idle follows (the real sidecar sends none). - emit(resultFrame(msg.sessionId, facts)) - } finally { - st.sendInFlight = false - } - return - } - - 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): 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 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 - st.interrupted = 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 aborted result. - return - } - // 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 - emit(resultFrame(msg.sessionId, STOPPED_RESULT)) - return - } - // 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 - } - - if (msg.type === 'shutdown') { - process.exit(0) - } -} - -process.on('uncaughtException', (err) => { - process.stderr.write(`[fake-kilroy] uncaught: ${err}\n`) - process.exit(4) -}) diff --git a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs deleted file mode 100755 index d2ce0ae85..000000000 --- a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs +++ /dev/null @@ -1,546 +0,0 @@ -#!/usr/bin/env node -// HARNESS-03 deterministic fake Claude-SDK bridge sidecar — covers the -// checklist's "Kilroy/Claude-SDK" entry with ONE executable: both kilroy and -// freshclaude ride the claude provider's sidecar protocol -// (crates/freshell-freshagent/src/claude.rs), differing only in the -// sessionType flavour — select it via FRESHELL_FAKE_PROVIDER (default -// 'kilroy'). -// -// Wire protocol (mirrors crates/freshell-claude-sidecar/index.mjs and the -// realism notes in fixtures/fake-claude-sidecar.mjs): -// in : {"type":"create",protocol:2,requestId,cwd,model,permissionMode,effort, -// boot:{kind:'fresh'}|{kind:'resume',resume}|{kind:'fork',resume,resumeSessionAt?,dropsTurn?}} -// (protocol v2, crates/freshell-claude-sidecar/boot.mjs; kata 1wxv fork-at-point: -// a fork boot of `resume` at resumeSessionAt -// mints a NEW durable cliSessionId and writes the parent's transcript PREFIX -// through the addressed uuid verbatim; a dropsTurn guard that is NOT the -// 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,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 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 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. -// -// 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. 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. 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). 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} — -// the spec's ground truth for "the exact respond/compact frames the Rust -// server wrote to the sidecar" and for zero-before-click proofs. -// (FRESHELL_FAKE_* keys are auto-recorded into the launch ledger.) -// -// The `stream-error` lane models the real sidecar's end of stream (its -// 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 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 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). -// -// Transcript realism (AGENT-05 reload-while-pending): the cards render -// EXCLUSIVELY from the REST snapshot, which 404s without a durable transcript -// — so `create` ensures an EMPTY JSONL transcript at -// /projects//.jsonl, every `send` -// appends one {"type":"user", cwd, message:{role,content:[{type:'text',text}]}} -// entry, and every completion appends the matching assistant entry. The line -// shape mirrors `parse_transcript_turns`' accepted shape -// (crates/freshell-freshagent/src/claude_snapshot.rs:367-420); the cwd -// mangling (`[^A-Za-z0-9]` → '-') mirrors the real CLI's project-dir slug — -// though the Rust locator scans EVERY projects dir, so only the filename -// portion (the canonical cliSessionId) is load-bearing. claudeHome resolves -// CLAUDE_CONFIG_DIR > CLAUDE_HOME > ~/.claude, the same candidate order the -// Rust server uses; the sidecar inherits the harness's isolated HOME. -// -// The process stays alive until `shutdown` (exit 0), a scripted `crash` -// (exit code), a `stream-error` (exit 3), or kill. -import fs from 'node:fs' -import os from 'node:os' -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' - -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 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) - -// AGENT-05/06 e2e audit: one JSONL row per RAW stdin line, before parsing — -// a malformed line is audited too, and the spec never has to trust the -// fixture's own parse to prove what the server wrote. -const STDIN_LOG = env.FRESHELL_FAKE_STDIN - -// bridge sessionId -> { cliSessionId, cwd, pending, pendingEntries } -const sessions = new Map() -let activeSessionId = null -let createCounter = 0 - -function emit(obj) { - // Wire audit (D1-F2, see the header): record the ACTUAL outbound frame so - // specs assert on wire truth (what crossed stdout), not just program intent. - appendJsonl(env[EVENTS_ENV], { t: Date.now(), pid: process.pid, provider, kind: 'wire', frame: obj }) - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function claudeHome() { - // Candidate order matches the Rust `claude_home_candidates` (ledger A3). - return ( - env.CLAUDE_CONFIG_DIR || env.CLAUDE_HOME || path.join(os.homedir(), '.claude') - ) -} - -function mangleCwd(cwd) { - return String(cwd ?? '').replace(/[^A-Za-z0-9]/g, '-') -} - -function transcriptPath(cliSessionId, cwd) { - return path.join(claudeHome(), 'projects', mangleCwd(cwd), `${cliSessionId}.jsonl`) -} - -// kata 1wxv Task 7 (fork-at-point): the rollback resume math runs over the RAW -// parentUuid chain, so every appended transcript line now carries a real -// 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. 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 = typeof lineUuid === 'string' && lineUuid ? lineUuid : randomUUID() - lastUuidBySession.set(cliSessionId, uuid) - const line = { - type: role, - uuid, - parentUuid, - timestamp: new Date().toISOString(), - cwd: cwd ?? process.cwd(), - message: { role, content: [{ type: 'text', text }] }, - } - appendJsonl(transcriptOverride ?? transcriptPath(cliSessionId, cwd), line) -} - -// ── pending-request tracking (mirrors the real sidecar's permission-channel) ── -function trackPending(sessionId, kind, requestId) { - const st = sessions.get(sessionId) - if (!st) return - st.pendingEntries.push({ kind, requestId }) -} - -/** - * Resolve ONE parked request (permission.respond/question.respond): drop the - * tracked entry. Unknown requestIds are a lose-safely no-op. - */ -function resolvePending(sessionId, kind, requestId) { - const st = sessions.get(sessionId) - if (!st) return - const idx = st.pendingEntries.findIndex( - (entry) => entry.kind === kind && entry.requestId === String(requestId), - ) - if (idx === -1) return - st.pendingEntries.splice(idx, 1) -} - -async function render(event) { - const { kind, data } = event - const sessionId = data.sessionId ?? activeSessionId - switch (kind) { - case 'session': - emit({ - type: 'sdk.session.init', - sessionId, - cliSessionId: data.cliSessionId, - model: data.model ?? 'fixture-model', - cwd: data.cwd ?? process.cwd(), - tools: [], - }) - break - case 'resume': - emit({ type: 'sdk.session.snapshot', sessionId, messages: data.messages ?? [] }) - break - case 'activity': - emit({ type: 'sdk.status', sessionId, status: data.status ?? 'running' }) - break - case 'approval': { - 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) - emit({ - type: 'sdk.permission.request', - sessionId, - requestId, - subtype: 'can_use_tool', - tool: { name: data.tool ?? 'Bash', input }, - }) - break - } - case 'question': { - const questions = Array.isArray(data.questions) - ? data.questions - : [{ question: data.text ?? '', header: 'Fixture', options: [], multiSelect: false }] - const requestId = String(data.id ?? `q-${randomUUID()}`) - trackPending(sessionId, 'question', requestId) - emit({ - type: 'sdk.question.request', - sessionId, - requestId, - questions, - }) - break - } - case 'completion': { - const st = sessions.get(sessionId) - // 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 idle. - // The fake must not invent frames the real sidecar never emits. - if (!st) return - emit({ - type: 'sdk.assistant', - sessionId, - content: [{ type: 'text', text: data.text ?? 'Fixture turn' }], - model: st.settings.model ?? 'fixture-model', - }) - appendTranscript(st.cliSessionId, st.cwd, 'assistant', data.text ?? 'Fixture turn', st.transcriptOverride) - // 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 facts = st.interrupted ? STOPPED_RESULT : endedResult(data.subtype ?? 'success') - st.interrupted = false - st.pendingResults = Math.max(0, st.pendingResults - 1) - // 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, 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)) { - emit({ - type: entry.kind === 'question' ? 'sdk.question.cancelled' : 'sdk.permission.cancelled', - sessionId, - requestId: entry.requestId, - }) - } - } - if (st) sessions.delete(sessionId) - emit({ - type: 'sdk.stream.ended', - sessionId, - reason: data.reason ?? 'stream_error', - message: data.message ?? 'Fixture stream error', - }) - exiting = true - process.stdout.write('', () => process.exit(STREAM_ENDED_EXIT_CODE)) - break - } - case 'marker': - if (data.signal === 'interrupt') { - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } - break - case 'crash': - // A real crash screams no protocol frame; the ledger holds the record. - break - default: - break - } -} - -const engine = new FixtureEngine({ - provider, - program, - env, - write: (event) => render(event), -}) - -const rl = readline.createInterface({ input: process.stdin }) -rl.on('line', (line) => { - appendJsonl(STDIN_LOG, { t: Date.now(), pid: process.pid, line }) - // Like the real sidecar: once its stream ended, sdk.stream.ended stays the last line. - if (exiting) return - void handleInput(line).catch((err) => { - emit({ type: 'sdk.error', sessionId: activeSessionId, message: String(err?.message ?? err) }) - }) -}) - -async function handleInput(line) { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - if (msg.type === 'create') { - createCounter += 1 - const sessionId = msg.sessionId ?? `${provider}-fake-${process.pid}-${createCounter}` - activeSessionId = sessionId - // kata 1wxv Task 7 (fork-at-point, s2rk correction): a `forkSession:true` - // create mints a NEW durable cliSessionId — real `claude --fork-session` - // NEVER reuses the parent's id; plain resume keeps the same-id behavior. - // A PATH-shaped resumeSessionId (the server's cwd-gone fallback lane: - // `--resume .jsonl` bypasses the CLI's project-slug scoping) names - // the transcript file to CONTINUE — the session's durable id stays the - // file's stem, and every record lands in THAT file (never a - // path-flattened `<...>.jsonl.jsonl` phantom). - // Protocol v2 (crates/freshell-claude-sidecar/boot.mjs): the boot names - // the conversation to start. - const boot = msg.boot ?? {} - const resumeSessionId = typeof boot.resume === 'string' ? boot.resume : undefined - const forking = boot.kind === 'fork' - const resumeRaw = resumeSessionId ?? null - const resumeIsPath = resumeRaw !== null - && (resumeRaw.includes('/') || resumeRaw.endsWith('.jsonl')) - const resumePath = !forking && resumeIsPath ? resumeRaw : null - const cliSessionId = msg.sessionId ?? (forking - ? randomUUID() - : (resumePath - ? path.basename(resumePath).replace(/\.jsonl$/, '') - : (resumeRaw ?? program.sessionId ?? randomUUID()))) - const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, { cliSessionId, cwd, pendingEntries: [], - transcriptOverride: resumePath, - settings: { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd }, - // 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. - const transcript = resumePath ?? transcriptPath(cliSessionId, cwd) - fs.mkdirSync(path.dirname(transcript), { recursive: true }) - if (forking && resumeSessionId) { - // created FIRST — a real consumer discards anything earlier. The - // resumeDropsTurn refusal watch runs BEFORE any durable state moves: - // the guard must name the RAW-chain successor of the resume point (the - // SDK-armed discard guard); anything else refuses with the SDK's - // documented prefix and NO sdk.session.init / durable session is ever - // minted (freshell retries ONCE with the guard omitted). - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - const parentPath = transcriptPath(resumeSessionId, cwd) - const parentLines = fs.existsSync(parentPath) - ? fs.readFileSync(parentPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) - : [] - const cut = typeof boot.resumeSessionAt === 'string' - ? parentLines.findIndex((l) => l.uuid === boot.resumeSessionAt) - : parentLines.length - 1 - if (typeof boot.dropsTurn === 'string') { - const successor = cut >= 0 ? (parentLines[cut + 1]?.uuid ?? null) : null - if (boot.dropsTurn !== successor) { - emit({ - type: 'sdk.error', - sessionId, - message: `Resume rejected by --resume-drops-turn: ${boot.dropsTurn} is not the raw-chain successor of the resume point (drop-guard mismatch)`, - }) - return - } - } - // The child file is the parent's transcript PREFIX through - // resumeSessionAt, uuids preserved verbatim (a real fork keeps original - // message ids), so freshell's transcript readers see a real durable - // JSONL; the chain cursor seeds onto the fork point's uuid. - const prefix = parentLines.slice(0, cut < 0 ? undefined : cut + 1) - const lastPrefixUuid = prefix.length > 0 ? prefix[prefix.length - 1]?.uuid : null - fs.writeFileSync(transcript, prefix.map((l) => JSON.stringify(l)).join('\n') + (prefix.length ? '\n' : '')) - if (typeof lastPrefixUuid === 'string') lastUuidBySession.set(cliSessionId, lastPrefixUuid) - } else { - fs.closeSync(fs.openSync(transcript, 'a')) - // created FIRST — a real consumer discards anything earlier. - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - // A plain resume CONTINUES the parent's chain: seed the uuid cursor from - // the transcript tail so the next appended line's parentUuid is right. - if (typeof resumeSessionId === 'string' && fs.existsSync(transcript)) { - const lines = fs.readFileSync(transcript, 'utf8').split('\n').filter(Boolean) - const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : null - if (typeof last?.uuid === 'string') lastUuidBySession.set(cliSessionId, last.uuid) - } - } - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - if (!emitted.has('session')) { - await engine.emitEvent( - 'session', - { cliSessionId, model: msg.model ?? 'fixture-model', cwd }, - 'msg:create:default', - ) - } - if (resumeSessionId) { - await engine.emitResume(cliSessionId) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'configure') { - const st = sessions.get(msg.sessionId) - if (!st) { - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: false, message: 'Session not found' }) - return - } - for (const key of ['model', 'effort', 'permissionMode', 'cwd']) { - if (msg.settings?.[key] != null || (key === 'effort' && msg.settings?.effort === null)) st.settings[key] = msg.settings[key] - } - st.cwd = st.settings.cwd - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: st.settings }) - } 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, msg.uuid) - if (st) { - // 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.sendInFlight = true - } - try { - // Turn-open bookkeeping is unconditional (the real bridge always goes busy). - await engine.emitEvent('activity', { status: 'running' }, 'msg:send:open') - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - // A turn that raised an approval/question PARKS until the matching - // respond (or an interrupt) arrives — the canned success turn must NOT - // close it early, or the card would clear with an invented resolution. - if (emitted.has('completion') || emitted.has('approval') || emitted.has('question')) return - await engine.emitEvent('completion', { subtype: 'success' }, 'msg:send:default') - } finally { - if (st) st.sendInFlight = false - } - } else if ( - msg.type === 'permission.respond' - || msg.type === 'question.respond' - ) { - activeSessionId = msg.sessionId ?? activeSessionId - const kind = msg.type === 'question.respond' ? 'question' : 'permission' - resolvePending(msg.sessionId, kind, msg.requestId) - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - } 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): a session this - // sidecar does not host settles ok:false. - settled({ ok: false, message: 'Claude session is no longer available.' }) - return - } - // 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 (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 - 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 took the aborted shape). - if (emitted.has('completion')) return - // 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 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 - st.interrupted = false - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: STOPPED_RESULT.subtype, ...STOPPED_RESULT }) - return - } - // 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 === 'shutdown') { - process.exit(0) - } -} - -keepAlive() diff --git a/test/e2e-browser/fixtures/providers/fixture-core.mjs b/test/e2e-browser/fixtures/providers/fixture-core.mjs index 2764ca982..e37a2e9d0 100644 --- a/test/e2e-browser/fixtures/providers/fixture-core.mjs +++ b/test/e2e-browser/fixtures/providers/fixture-core.mjs @@ -1,10 +1,9 @@ // HARNESS-03 — deterministic provider-fixture core engine. // -// Shared engine behind the seven fake provider executables in this directory -// (fake-claude / fake-gemini / fake-kimi / fake-amplifier terminal CLIs, -// fake-claude-sdk-sidecar for kilroy/freshclaude, fake-codex-app-server, -// fake-opencode-server). It provides two uniform, provider-independent -// observability surfaces plus a scriptable event engine: +// Shared engine behind the six remaining fake provider executables in this +// directory (fake-claude / fake-gemini / fake-kimi / fake-amplifier terminal +// CLIs, fake-codex-app-server, fake-opencode-server). It provides two uniform, +// provider-independent observability surfaces plus a scriptable event engine: // // - Launch ledger (env FRESHELL_FAKE_LEDGER): one JSONL row per process // launch — { t, pid, provider, argv, cwd, env } where `env` is STRICTLY diff --git a/test/e2e-browser/helpers/claude-sim-lane.ts b/test/e2e-browser/helpers/claude-sim-lane.ts index 19842bf11..4d304b330 100644 --- a/test/e2e-browser/helpers/claude-sim-lane.ts +++ b/test/e2e-browser/helpers/claude-sim-lane.ts @@ -37,6 +37,28 @@ export const CLAUDE_CLI_SIMULATOR = path.resolve(__dirname, '../../fixtures/clau export type ClaudeSimFlavour = 'freshclaude' | 'kilroy' +/** Environment for custom RustServer fixtures that use the production sidecar and CLI simulator. */ +export function claudeSimulatorServerEnv(input: { + ledgerPath: string + flavour?: ClaudeSimFlavour + program?: unknown + extra?: Record +}): Record { + return { + FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR, + FRESHELL_CLAUDE_NODE: process.execPath, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: CLAUDE_CLI_SIMULATOR, + FRESHELL_CLAUDE_SIM_LEDGER: input.ledgerPath, + // An empty program preserves the simulator's built-in prompt markers + // (HANG, SLOW, ERROR_TURN, and friends) while preventing an inherited + // runner program from changing the test. Scenario-specific programs are + // supplied explicitly by the caller. + FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(input.program ?? { rules: [] }), + ...(input.flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), + ...input.extra, + } +} + /** One program emission (see test/fixtures/claude-cli-sim/program.mjs for the kinds). */ export type ClaudeSimEmission = { kind: string, delayMs?: number, data?: Record } export type ClaudeSimRule = { on: string, match?: Record, once?: boolean, emit: ClaudeSimEmission[] } @@ -65,6 +87,21 @@ export interface ClaudeSimLaneOptions { /** A row of the simulator's ledger (FRESHELL_CLAUDE_SIM_LEDGER). */ export type ClaudeSimLedgerRow = { kind: string, [key: string]: unknown } +/** Read the simulator's append-only JSONL ledger, tolerating a partial last write. */ +export function readClaudeSimLedger(file: string): ClaudeSimLedgerRow[] { + if (!existsSync(file)) return [] + return readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as ClaudeSimLedgerRow] + } catch { + return [] + } + }) +} + export interface ClaudeSimPane { paneRoot: Locator tabId: string @@ -214,17 +251,7 @@ export class ClaudeSimLane { /** What the simulated CLI recorded so far (its ledger). */ ledger(): ClaudeSimLedgerRow[] { - if (!existsSync(this.ledgerPath)) return [] - return readFileSync(this.ledgerPath, 'utf8') - .split('\n') - .filter(Boolean) - .flatMap((line) => { - try { - return [JSON.parse(line) as ClaudeSimLedgerRow] - } catch { - return [] - } - }) + return readClaudeSimLedger(this.ledgerPath) } /** Attach the server's output to a failed test's report. */ 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 deleted file mode 100644 index af9dcfddb..000000000 --- a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts +++ /dev/null @@ -1,609 +0,0 @@ -// 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, 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 -// for all seven fixtures stays in specs/harness-03-provider-fixtures.spec.ts; -// this file pins only the new arms, driven through real child processes with -// per-test tmp HOMEs. -import { spawn, type ChildProcess } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') - -let tmp: string -beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-claude-sidecar-arms-')) -}) -afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }) -}) - -type Launched = { - proc: ChildProcess - stdoutLines: () => any[] - send: (msg: Record) => void - waitLine: (pred: (o: any) => boolean, what: string) => Promise - stop: () => Promise -} - -function launch(program: unknown, extraEnv: Record = {}, fixture = FIXTURE): Launched { - const proc = spawn(process.execPath, [fixture], { - env: { - ...process.env, - HOME: tmp, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(program), - ...extraEnv, - }, - stdio: ['pipe', 'pipe', 'inherit'], - }) - let killed = false - const parsed: any[] = [] - let carry = '' - proc.stdout!.on('data', (chunk) => { - carry += String(chunk) - let idx - while ((idx = carry.indexOf('\n')) !== -1) { - const line = carry.slice(0, idx) - carry = carry.slice(idx + 1) - try { - parsed.push(JSON.parse(line)) - } catch { - // non-JSON stdout is not part of this protocol; ignore - } - } - }) - return { - proc, - stdoutLines: () => parsed.slice(), - send: (msg) => proc.stdin!.write(`${JSON.stringify(msg)}\n`), - waitLine: async (pred, what) => { - const deadline = Date.now() + 10_000 - for (;;) { - const hit = parsed.find(pred) - if (hit) return hit - if (Date.now() > deadline) { - throw new Error(`fake sidecar: timed out waiting for ${what}; saw ${JSON.stringify(parsed.map((o) => o?.type))}`) - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } - }, - stop: async () => { - if (proc.exitCode !== null || killed) return - killed = true - proc.kill('SIGKILL') - }, - } -} - -function readJsonl(filePath: string): any[] { - if (!fs.existsSync(filePath)) return [] - return fs - .readFileSync(filePath, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) -} - -const RAISE_PROGRAM = { - rules: [ - { - on: 'msg:send', - match: { text: 'RAISE_PERMISSION' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, - ], - }, - { - // A DISTINCT second id: the real sidecar nanoids each raise, so a - // concurrent-parking probe never reuses the first request's id. - on: 'msg:send', - match: { text: 'RAISE_PERMISSION_2' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-2', tool: 'Bash', input: { command: 'ls -la' } } }, - ], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'allow' } }, - emit: [{ kind: 'completion', data: { subtype: 'success' } }], - }, - { - on: 'msg:send', - match: { text: 'RAISE_QUESTION' }, - emit: [ - { - kind: 'question', - data: { - id: 'req-q-1', - questions: [ - { - question: 'Pick one', - header: 'Fixture', - options: [{ label: 'A', description: 'alpha' }, { label: 'B', description: 'beta' }], - multiSelect: false, - }, - ], - }, - }, - ], - }, - { - on: 'msg:question.respond', - emit: [{ kind: 'completion', data: { subtype: 'success' }, delayMs: 10 }], - }, - ], -} - -describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', () => { - it( - '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 }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - const request = await fx.waitLine((o) => o.type === 'sdk.permission.request', 'sdk.permission.request') - expect(request).toMatchObject({ - sessionId, - requestId: 'req-perm-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'ls' } }, - }) - // 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. - 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') - - // 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.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.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' || 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). - const stdinRows = readJsonl(stdinLog) - const responds = stdinRows - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((m) => m?.type === 'permission.respond') - expect(responds).toHaveLength(2) - expect(responds[0]).toMatchObject({ sessionId, requestId: 'req-perm-1', decision: { behavior: 'allow' } }) - expect(responds[1]).toMatchObject({ sessionId, requestId: 'req-perm-2', decision: { behavior: 'allow' } }) - } finally { - await fx.stop() - } - }, - ) - - it('a respond for a NEVER-raised requestId does not poison the pending ledger (task-008-review N-2)', async () => { - const fx = launch(RAISE_PROGRAM) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - - // 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: 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.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() - } - }) - - it('interrupt cancels every parked entry with cancel frames and never emits sdk.exit', async () => { - const stdinLog = path.join(tmp, 'stdin.jsonl') - const fx = launch(RAISE_PROGRAM, { FRESHELL_FAKE_STDIN: stdinLog }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - fx.send({ type: 'send', sessionId, text: 'RAISE_QUESTION' }) - await fx.waitLine((o) => o.type === 'sdk.question.request', 'question.request') - - 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 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) - - // Zero fabricated decisions: the stdin audit contains NO respond frame. - const stdinRows = readJsonl(stdinLog) - expect( - stdinRows.filter((row) => /"(permission|question)\.respond"/.test(String(row.line))), - 'interrupt must never invent a user decision', - ).toEqual([]) - - // 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.result' && o.result === 'success', 'post-interrupt completion') - } finally { - await fx.stop() - } - }) - - 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: [ - { - on: 'msg:send', - match: { text: 'RAISE_PERMISSION' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, - ], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'deny' } }, - emit: [{ kind: 'completion', data: { subtype: 'error', text: 'The user denied this request.' } }], - }, - ], - }, { FRESHELL_FAKE_EVENTS: eventsLog }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - // Parked: NO completion frame may arrive before the respond. - 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' } }) - // 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() - const resultIdx = out.findIndex( - (o) => o.type === 'sdk.result' && o.result === 'error' && o.sessionId === sessionId, - ) - expect(resultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) - // Like the real sidecar, the turn ends with its result alone: no idle - // follows it (the real sidecar sends no idle at all). - expect( - out.filter((o, i) => i > resultIdx && o.type === 'sdk.status' && o.sessionId === sessionId && o.status === 'idle'), - 'no per-turn idle after the result', - ).toEqual([]) - expect( - out.some((o) => o.type === 'sdk.assistant' && o.sessionId === sessionId - && o.content?.[0]?.text?.includes('denied')), - 'the denial assistant frame arrived', - ).toBe(true) - - // 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.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). - fx.send({ type: 'send', sessionId, text: 'plain follow-up' }) - 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( - 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) with its stopId, and the next turn runs', async () => { - const fx = launch({ rules: [] }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - await fx.waitLine((o) => o.type === 'sdk.status' && o.status === 'idle', 'initial idle') - - // Nothing awaits a terminal frame. The real 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 - // 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, stopId: 'stop-idle', ok: true }) - - fx.send({ type: 'send', sessionId, text: 'next unrelated turn' }) - 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 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 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.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: sdk.stream.ended last, exit code 3, no edge', async () => { - const fx = launch({ - rules: [ - { on: 'msg:send', match: { text: 'DIE' }, emit: [{ kind: 'stream-error' }] }, - ], - }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - const exited = new Promise((resolve) => fx.proc.once('exit', (code) => resolve(code))) - - // 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'), '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 turnOpened = out.findLastIndex((o) => o.type === 'sdk.status' && o.status === 'running') - expect(turnOpened, 'the send opened its turn').toBeGreaterThanOrEqual(0) - expect( - out.slice(turnOpened + 1).filter((o) => o.type === 'sdk.status'), - 'no stream-end status: the server reports the ended session as exited', - ).toEqual([]) - } finally { - await fx.stop() - } - }) - - it('writes the durable claude transcript the snapshot route reads (create-touch, user on send, assistant on completion)', async () => { - const fx = launch(RAISE_PROGRAM) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - await fx.waitLine((o) => o.type === 'sdk.session.init', 'sdk.session.init') - const created = fx.stdoutLines().find((o) => o.type === 'created') - const init = fx.stdoutLines().find((o) => o.type === 'sdk.session.init') - const cliSessionId = init.cliSessionId as string - expect(cliSessionId).toMatch(/^[0-9a-f-]{36}$/) - const transcript = path.join(tmp, '.claude', 'projects', tmp.replace(/[^A-Za-z0-9]/g, '-'), `${cliSessionId}.jsonl`) - 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.result', 'completion') - - const lines = readJsonl(transcript) - expect(lines.map((l) => l.type)).toEqual(['user', 'assistant']) - expect(lines[0]).toMatchObject({ type: 'user', cwd: tmp, message: { role: 'user', content: [{ type: 'text', text: 'hello durable world' }] } }) - expect(lines[1].message.role).toBe('assistant') - } finally { - await fx.stop() - } - }) -}) - -describe('fake-claude-sdk-sidecar fork-at-point arm (kata 1wxv e2e fixture)', () => { - /** The fixture's transcript dir for a cwd under this test's tmp HOME. */ - function transcriptFor(cliSessionId: string, cwd: string): string { - return path.join(tmp, '.claude', 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${cliSessionId}.jsonl`) - } - - /** Drive ORIG through two complete turns so its transcript holds u1/a1/u2/a2. */ - async function seedTwoTurns(fx: Launched): Promise<{ sessionId: string; cliSessionId: string }> { - fx.send({ type: 'create', requestId: 'req-orig', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-orig', 'created ORIG') - const init = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === created.sessionId, - 'sdk.session.init ORIG', - ) - fx.send({ type: 'send', sessionId: created.sessionId, text: 'turn one' }) - 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.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 } - } - - it('a fork boot mints a fresh cliSessionId and seeds the transcript prefix (s2rk correction)', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - const parentLines = readJsonl(transcriptFor(ORIG, tmp)) - expect(parentLines.map((l) => l.type)).toEqual(['user', 'assistant', 'user', 'assistant']) - // Every chain line carries a uuid + parentUuid backbone (the rollback - // resume math runs over the RAW parentUuid chain). - expect(parentLines.every((l) => typeof l.uuid === 'string' && l.uuid.length > 0)).toBe(true) - expect(parentLines[0].parentUuid ?? null).toBe(null) - expect(parentLines[1].parentUuid).toBe(parentLines[0].uuid) - expect(parentLines[2].parentUuid).toBe(parentLines[1].uuid) - - // Fork at a1 (keep through-and-including a1); the dropsTurn guard - // is the RAW-chain successor of the resume point (u2). - fx.send({ - type: 'create', - protocol: 2, - requestId: 'req-fork', - cwd: tmp, - boot: { kind: 'fork', resume: ORIG, resumeSessionAt: parentLines[1].uuid, dropsTurn: parentLines[2].uuid }, - }) - const forkCreated = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-fork', 'created fork') - const forkInit = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === forkCreated.sessionId, - 'sdk.session.init fork', - ) - const CHILD = forkInit.cliSessionId as string - expect(CHILD, 'real claude --fork-session mints a NEW durable id').not.toBe(ORIG) - expect(CHILD).toMatch(/^[0-9a-f-]{36}$/) - - // The child file is the parent's transcript PREFIX through a1, uuids - // preserved verbatim (fork prefix retention) — visible to freshell's - // transcript readers as a real durable JSONL. - const childLines = readJsonl(transcriptFor(CHILD, tmp)) - expect(childLines).toEqual(parentLines.slice(0, 2)) - - // A follow-up plain-resume create still keeps ORIG (no same-id divergence - // regression on the non-fork arm). - fx.send({ type: 'create', protocol: 2, requestId: 'req-plain', cwd: tmp, boot: { kind: 'resume', resume: ORIG } }) - const plainCreated = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-plain', 'created plain resume') - const plainInit = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === plainCreated.sessionId, - 'sdk.session.init plain resume', - ) - expect(plainInit.cliSessionId).toBe(ORIG) - } finally { - await fx.stop() - } - }) - - it('a resume boot keeps the existing same-cliSessionId behavior', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - fx.send({ type: 'create', protocol: 2, requestId: 'req-resume', cwd: tmp, boot: { kind: 'resume', resume: ORIG } }) - const created = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-resume', 'created resume') - const init = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === created.sessionId, - 'sdk.session.init resume', - ) - expect(init.cliSessionId).toBe(ORIG) - // The untouched original transcript is never rewritten by a plain resume. - expect(readJsonl(transcriptFor(ORIG, tmp)).map((l) => l.type)).toEqual([ - 'user', - 'assistant', - 'user', - 'assistant', - ]) - } finally { - await fx.stop() - } - }) - - it('a dropsTurn guard that is NOT the raw-chain successor of the resume point refuses with the SDK prefix', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - const parentLines = readJsonl(transcriptFor(ORIG, tmp)) - // Fork at u1: the successor is a1; a guard naming anything else is the - // SDK's refused-completion contract (freshell retries once guard-less). - fx.send({ - type: 'create', - protocol: 2, - requestId: 'req-refused', - cwd: tmp, - // dropsTurn a2 — NOT the successor of u1 - boot: { kind: 'fork', resume: ORIG, resumeSessionAt: parentLines[0].uuid, dropsTurn: parentLines[3].uuid }, - }) - // This fake still reports the refusal after its placeholder `created`, the - // shape from before sidecar protocol v2 (the real sidecar now answers - // create.failed{FORK_GUARD_REFUSED} instead: test/unit/claude-sidecar/ - // boot-handshake.test.ts); the server's rollback treats both as the guard - // refusal. - await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-refused', 'created refused fork') - const refusal = await fx.waitLine( - (o) => typeof o?.message === 'string' && o.message.startsWith('Resume rejected by --resume-drops-turn:'), - 'resume-drops-turn refusal line', - ) - expect(refusal.message).toContain(parentLines[3].uuid) - // The refused fork must never mint a DURABLE session: the only - // sdk.session.init on the whole wire is ORIG's own (from seedTwoTurns). - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.session.init'), - 'no sdk.session.init ever lands for a refused fork', - ).toHaveLength(1) - } finally { - await fx.stop() - } - }) -}) - -describe('create-time UUID contract shared by browser Claude fixtures', () => { - it.each([ - ['SDK fixture', FIXTURE], - ['simple fixture', path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs')], - ])('%s keeps the requested UUID on created, init, and its transcript', async (_name, fixture) => { - const sessionId = '11111111-1111-4111-8111-111111111111' - const config = path.join(tmp, 'claude') - const fx = launch({}, { CLAUDE_CONFIG_DIR: config }, fixture) - try { - fx.send({ type: 'create', protocol: 2, sessionId, requestId: 'uuid-create', cwd: tmp, boot: { kind: 'fresh' } }) - expect(await fx.waitLine((frame) => frame.type === 'created', 'created')).toMatchObject({ sessionId }) - expect(await fx.waitLine((frame) => frame.type === 'sdk.session.init', 'init')).toMatchObject({ sessionId, cliSessionId: sessionId }) - fx.send({ type: 'send', sessionId, uuid: '22222222-2222-4222-8222-222222222222', text: 'hello' }) - await fx.waitLine((frame) => frame.type === 'sdk.result', 'result') - const files = fs.readdirSync(config, { recursive: true }).map(String) - expect(files.some((file) => file.endsWith(`${sessionId}.jsonl`))).toBe(true) - } finally { await fx.stop() } - }) -}) 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 82ad61a54..e9f82c95a 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts @@ -1,261 +1,233 @@ import { describe, it, expect, afterEach } from 'vitest' -import fsp from 'node:fs/promises' -import os from 'node:os' +import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { spawnFakeKilroy, type KilroyRuntime } from './kilroy-runtime.js' - -/** - * HARNESS-06 kilroy-runtime coverage: the harness-level "full Kilroy runtime" - * fake. It must speak the real claude-sidecar newline-JSON protocol - * (crates/freshell-claude-sidecar/index.mjs doc comment) with kilroy flavour, - * record every request to a JSONL ledger ("records Kilroy invocations"), and - * expose controllable approval / failure / crash / resume edges. - */ - +import { + spawnKilroyRuntime, + readKilroyLedger, + type KilroyRuntime, + type KilroyRuntimeOptions, +} from './kilroy-runtime.js' + +// Drive the production sidecar, not a second implementation of its protocol. +// The simulator supplies model turns and records the real SDK's inputs. const runtimes: KilroyRuntime[] = [] -const tmpDirs: string[] = [] - -async function make(env: NodeJS.ProcessEnv = {}): Promise<{ rt: KilroyRuntime; logPath: string }> { - const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-h06-kilroy-')) - tmpDirs.push(dir) - const logPath = path.join(dir, 'requests.jsonl') - const rt = await spawnFakeKilroy({ FAKE_KILROY_LOG: logPath, ...env }) - runtimes.push(rt) - return { rt, logPath } +async function make(options: KilroyRuntimeOptions = {}): Promise { + const runtime = await spawnKilroyRuntime({ timeScale: 0.02, ...options }) + runtimes.push(runtime) + return runtime } afterEach(async () => { while (runtimes.length) await runtimes.pop()!.kill() - while (tmpDirs.length) await fsp.rm(tmpDirs.pop()!, { recursive: true, force: true }) }) -async function readLedger(logPath: string): Promise>> { - try { - const text = await fsp.readFile(logPath, 'utf8') - return text.split('\n').filter(Boolean).map((l) => JSON.parse(l)) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw err - } +async function create(rt: KilroyRuntime, extra: Record = {}): Promise { + const sessionId = randomUUID() + rt.send({ type: 'create', protocol: 2, sessionType: 'kilroy', sessionId, requestId: 'create', cwd: rt.cwd, boot: { kind: 'fresh' }, ...extra }) + const answer = await rt.nextEvent('created') + return answer.sessionId as string } -/** Whether the fake wrote an idle status between event indexes `from` and `to`. */ -function idleBetween(rt: KilroyRuntime, from: number, to: number): boolean { - return rt.events().slice(from, to).some((e) => e.type === 'sdk.status' && e.status === 'idle') +function send(rt: KilroyRuntime, sessionId: string, text: string): string { + const uuid = randomUUID() + rt.send({ type: 'send', sessionId, uuid, text }) + return uuid } -/** - * Every frame the fake wrote before this call has been read: an interrupt with - * nothing in flight is settled after all of them (it arms nothing). Returns the - * index of its settle. - */ -async function readEverythingSoFar(rt: KilroyRuntime, sessionId: string): Promise { - rt.send({ type: 'interrupt', sessionId }) - const settle = await rt.nextEvent('sdk.interrupt_settled') - return rt.events().indexOf(settle) +function serverOnlyFrames(rt: KilroyRuntime) { + return rt.events().filter((event) => ['sdk.turn.complete', 'sdk.turn.waiting', 'sdk.exit'].includes(event.type)) } -describe('harness-06 fake kilroy runtime', () => { - it('answers create with created-first, init, idle — and records the invocation', async () => { - const { rt, logPath } = await make() - const sessionId = '11111111-1111-4111-8111-111111111111' - rt.send({ type: 'create', sessionId, requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) - - const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } - expect(created.requestId).toBe('r-1') - expect(created.sessionId).toBe(sessionId) - - const init = (await rt.nextEvent('sdk.session.init', undefined, 5000)) as { - sessionId: string; cliSessionId: string; model: string; cwd: string - } - expect(init.sessionId).toBe(created.sessionId) - expect(init.cliSessionId).toBe(sessionId) - expect(init.cwd).toBe('/tmp/fixture-cwd') - - const idle = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle', 5000)) - expect((idle as { sessionId: string }).sessionId).toBe(created.sessionId) +async function barrier(rt: KilroyRuntime, sessionId: string) { + const requestId = randomUUID() + rt.send({ type: 'configure', sessionId, requestId, settings: {} }) + await rt.nextEvent('sdk.configured', (event) => event.requestId === requestId) +} - // The FIRST stdout line must be `created` (read_created discards earlier sdk.*). - expect((rt.events()[0] as { type: string }).type).toBe('created') +const APPROVAL_PROGRAM = { + rules: [{ + on: 'msg:prompt', match: { text: 'needs approval' }, + emit: [ + { kind: 'approval', data: { tool: 'Bash', input: { command: 'ls' } } }, + { kind: 'text', data: { text: 'approved reply' } }, + ], + }], +} - const ledger = await readLedger(logPath) - expect(ledger).toHaveLength(1) - expect((ledger[0].msg as { type: string }).type).toBe('create') - expect((ledger[0].msg as { cwd?: string }).cwd).toBe('/tmp/fixture-cwd') +describe('harness-06 production Kilroy runtime', () => { + it('answers create first with the native UUID and defers session init until a real prompt', async () => { + const rt = await make() + const sessionId = await create(rt, { model: 'claude-opus-4-6' }) + expect(rt.events()[0]).toMatchObject({ type: 'created', requestId: 'create', sessionId, protocol: 2 }) + rt.send({ type: 'interrupt', sessionId, stopId: 'idle-before-prompt' }) + await rt.nextEvent('sdk.interrupt_settled') + expect(rt.events().filter((event) => event.type === 'sdk.session.init')).toEqual([]) + const query = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'query')! + expect(query.options).toMatchObject({ sessionId, cwd: rt.cwd, model: 'claude-opus-4-6' }) + + send(rt, sessionId, 'first prompt') + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ sessionId, cliSessionId: sessionId, cwd: rt.cwd, model: 'claude-opus-4-6' }) + await rt.nextEvent('sdk.result') }) - 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 } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'hello kilroy' }) - - const running = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running')) - expect((running as { sessionId: string }).sessionId).toBe(created.sessionId) - - const assistant = (await rt.nextEvent('sdk.assistant')) as { content: Array<{ type: string; text?: string }> } - expect(Array.isArray(assistant.content)).toBe(true) - expect(assistant.content[0].type).toBe('text') - expect(assistant.content[0].text).toContain('hello kilroy') - - 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' }) - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'again' }) + it('runs successive real turns with their submitted UUIDs and result facts, and mints no edge or synthetic idle', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'kilroy reply' } }] }] } }) + const sessionId = await create(rt) + const first = send(rt, sessionId, 'hello kilroy') + const assistant = await rt.nextEvent('sdk.assistant') + expect(assistant.content).toEqual([{ type: 'text', text: 'kilroy reply' }]) + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) + const second = send(rt, sessionId, 'again') 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( - (e, i) => i > rt.events().indexOf(result) && e.type === 'sdk.status' && e.status === 'running', - ) - expect(secondRunning).toBeGreaterThan(0) - expect(idleBetween(rt, rt.events().indexOf(result), secondRunning), 'no per-turn idle').toBe(false) - - const ledger = await readLedger(logPath) - expect(ledger.map((row) => (row.msg as { type: string }).type)).toEqual(['create', 'send', 'send']) + const prompts = (await readKilroyLedger(rt.ledgerPath)).filter((row) => row.kind === 'prompt') + expect(prompts.map((row) => [row.sessionId, row.uuid, row.text])).toEqual([ + [sessionId, first, 'hello kilroy'], [sessionId, second, 'again'], + ]) + expect(serverOnlyFrames(rt)).toEqual([]) + expect(rt.events().slice(rt.events().indexOf(result)).filter((event) => event.type === 'sdk.status' && event.status === 'idle')).toEqual([]) }) - 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 } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'needs approval' }) - - 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.result') // allowed by the fake after the delay - const types = rt.events().map((e) => (e as { type: string }).type) - 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('waits for the actual approval decision before completing and records what canUseTool received', async () => { + const rt = await make({ program: APPROVAL_PROGRAM }) + const sessionId = await create(rt) + send(rt, sessionId, 'needs approval') + const request = await rt.nextEvent('sdk.permission.request') + expect(request).toMatchObject({ sessionId, tool: { name: 'Bash', input: { command: 'ls' } } }) + await barrier(rt, sessionId) + expect(rt.events().some((event) => event.type === 'sdk.result')).toBe(false) + expect((await readKilroyLedger(rt.ledgerPath)).filter((row) => row.kind === 'can_use_tool')).toEqual([]) + rt.send({ type: 'permission.respond', sessionId, requestId: request.requestId, decision: { behavior: 'allow', updatedInput: { command: 'ls' } } }) + // A user answer resolves canUseTool; cancellation frames belong to aborts. + const assistant = await rt.nextEvent('sdk.assistant', (event) => event.content?.some((block: any) => block.text === 'approved reply')) + const result = await rt.nextEvent('sdk.result') + expect(rt.events().indexOf(request)).toBeLessThan(rt.events().indexOf(assistant)) + expect(rt.events().indexOf(assistant)).toBeLessThan(rt.events().indexOf(result)) + const decision = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'can_use_tool')! + expect(decision.decision).toMatchObject({ behavior: 'allow', updatedInput: { command: 'ls' } }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - 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 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('forwards a failed result with its error text while leaving attention accounting to Rust', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'completion', data: { subtype: 'error_max_turns', errors: ['Fixture turn failed'] } }] }] } }) + const sessionId = await create(rt) + send(rt, sessionId, 'fail me') + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, subtype: 'error_max_turns', isError: true, errorText: 'Fixture turn failed' }) + await barrier(rt, sessionId) + expect(serverOnlyFrames(rt)).toEqual([]) + expect(rt.events().filter((event) => event.type === 'sdk.error')).toEqual([]) }) - 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 } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - 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, 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) - - // 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', - ).toBe(false) - expect( - rt.events().some((e) => (e as { type: string }).type === 'sdk.exit'), - 'interrupt must never end the session', - ).toBe(false) + it('interrupts an actual held turn with the Stop receipt and aborted result, then runs the next turn', async () => { + const rt = await make() + const sessionId = await create(rt) + send(rt, sessionId, 'HANG') + await rt.nextEvent('sdk.status', (event) => event.status === 'requesting') + rt.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-1', ok: true, stillQueued: [] }) + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, subtype: 'error_during_execution', isError: true }) + expect(result.terminalReason).toMatch(/^aborted_/) + send(rt, sessionId, 'next unrelated turn') + expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - 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.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 aborted result. - const result = (await rt.nextEvent('sdk.result')) as Record - expect(result).toMatchObject({ result: 'error_during_execution', terminalReason: 'aborted_streaming' }) - - // The NEXT turn on the same live session runs to success. - rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) - 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('interrupts a pending approval through the SDK and keeps the session usable', async () => { + const rt = await make({ program: APPROVAL_PROGRAM }) + const sessionId = await create(rt) + send(rt, sessionId, 'needs approval') + const request = await rt.nextEvent('sdk.permission.request') + rt.send({ type: 'interrupt', sessionId, stopId: 'stop-approval' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-approval', ok: true }) + expect(await rt.nextEvent('sdk.result')).toMatchObject({ sessionId, subtype: 'error_during_execution', terminalReason: 'aborted_streaming' }) + expect(rt.events().some((event) => event.type === 'sdk.permission.cancelled' && event.requestId === request.requestId)).toBe(true) + send(rt, sessionId, 'next unrelated turn') + expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - 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 } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'interrupt', sessionId: created.sessionId }) - const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean } - expect(settle.ok).toBe(true) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) + it('settles an idle interrupt without a synthetic status or latch that affects the next turn', async () => { + const rt = await make() + const sessionId = await create(rt) + rt.send({ type: 'interrupt', sessionId, stopId: 'idle-stop' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'idle-stop', ok: true }) + send(rt, sessionId, 'next unrelated turn') expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(rt.events().filter((event) => event.type === 'sdk.status' && event.status === 'idle')).toEqual([]) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('crash knob kills the process mid-turn with NO completion edge (the Rust death synthesis rings, not the fake)', async () => { - const { rt } = await make({ FAKE_KILROY_CRASH_ON_SEND: '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: 'boom' }) - const code = await new Promise((resolve) => { - rt.proc.once('exit', (c) => resolve(c)) - }) - expect(code).toBe(3) - // A real crash screams no protocol frame — the fake must NOT mint an - // edge here or a real crash would double-ring (sidecar edge + the Rust - // unrequested-death synthesis). The RUST side rings for process death. - expect(rt.events().some((e) => (e as { type: string }).type === 'sdk.turn.complete')).toBe(false) + it('reports a mid-turn CLI crash as the real stream end and process exit, without a result or edge', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'crash' }] }] } }) + const sessionId = await create(rt) + send(rt, sessionId, 'crash now') + expect(await rt.nextEvent('sdk.stream.ended')).toMatchObject({ sessionId, reason: 'cli_exited' }) + expect(await rt.waitExit()).toMatchObject({ code: 3, signal: null }) + expect(rt.events().filter((event) => event.type === 'sdk.result')).toEqual([]) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('resume keeps the durable cliSessionId and replays a session snapshot', async () => { + it('resumes the saved native conversation and initializes it only after the next prompt', async () => { + const rt = await make() const durable = randomUUID() - const { rt } = await make() - rt.send({ type: 'create', protocol: 2, requestId: 'r-1', cwd: '/tmp', boot: { kind: 'resume', resume: durable } }) - const init = (await rt.nextEvent('sdk.session.init')) as { cliSessionId: string } - expect(init.cliSessionId).toBe(durable) - const snapshot = (await rt.nextEvent('sdk.session.snapshot')) as { messages: unknown[] } - expect(Array.isArray(snapshot.messages)).toBe(true) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') + const project = path.join(rt.claudeConfigDir, 'projects', 'saved') + await fs.mkdir(project, { recursive: true }) + const saved = path.join(project, `${durable}.jsonl`) + await fs.writeFile(saved, `${JSON.stringify({ type: 'user', uuid: randomUUID(), parentUuid: null, sessionId: durable, cwd: rt.cwd, message: { role: 'user', content: 'saved history' } })}\n`) + expect(await create(rt, { sessionId: durable, boot: { kind: 'resume', resume: durable } })).toBe(durable) + expect(rt.events().some((event) => event.type === 'sdk.session.init')).toBe(false) + const query = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'query')! + expect(query.options.resume).toBe(durable) + const uuid = send(rt, durable, 'continue the conversation') + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ sessionId: durable, cliSessionId: durable }) + await rt.nextEvent('sdk.result') + const prompt = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'prompt')! + expect(prompt).toMatchObject({ sessionId: durable, uuid, text: 'continue the conversation' }) + await expect.poll(() => fs.readFile(saved, 'utf8')).toContain('continue the conversation') + const lines = (await fs.readFile(saved, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(lines).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: { role: 'user', content: 'saved history' } }), + expect.objectContaining({ sessionId: durable, uuid, type: 'user' }), + ])) }) - it('shutdown exits 0', async () => { - const { rt } = await make() + it('shutdown exits zero even before a session is created', async () => { + const rt = await make() rt.send({ type: 'shutdown' }) - const code = await new Promise((resolve) => rt.proc.once('exit', (c) => resolve(c))) - expect(code).toBe(0) - runtimes.pop() // already exited + expect(await rt.waitExit()).toEqual({ code: 0, signal: null }) + }) + + it('cleanup confirms the sidecar exit and removes the owned workspace and ledger', async () => { + const rt = await make() + const sessionId = await create(rt) + const boot = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'boot')! + const cliPid = boot.cliPid as number + let cliStart: string | undefined + const running = async () => { + if (process.platform === 'linux') { + try { + const stat = await fs.readFile(`/proc/${cliPid}/stat`, 'utf8') + const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/) + if (cliStart !== undefined && fields[19] !== cliStart) return false + cliStart ??= fields[19] + return fields[0] !== 'Z' && fields[0] !== 'X' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } + } + try { process.kill(cliPid, 0); return true } catch { return false } + } + expect(sessionId).toBe(boot.sessionId) + expect(await running(), 'the actual simulated CLI is alive before cleanup').toBe(true) + await rt.kill() + expect(rt.proc.exitCode).toBe(0) + expect(rt.proc.signalCode).toBeNull() + await expect.poll(running).toBe(false) + await expect(fs.stat(rt.cwd)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(rt.ledgerPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) }) diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts index d18d3fdbd..d766ad964 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts @@ -1,103 +1,129 @@ import { spawn, type ChildProcess } from 'node:child_process' import readline from 'node:readline' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' -/** - * HARNESS-06 kilroy-runtime driver: spawns `fixtures/fake-kilroy-runtime.mjs` - * and exposes a typed stdio protocol client over it ("the harness-level full - * Kilroy runtime"). The same process can also be used as a REAL server's - * sidecar via the production seam `FRESHELL_CLAUDE_SIDECAR=` (the - * protocol is verbatim); this driver is for fixture-direct smoke/contract - * assertions. - */ - -export interface KilroyRequestLogEntry { +/** A Kilroy-flavoured production sidecar over the contract-tested CLI simulator. */ +export interface KilroyLedgerEntry { + kind: string pid: number - t: number - msg: Record + [key: string]: any +} + +export interface KilroyRuntimeOptions { + program?: { rules: unknown[] } + timeScale?: number } export interface KilroyRuntime { proc: ChildProcess - /** Write one protocol message (a single JSON line) to the sidecar's stdin. */ + cwd: string + claudeConfigDir: string + ledgerPath: string + /** Write a real sidecar request. The caller supplies native create/send UUIDs. */ send: (msg: Record) => void - /** - * Wait for the NEXT event matching `type` (+ optional predicate) after the - * runtime's read cursor. Sequential calls consume successive events, so - * `await nextEvent('sdk.status', s==='idle')` twice yields distinct idles. - */ + /** Consume successive matching frames after this runtime's read cursor. */ nextEvent: ( type: string, - pred?: (event: Record) => boolean, + pred?: (event: Record) => boolean, timeoutMs?: number, - ) => Promise> - /** All events parsed from stdout so far (in arrival order). */ - events: () => readonly Record[] + ) => Promise> + events: () => readonly Record[] + waitExit: (timeoutMs?: number) => Promise<{ code: number | null; signal: NodeJS.Signals | null }> + /** Gracefully close the owned sidecar/CLI, then remove their isolated files. */ kill: () => Promise } -const FIXTURE = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../fixtures/fake-kilroy-runtime.mjs', -) +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..') +const SIDECAR = path.join(REPO_ROOT, 'crates/freshell-claude-sidecar/index.mjs') +const SIMULATOR = path.join(REPO_ROOT, 'test/fixtures/claude-cli-sim/query.mjs') -export async function spawnFakeKilroy(env: NodeJS.ProcessEnv = {}): Promise { - const proc = spawn(process.execPath, [FIXTURE], { - env: { ...process.env, ...env }, +export async function spawnKilroyRuntime(options: KilroyRuntimeOptions = {}): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-h06-kilroy-')) + const home = path.join(root, 'home') + const cwd = path.join(root, 'workspace') + const tmp = path.join(root, 'tmp') + for (const directory of [home, cwd, tmp]) await fs.mkdir(directory, { recursive: true }) + const claudeConfigDir = path.join(home, '.claude') + const ledgerPath = path.join(root, 'sim-ledger.jsonl') + const proc = spawn(process.execPath, [SIDECAR], { + cwd, + env: { + HOME: home, + PATH: process.env.PATH ?? '', + TMPDIR: tmp, + LANG: 'C.UTF-8', + CLAUDE_CONFIG_DIR: claudeConfigDir, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: pathToFileURL(SIMULATOR).href, + FRESHELL_CLAUDE_SIM_LEDGER: ledgerPath, + FRESHELL_CLAUDE_SIM_TIME_SCALE: String(options.timeScale ?? 1), + FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(options.program ?? { rules: [] }), + }, stdio: ['pipe', 'pipe', 'pipe'], }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + proc.once('exit', (code, signal) => resolve({ code, signal })) + }) + proc.stdin!.on('error', () => { /* an already-ended sidecar cannot accept another request */ }) - const events: Record[] = [] + const events: Record[] = [] let cursor = 0 const waiters: Array<{ type: string - pred?: (event: Record) => boolean - resolve: (event: Record) => void + pred?: (event: Record) => boolean + resolve: (event: Record) => void timer: NodeJS.Timeout }> = [] - const rl = readline.createInterface({ input: proc.stdout! }) rl.on('line', (line) => { - let event: Record - try { - event = JSON.parse(line) as Record - } catch { - return - } + let event: Record + try { event = JSON.parse(line) } catch { return } events.push(event) for (let i = waiters.length - 1; i >= 0; i--) { - const w = waiters[i] - const idx = events.findIndex( - (e, j) => j >= cursor && e.type === w.type && (!w.pred || w.pred(e)), + const waiter = waiters[i] + const index = events.findIndex( + (candidate, j) => j >= cursor && candidate.type === waiter.type && (!waiter.pred || waiter.pred(candidate)), ) - if (idx >= 0) { - cursor = idx + 1 + if (index >= 0) { + cursor = index + 1 waiters.splice(i, 1) - clearTimeout(w.timer) - w.resolve(events[idx]) + clearTimeout(waiter.timer) + waiter.resolve(events[index]) } } }) - - const stderrChunks: Buffer[] = [] - proc.stderr!.on('data', (c: Buffer) => stderrChunks.push(c)) - - // Ensure the child has booted its readline loop before callers write — - // the fixture processes lines strictly in order, so stdin writes are safe - // immediately after spawn (pipe buffering). We return right away. - + let stderr = '' + proc.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + const waitExit = async (timeoutMs = 10_000) => { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Kilroy sidecar did not exit: ${stderr.slice(-1000)}`)), timeoutMs) + timer.unref() + }), + ]) + } finally { + clearTimeout(timer) + } + } + let cleanup: Promise | undefined return { proc, + cwd, + claudeConfigDir, + ledgerPath, send: (msg) => { - if (!proc.stdin || proc.killed) throw new Error('fake-kilroy sidecar stdin unavailable') + if (!proc.stdin || proc.exitCode !== null || proc.signalCode !== null) throw new Error('Kilroy sidecar stdin unavailable') proc.stdin.write(`${JSON.stringify(msg)}\n`) }, nextEvent: (type, pred, timeoutMs = 15_000) => new Promise((resolve, reject) => { const existing = events.findIndex( - (e, j) => j >= cursor && e.type === type && (!pred || pred(e)), + (event, j) => j >= cursor && event.type === type && (!pred || pred(event)), ) if (existing >= 0) { cursor = existing + 1 @@ -105,30 +131,38 @@ export async function spawnFakeKilroy(env: NodeJS.ProcessEnv = {}): Promise { - const i = waiters.findIndex((w) => w.timer === timer) - if (i >= 0) waiters.splice(i, 1) - reject( - new Error( - `timed out (${timeoutMs}ms) waiting for fake-kilroy event ${type}; ` + - `seen so far: ${events.map((e) => e.type).join(', ')}; ` + - `stderr: ${Buffer.concat(stderrChunks).toString('utf8').slice(-500)}`, - ), - ) + const index = waiters.findIndex((waiter) => waiter.timer === timer) + if (index >= 0) waiters.splice(index, 1) + reject(new Error(`timed out waiting for Kilroy ${type}; frames=${JSON.stringify(events).slice(-3000)}; stderr=${stderr.slice(-1000)}`)) }, timeoutMs) waiters.push({ type, pred, resolve, timer }) }), events: () => events, - kill: async () => { - if (proc.exitCode !== null || proc.killed) return - const exited = new Promise((resolve) => proc.once('exit', () => resolve())) - try { proc.kill('SIGKILL') } catch { /* already dead */ } - await exited - rl.close() - }, + waitExit, + kill: () => cleanup ??= (async () => { + try { + if (proc.exitCode === null && proc.signalCode === null) { + proc.stdin!.write(`${JSON.stringify({ type: 'shutdown' })}\n`) + try { await waitExit(5_000) } catch { + proc.kill('SIGKILL') + await waitExit(5_000) + } + } + } finally { + rl.close() + for (const waiter of waiters) clearTimeout(waiter.timer) + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + })(), } } -export async function readKilroyLedger(logPath: string): Promise { - const text = await fs.readFile(logPath, 'utf8') - return text.split('\n').filter(Boolean).map((l) => JSON.parse(l) as KilroyRequestLogEntry) +/** What the actual SDK query and simulated CLI received, rather than synthetic wire requests. */ +export async function readKilroyLedger(logPath: string): Promise { + let text: string + try { text = await fs.readFile(logPath, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + return text.slice(0, text.lastIndexOf('\n') + 1).split('\n').filter(Boolean).map((line) => JSON.parse(line)) } diff --git a/test/e2e-browser/helpers/managed-runtime.ts b/test/e2e-browser/helpers/managed-runtime.ts index 47907196a..144585397 100644 --- a/test/e2e-browser/helpers/managed-runtime.ts +++ b/test/e2e-browser/helpers/managed-runtime.ts @@ -11,6 +11,7 @@ import { type ProviderQualificationRow, } from '../../../scripts/testing/provider-qualification-receipt.js' import { RuntimeHarness, type RuntimeFixtureInputs, type SupervisorInstance } from '../../../scripts/testing/runtime-sandbox.js' +import { preparedRuntimeBinary } from '../../../scripts/testing/runtime-build-preflight.js' import { RustServer } from './rust-server.js' import type { E2eServerInfo as TestServerInfo } from './server-fixture-support.js' import type { QualifiableTerminalProvider } from '../../../scripts/testing/provider-qualification-selection.js' @@ -518,13 +519,16 @@ export class ManagedRuntimeBrowserRig { private buildManagedServer(): string { const mise = path.join(os.homedir(), '.local', 'bin', 'mise') const qualification = this.pendingQualificationProviders() - execFileSync(mise, [ + const features = this.fixtureFreshAgentModes.length > 0 + ? 'managed-fresh-agent-fixtures' + : qualification.length ? 'managed-provider-qualification' : 'managed-runtime-v1' + const prepared = features === 'managed-runtime-v1' ? undefined + : preparedRuntimeBinary(this.repoRoot, `server-${features}`) + if (!prepared) execFileSync(mise, [ 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-server', - '--features', this.fixtureFreshAgentModes.length > 0 - ? 'managed-fresh-agent-fixtures' - : qualification.length ? 'managed-provider-qualification' : 'managed-runtime-v1', + '--features', features, ], { cwd: this.repoRoot, stdio: 'inherit' }) - const source = path.join(this.repoRoot, 'target', 'release', 'freshell-server') + const source = prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-server') const target = path.join(this.runtime.buildDir, 'freshell-server-managed') fs.copyFileSync(source, target) fs.chmodSync(target, 0o755) @@ -534,12 +538,13 @@ export class ManagedRuntimeBrowserRig { private buildQualificationSupervisor(): string | undefined { if (this.pendingQualificationProviders().length === 0) return undefined const mise = path.join(os.homedir(), '.local', 'bin', 'mise') - execFileSync(mise, [ + const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-qualification') + if (!prepared) execFileSync(mise, [ 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification', ], { cwd: this.repoRoot, stdio: 'inherit' }) const target = path.join(this.runtime.buildDir, 'freshell-supervisor-qualification') - fs.copyFileSync(path.join(this.repoRoot, 'target', 'release', 'freshell-supervisor'), target) + fs.copyFileSync(prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-supervisor'), target) fs.chmodSync(target, 0o755) return target } diff --git a/test/e2e-browser/helpers/unified-agent-names-modes.ts b/test/e2e-browser/helpers/unified-agent-names-modes.ts index b3bc94253..2338a7928 100644 --- a/test/e2e-browser/helpers/unified-agent-names-modes.ts +++ b/test/e2e-browser/helpers/unified-agent-names-modes.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises' +import path from 'node:path' import { expect, type Browser } from './fixtures.js' import type { SessionNameRef } from '../../shared/session-names.js' import { @@ -515,15 +517,88 @@ export async function twoBrowsersAndAnloadedHistoryConverge(mode: UnifiedAgentMo } } +function countClaudeTurnCompleteEvents(messages: unknown[], identities: Array): number { + const ids = new Set(identities.filter((identity): identity is string => Boolean(identity))) + return messages.filter((raw) => { + if (!raw || typeof raw !== 'object') return false + const message = raw as { type?: unknown; sessionId?: unknown; event?: { type?: unknown; sessionId?: unknown } } + return message.type === 'freshAgent.event' + && message.event?.type === 'freshAgent.turn.complete' + && (ids.has(String(message.sessionId ?? '')) || ids.has(String(message.event?.sessionId ?? ''))) + }).length +} + +async function hasSavedClaudeExchanges( + homeDir: string, + projectDir: string, + sessionId: string, + exchanges: Array<{ prompt: string; reply: string }>, +): Promise { + const projectSlug = projectDir.replace(/[^A-Za-z0-9]/g, '-') + const transcriptPath = path.join(homeDir, '.claude', 'projects', projectSlug, `${sessionId}.jsonl`) + const contents = await fs.readFile(transcriptPath, 'utf8').catch(() => '') + const rows = contents.split(/\r?\n/).flatMap((line) => { + if (!line.trim()) return [] + try { + return [JSON.parse(line) as { type?: string; message?: { content?: unknown } }] + } catch { + // The simulator appends JSONL asynchronously; ignore an incomplete final line + // while polling for the flushed record. + return [] + } + }) + const messageText = (row: { message?: { content?: unknown } }): string => { + const content = row.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.flatMap((block: unknown) => { + if (!block || typeof block !== 'object') return [] + const item = block as { type?: unknown; text?: unknown } + return item.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + }).join('') + } + + let cursor = 0 + for (const exchange of exchanges) { + const userIndex = rows.findIndex((row, index) => index >= cursor && row.type === 'user' && messageText(row) === exchange.prompt) + if (userIndex < 0) return false + const nextUserIndex = rows.findIndex((row, index) => index > userIndex && row.type === 'user') + const end = nextUserIndex < 0 ? rows.length : nextUserIndex + const hasReply = rows.slice(userIndex + 1, end) + .some((row) => row.type === 'assistant' && messageText(row).includes(exchange.reply)) + if (!hasReply) return false + cursor = userIndex + 1 + } + return true +} + export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAgentMode, browser: Browser): Promise { { const gemini = await startFakeGemini(AI_NAME, { hold: true }) - const journey = await bootJourney(mode, { gemini }) + const firstOldPrompt = 'Hold the generation request' + const firstOldReply = 'First old conversation reply is saved' + const secondOldPrompt = 'A second turn keeps the old session listed' + const secondOldReply = 'Second old conversation reply is saved' + const freshClaudeProgram = mode === 'freshclaude' + ? JSON.stringify({ + rules: [ + { on: 'msg:prompt', match: { text: firstOldPrompt }, once: true, emit: [{ kind: 'text', data: { text: firstOldReply } }] }, + { on: 'msg:prompt', match: { text: secondOldPrompt }, once: true, emit: [{ kind: 'text', data: { text: secondOldReply } }] }, + ], + }) + : undefined + const journey = await bootJourney(mode, { + gemini, + ...(freshClaudeProgram ? { env: { FRESHELL_CLAUDE_SIM_PROGRAM: freshClaudeProgram } } : {}), + }) try { const { page, harness, context } = await connect(journey, browser) const created = await createNamedAgent(harness, mode, { entry: 'ui', page, server: journey.server }) const handle = await refreshHandle(harness, created, mode) - await sendFirstMessage(page, harness, handle, mode, 'Hold the generation request') + const firstCompletionCount = mode === 'freshclaude' + ? countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), [handle.sessionId]) + : 0 + await sendFirstMessage(page, harness, handle, mode, firstOldPrompt) // The generation request is provably in flight. await expect.poll(() => gemini.requests.length, { timeout: 30_000 }).toBeGreaterThan(0) // The manual rename commits mid-flight. @@ -537,6 +612,19 @@ export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAge await page.waitForTimeout(3_000) const sessionId = await resolveSessionId(journey, handle) const sessionRef: SessionNameRef = { kind: 'session', provider: (await modeFixture(mode)).provider, sessionId } + if (mode === 'freshclaude') { + const identities = [handle.sessionId, sessionId] + await expect.poll( + async () => countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), identities), + { timeout: 30_000, message: 'the first old-session Claude turn completes' }, + ).toBeGreaterThan(firstCompletionCount) + await expect.poll( + () => hasSavedClaudeExchanges(journey.server.info.homeDir, journey.server.projectDir, sessionId, [ + { prompt: firstOldPrompt, reply: firstOldReply }, + ]), + { timeout: 30_000, message: 'the first old-session exchange is flushed to its native transcript' }, + ).toBe(true) + } let record = await readRecord(journey, sessionRef) expect(record.name).toBe(manualName) expect(record.source).toBe('manual') @@ -549,7 +637,24 @@ export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAge // transcript the real-exchange shape (the directory listing's // parity filter hides a single-turn session, and after the switch // no live pane carries the old row). - await sendFirstMessage(page, harness, handle, mode, 'A second turn keeps the old session listed') + const oldSessionIdentities = [handle.sessionId, sessionId] + const secondCompletionCount = mode === 'freshclaude' + ? countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), oldSessionIdentities) + : 0 + await sendFirstMessage(page, harness, handle, mode, secondOldPrompt) + if (mode === 'freshclaude') { + await expect.poll( + async () => countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), oldSessionIdentities), + { timeout: 30_000, message: 'the second old-session Claude turn completes before /new' }, + ).toBeGreaterThan(secondCompletionCount) + await expect.poll( + () => hasSavedClaudeExchanges(journey.server.info.homeDir, journey.server.projectDir, sessionId, [ + { prompt: firstOldPrompt, reply: firstOldReply }, + { prompt: secondOldPrompt, reply: secondOldReply }, + ]), + { timeout: 30_000, message: 'both completed old-session exchanges are flushed before /new' }, + ).toBe(true) + } const fixture = await modeFixture(mode) if (fixture.fresh) { // The pane's REAL conversation-switch control: the `/new` slash diff --git a/test/e2e-browser/helpers/unified-agent-names.ts b/test/e2e-browser/helpers/unified-agent-names.ts index d1e2fcde1..5f14363c7 100644 --- a/test/e2e-browser/helpers/unified-agent-names.ts +++ b/test/e2e-browser/helpers/unified-agent-names.ts @@ -12,6 +12,7 @@ import type { E2eServerInfo } from './server-fixture-support.js' import { selectShellFromPicker } from './test-harness.js' import { openPanePicker } from './pane-picker.js' import type { SessionNameRef } from '../../../shared/session-names.js' +import { claudeSimulatorServerEnv } from './claude-sim-lane.js' /** * UNIFIED AGENT NAMES (plan Task 8) — the shared six-mode journey helper. @@ -263,10 +264,9 @@ export async function modeFixture(mode: UnifiedAgentMode): Promise provider: 'claude', fresh: true, pickerLabel: /^Freshclaude$/, - env: async () => ({ - FRESHELL_CLAUDE_SIDECAR: path.join(providers, 'fake-claude-sdk-sidecar.mjs'), - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', + env: async (root) => claudeSimulatorServerEnv({ + ledgerPath: path.join(root, 'claude-sim-ledger.jsonl'), + flavour: 'freshclaude', }), } case 'freshcodex': @@ -329,7 +329,6 @@ export async function bootUnifiedNamesServer(opts: BootUnifiedNamesOptions = {}) const env: Record = { GOOGLE_GENERATIVE_AI_API_KEY: '', ...(opts.gemini ? { FRESHELL_GEMINI_BASE_URL: opts.gemini.baseUrl } : {}), - ...(opts.env ?? {}), } let enabledProviders: string[] = [] if (opts.mode) { @@ -337,6 +336,7 @@ export async function bootUnifiedNamesServer(opts: BootUnifiedNamesOptions = {}) enabledProviders = [fixture.provider] Object.assign(env, await fixture.env(root)) } + Object.assign(env, opts.env ?? {}) const server = new RustServer({ port: opts.port, homeDir: opts.homeDir ?? debugHome, @@ -1079,9 +1079,15 @@ export async function expectSharedName( server, ) if (sessionId) { - await expect( - page.locator(`[data-context="sidebar-session"][data-session-id="${sessionId}"]`), - ).toContainText(expected, { timeout: 20_000 }) + const row = page.locator(`[data-context="sidebar-session"][data-session-id="${sessionId}"]`) + if (!(await row.isVisible().catch(() => false))) { + // Conversation switches and rename surfaces can leave the sidebar on + // another view. The assertion is about the session directory row, so + // select its owning view before deciding that the old session is + // absent from the directory. + await page.getByTitle('Coding Agents (Ctrl+B T)').click() + } + await expect(row).toContainText(expected, { timeout: 20_000 }) } } 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 6ab0808c0..062723ccf 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1,15 +1,15 @@ /** * FRESH-AGENT CONTROL SURFACES — PW-RUST e2e validation (AGENT-04/05/06/07/24). * - * Drives the REAL Rust server + real browser UI against hermetic provider - * fakes and pins the four control surfaces end-to-end: + * Drives the REAL Rust server + real browser UI against the production Claude + * sidecar over the CLI simulator, plus the existing Codex/OpenCode fixtures, + * and pins the control surfaces end-to-end: * * AGENT-05 (approval responses + cancellation): Allow/Deny clicks must - * write the EXACT `permission.respond` frame to the sidecar - * stdin (FRESHELL_FAKE_STDIN raw audit log) — and provably ZERO frames - * before the click; reload-while-pending restores exactly one card from - * the REST snapshot overlay; the composer Stop button cancels without - * inventing a user decision. + * Allow/Deny clicks reach the simulated CLI's real canUseTool callback — + * and provably no decision exists before the click; reload-while-pending + * restores exactly one card from the REST snapshot overlay; composer Stop + * cancels without inventing a user decision. * AGENT-06 (question responses): single-choice / multi-select / Other * answers land in `question.respond` keyed by QUESTION TEXT, and the turn * continues. @@ -31,35 +31,6 @@ * capabilities.fork=true since C14; the fork itself is pinned end to end * by freshclaude-fork-rust.spec.ts). * - * RESIDUAL HONESTY (also recorded in the checklist note): these fakes pin the - * CONTRACT SHAPE + server/client wiring. Real-provider semantic fidelity of - * compact content is out of scope for shape tests by design (the real-provider - * contract suites are opt-in per repo policy). - * - * ── PRE-FIX NEGATIVE (mutation-check recipe) ──────────────────────────────── - * The spec must fail loudly without the response wiring. Prove it by pointing - * the claude lane at a NO-OP stub that answers create but never raises a card - * (NOTE: the stub must be .cjs — a require()-based stub in a .mjs file crashes - * at import under NodeNext, which would fail the run for the WRONG reason): - * 1. Write /tmp/noop-sidecar.cjs: - * const rl = require('readline').createInterface({ input: process.stdin }) - * rl.on('line', (line) => { - * let m; try { m = JSON.parse(line) } catch { return } - * if (m.type === 'create') { - * const id = 'noop-' + process.pid - * process.stdout.write(JSON.stringify({ type:'created', requestId:m.requestId, sessionId:id })+'\n') - * process.stdout.write(JSON.stringify({ type:'sdk.session.init', sessionId:id, cliSessionId:'66666666-6666-4666-8666-666666666666', model:'noop', cwd:m.cwd||process.cwd(), tools:[] })+'\n') - * process.stdout.write(JSON.stringify({ type:'sdk.status', sessionId:id, status:'idle' })+'\n') - * } - * }) - * process.stdin.resume() - * 2. FRESHELL_FAKE_NOOP=1 pnpm exec playwright test --config test/e2e-browser/playwright.config.ts \ - * Expected: the approval-allow test fails — the approval card never renders - * (timeout waiting for role=alert "Permission request for Bash"), proving - * the assertion chain is not vacuous. (Recorded in the Task-8 report; with - * the Tasks 1-6 server wiring REMOVED the failure is instead the click - * producing zero `permission.respond` lines in the sidecar stdin audit.) - * * Donors (helpers copied, not imported, per this suite's per-spec-ownership * convention): freshclaude-identity-persistence-rust.spec.ts (boot/identity * plumbing), freshagent-settings-resume-rust.spec.ts (CODEX_CMD/OPENCODE_CMD @@ -81,11 +52,11 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js'; import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') const CODEX_APP_SERVER_FIXTURE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', @@ -95,26 +66,21 @@ const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs' const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -// ── Fixture program (the shared claude/kilroy lane) ───────────────────────── -// Text-driven raises; responds continue the turn: allow → success completion, -// deny → a NON-SUCCESS closure that rings the unified attention edge — a deny -// is an unwitnessed-error turn end and rings exactly like any other turn end -// (the edge is outcome-agnostic; only a user-initiated interrupt stays -// silent), question answers → success completion. Compact emits the compacting -// status then completes after a pollable window. Crash exits the process -// mid-turn — no protocol frames, like a real sidecar death (the Rust -// unrequested-death synthesis rings for it, never the fake). +// ── Simulator program (the shared Claude/Kilroy lane) ─────────────────────── +// Text-driven permission and question requests. The simulator's real +// canUseTool callback handles the user's answer; its local /compact command +// and process-death behavior supply the remaining lifecycle events. const CLAUDE_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_PERMISSION' }, emit: [ { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, ], }, { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION' }, emit: [ { @@ -137,7 +103,7 @@ const CLAUDE_PROGRAM = { ], }, { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION_MULTI' }, emit: [ { @@ -161,7 +127,7 @@ const CLAUDE_PROGRAM = { ], }, { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION_OTHER' }, emit: [ { @@ -180,41 +146,10 @@ const CLAUDE_PROGRAM = { }, ], }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'allow' } }, - emit: [{ kind: 'completion', data: { subtype: 'success' } }], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'deny' } }, - emit: [ - { - kind: 'completion', - delayMs: 20, - data: { subtype: 'error', text: 'The user denied this request.' }, - }, - ], - }, - { - on: 'msg:question.respond', - emit: [{ kind: 'completion', delayMs: 10, data: { subtype: 'success' } }], - }, - { - on: 'msg:send', - match: { text: '/compact focus the diff' }, - emit: [ - { kind: 'activity', data: { status: 'compacting' } }, - // The compacting indicator assertion polls attribute state; under - // full-suite multi-worker load the page's rAF polling can stall beyond a - // few hundred ms, so the hold must comfortably outlast that (1500ms). - { kind: 'completion', delayMs: 1500, data: { subtype: 'success' } }, - ], - }, { // The 3s delay is the window in which the crash lane moves the user to // another tab, so the crash ends a turn nobody is watching. - on: 'msg:send', + on: 'msg:prompt', match: { text: 'CRASH_NOW' }, emit: [{ kind: 'crash', delayMs: 3_000, data: { code: 1 } }], }, @@ -345,6 +280,7 @@ async function bootWall( env: options.env, stripEnvPrefixes: options.stripEnvPrefixes, setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) @@ -382,19 +318,6 @@ function readJsonl(filePath: string): any[] { .filter((row) => row !== null) } -/** Read the sidecar raw-stdin audit, JSON-parsing each recorded frame. */ -function readStdinFrames(stdinLog: string): any[] { - return readJsonl(stdinLog) - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((frame) => frame !== null) -} - async function waitForLogEntry( logPath: string, pred: (row: any) => boolean, @@ -414,9 +337,9 @@ async function waitForLogEntry( return found } -async function waitForStdinFrame( - stdinLog: string, - pred: (frame: any) => boolean, +async function waitForSimRow( + simLedger: string, + pred: (row: any) => boolean, what: string, timeoutMs = 15_000, ): Promise { @@ -424,10 +347,10 @@ async function waitForStdinFrame( await expect .poll( () => { - found = readStdinFrames(stdinLog).find(pred) ?? null + found = readClaudeSimLedger(simLedger).find(pred) ?? null return Boolean(found) }, - { timeout: timeoutMs, message: `timed out waiting for ${what} in ${stdinLog}` }, + { timeout: timeoutMs, message: `timed out waiting for ${what} in ${simLedger}` }, ) .toBe(true) return found @@ -527,16 +450,12 @@ function claudeLaneEnv( flavour: 'freshclaude' | 'kilroy', extraEnv: Record = {}, ): Record { - const sidecar = process.env.FRESHELL_FAKE_NOOP === '1' - ? '/tmp/noop-sidecar.cjs' - : CLAUDE_SIDECAR_FIXTURE return { - FRESHELL_CLAUDE_SIDECAR: sidecar, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: flavour, - FRESHELL_FAKE_PROGRAM: JSON.stringify(CLAUDE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + flavour, + program: CLAUDE_PROGRAM, + }), ...extraEnv, } } @@ -552,8 +471,7 @@ async function bootClaudeLane( harness: TestHarness sharedRoot: string projectDir: string - stdinLog: string - eventsLog: string + simLedger: string tabId: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), `freshell-agentctl-${flavour}-`)) @@ -591,8 +509,7 @@ async function bootClaudeLane( harness, sharedRoot, projectDir, - stdinLog: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - eventsLog: path.join(sharedRoot, 'sidecar-events.jsonl'), + simLedger: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), tabId, } } catch (error) { @@ -602,21 +519,60 @@ async function bootClaudeLane( } /** Raise the scripted Bash permission and wait until its card is up, proving - * ZERO respond frames landed before the user's click. */ + * no decision reached the CLI before the user's click. */ async function raisePermissionAndAssertNoDecisions( page: Page, harness: TestHarness, tabId: string, - stdinLog: string, + simLedger: string, ): Promise { + // Claude's REST create default is bypassPermissions unless the user saved a + // provider default. This scenario exercises canUseTool, so choose the + // product's asking mode explicitly instead of relying on that saved default. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect.poll(async () => (await paneLeaf(harness, tabId))?.content?.permissionMode ?? null) + .toBe('default') + await waitForSimRow( + simLedger, + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + 'the CLI asking permission mode', + ) + await sendComposerText(page, 'RAISE_PERMISSION') + const raiseSend = (await harness.getSentWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.send' && message?.text === 'RAISE_PERMISSION', + ) + expect(raiseSend?.settings?.permissionMode, 'the approval send carries the asking mode').toBe('default') const card = page.getByRole('alert', { name: 'Permission request for Bash' }) - await expect(card).toBeVisible({ timeout: 15_000 }) - // AGENT-05 pause-proof: the fake has received zero decisions — the card came + try { + await expect(card).toBeVisible({ timeout: 15_000 }) + } catch (error) { + const rows = readClaudeSimLedger(simLedger) + const received = (await harness.getReceivedWsMessages()) as any[] + const diagnostic = { + severity: 'error', + event: 'e2e.claude_approval_card_diagnostic', + queryPermissionModes: rows.filter((row) => row.kind === 'query') + .map((row) => row.options?.permissionMode ?? null), + promptPermissionModes: rows.filter((row) => row.kind === 'prompt') + .map((row) => row.permissionMode ?? null), + approvalEmissionCount: rows.filter((row) => row.kind === 'approval').length, + canUseToolOutcomes: rows.filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, behavior: row.decision?.behavior, rejected: Boolean(row.error) })), + receivedFreshAgentEvents: received + .filter((message) => message?.type === 'freshAgent.event') + .map((message) => ({ type: message.event?.type, code: message.event?.code })) + .slice(-30), + } + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostic)}`) + } + // AGENT-05 pause-proof: the CLI has received zero decisions — the card came // from the snapshot overlay, not from any fabricated user choice. expect( - readStdinFrames(stdinLog).filter((f) => f?.type === 'permission.respond'), - 'no permission.respond may exist before a user click', + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'can_use_tool'), + 'no canUseTool decision may exist before a user click', ).toEqual([]) // And the raise is pending server-side too (the REST snapshot overlay the // card renders from carries exactly one pending approval). @@ -632,26 +588,23 @@ async function raisePermissionAndAssertNoDecisions( test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test.setTimeout(180_000) - test('approval: Allow writes the exact permission.respond, never before the click', async ({ page }) => { + test('approval: Allow reaches the simulated CLI only after the click', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) await card.getByRole('button', { name: 'Allow tool use' }).click() - // The decision reaches the sidecar stdin VERBATIM (the transparency the - // pre-fix UNSUPPORTED_MESSAGE path destroyed). - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + // The production sidecar resolves its parked canUseTool callback with + // the user's decision; the simulator ledger records that actual result. + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.requestId).toBe('req-perm-1') - expect(respond.decision).toEqual({ behavior: 'allow' }) - const leaf = await paneLeaf(lane.harness, lane.tabId) - expect(respond.sessionId).toBe(leaf?.content?.sessionId) + expect(response.decision).toEqual({ behavior: 'allow' }) // The scripted continuation completes the turn; the card clears. await expect(card).toBeHidden({ timeout: 15_000 }) @@ -662,11 +615,11 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { } }) - test('approval: Deny writes the deny decision, no success completion, rings the unified attention edge, pane stays usable', async ({ page }) => { + test('approval: Deny reaches the CLI, rings the unified attention edge, and leaves the pane usable', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) @@ -682,63 +635,19 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await card.getByRole('button', { name: 'Deny tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'deny', message: 'Denied by user', interrupt: false }) + expect(response.decision).toMatchObject({ behavior: 'deny', message: 'Denied by user' }) - // The denial closes the turn WITHOUT a success completion (the fixture - // keys its scripted continuation on the decision behavior). + // A denied tool result returns control to the simulated model, which + // completes its response just as the real CLI can. await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const completions = readJsonl(lane.eventsLog).filter((e) => e.kind === 'completion') - expect( - completions.length, - 'exactly one scripted completion exists for this deny cycle', - ).toBe(1) - expect( - completions.every((e) => e.data?.subtype === 'error'), - 'a deny must never produce a success completion', - ).toBe(true) - - // 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( - (r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === deniedBridgeId - && r.frame?.result === 'error', - ) - await expect - .poll( - () => denyResultIndex(denyWires()), - { timeout: 15_000, message: 'the errored sdk.result for the denied turn' }, - ) - .toBeGreaterThanOrEqual(0) - const wires = denyWires() - const denyResultIdx = denyResultIndex(wires) - expect( - 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) - .filter((r) => r.frame?.type === 'sdk.status' && r.frame?.sessionId === deniedBridgeId && r.frame?.status === 'idle'), - 'no per-turn idle follows the result (the real sidecar sends no idle at all)', - ).toEqual([]) - expect( - wires.some((r) => r.frame?.type === 'sdk.assistant' && r.frame?.sessionId === deniedBridgeId - && r.frame?.content?.[0]?.text?.includes('denied')), - 'the denial assistant frame did arrive on the wire', - ).toBe(true) + const deniedBridgeId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string + expect(deniedBridgeId).toBeTruthy() // BROWSER-FACING contract: the server's edge for the denied result // folded in the browser as EXACTLY ONE new turnCompletion event @@ -778,10 +687,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { // Prompt-usable again: a plain follow-up send completes normally. await sendComposerText(page, 'follow-up after deny') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'follow-up after deny', - 'follow-up send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'follow-up after deny', + 'the follow-up prompt', ) } finally { await lane.server.stop().catch(() => {}) @@ -792,9 +701,9 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('approval: survives page reload mid-pending (exactly one restored card), Allow still works', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) // RELOAD while the permission is pending. The card must come back from // the REST snapshot overlay as EXACTLY ONE entry — no WS replay exists. @@ -814,16 +723,16 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { ).toHaveCount(1) // The reload window itself must never have fabricated a decision. expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), + readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool'), ).toEqual([]) await card.getByRole('button', { name: 'Allow tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'allow' }) + expect(response.decision).toEqual({ behavior: 'allow' }) await expect(card).toBeHidden({ timeout: 15_000 }) expect(await captureDurableId(harness2, tabId2, CANONICAL_UUID_RE)).toBe(durableBefore) } finally { @@ -835,8 +744,8 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('approval cancellation: composer Stop removes the card with zero fabricated decisions', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) const paneRoot = page.locator('[data-context="fresh-agent"]').last() @@ -844,16 +753,18 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await expect(card).toBeHidden({ timeout: 15_000 }) // Give the interrupt's full round trip a deterministic landing point: - // the fixture's cancelled frame drives the snapshot refresh that clears - // the card, and the session returns to idle. + // the sidecar's cancellation event clears the card, and the session + // returns to idle. await waitForPaneStatus(lane.harness, lane.tabId, 'idle') expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), - 'a provider cancellation must never invent a user decision', - ).toEqual([]) + readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, decision: row.decision })), + 'Stop resolves the CLI callback as a provider interruption, never as a user choice', + ).toEqual([{ toolName: 'Bash', decision: { behavior: 'deny', message: 'Interrupted' } }]) expect( - readStdinFrames(lane.stdinLog).some((f) => f?.type === 'interrupt'), - 'the interrupt frame itself must reach the sidecar', + readClaudeSimLedger(lane.simLedger).some((row) => row.kind === 'interrupt'), + 'the CLI observes the actual interrupt call', ).toBe(true) } finally { await lane.server.stop().catch(() => {}) @@ -864,24 +775,23 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('questions: single-choice, multi-select, and Other answers keyed by question text', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const banner = page.getByRole('region', { name: /Question from/ }) // 1. single-choice await sendComposerText(page, 'RAISE_QUESTION') await expect(banner).toBeVisible({ timeout: 15_000 }) expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond'), - 'no question.respond may exist before an answer', + readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion'), + 'no question answer may reach canUseTool before the user answers', ).toEqual([]) await banner.getByRole('button', { name: 'A' }).click() - let respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'question.respond', - 'question.respond #1', + let answer = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion', + 'the first CLI question answer', ) - expect(respond.requestId).toBe('req-q-1') - expect(respond.answers).toEqual({ 'Pick one': 'A' }) + expect(answer.decision.updatedInput.answers).toEqual({ 'Pick one': 'A' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -893,14 +803,14 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await banner.getByRole('button', { name: 'Submit' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(2) - respond = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'question.respond') - .find((f) => f.requestId === 'req-qm-1') - expect(respond.answers).toEqual({ 'Pick any two': 'A, C' }) + answer = readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + .at(-1) + expect(answer.decision.updatedInput.answers).toEqual({ 'Pick any two': 'A, C' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -913,14 +823,14 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await banner.getByRole('button', { name: 'Submit' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(3) - respond = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'question.respond') - .find((f) => f.requestId === 'req-qo-1') - expect(respond.answers).toEqual({ 'Free text': 'Something custom' }) + answer = readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + .at(-1) + expect(answer.decision.updatedInput.answers).toEqual({ 'Free text': 'Something custom' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') } finally { @@ -930,9 +840,15 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { }) test('compact: /compact instructions land on the same session; compacting shows; context retained', async ({ page }) => { - const lane = await bootClaudeLane(page, 'freshclaude') + // Give the simulator's real local /compact operation enough wall time for + // the UI's compacting state to be observed. + // 20x keeps the 500 ms simulated /compact window observable without + // stretching the 600 ms SDK boot to 30 seconds (50x). At that scale the + // pane did not reach its initial ready state before the test's 30 second + // setup bound under cloud scheduling. + const lane = await bootClaudeLane(page, 'freshclaude', { FRESHELL_CLAUDE_SIM_TIME_SCALE: '20' }) try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) // Warm the session with one ordinary turn. @@ -944,57 +860,76 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('/compact focus the diff') await paneRoot.getByRole('textbox', { name: 'Chat message input' }).press('Enter') - // The compacting indicator is visible while the fixture parks the turn - // (the program holds completion for a pollable window). + // The real local command exposes the compacting state while it works. await expect(page.getByTestId('fresh-agent-thinking-bar')).toHaveAttribute( 'data-state', 'active', { timeout: 15_000 }, ) - // The sidecar sees the compact as a SEND with the full instructions on - // the SAME bridge session id (never a fresh session, never dropped args). - const compactSend = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && typeof f?.text === 'string' && f.text.startsWith('/compact'), - 'compact send frame', + // The CLI ledger records the exact local command text and durable id. + const compactSend = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && typeof row.text === 'string' && row.text.startsWith('/compact'), + 'compact command', ) expect(compactSend.text).toBe('/compact focus the diff') - const leaf = await paneLeaf(lane.harness, lane.tabId) - expect(compactSend.sessionId).toBe(leaf?.content?.sessionId) + expect(compactSend.sessionId).toBe(durableBefore) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') // Follow-up prompt on the same pane → retained session + turn completes. - // Gate on the frame landing in the stdin audit (never the pane status, - // which can report a stale idle before the busy flip). + // Gate on the prompt reaching the simulated CLI, never a stale idle + // status from before the send's busy flip. await sendComposerText(page, 'follow-up after compact') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'follow-up after compact', - 'follow-up send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'follow-up after compact', + 'follow-up prompt', ) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const sends = readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'send') + const prompts = readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'prompt') expect( - sends.filter((f) => f.text === '/compact focus the diff').length, - 'exactly one compact send', + prompts.filter((row) => row.text === '/compact focus the diff').length, + 'exactly one compact command', ).toBe(1) expect( - sends.filter((f) => f.text === 'follow-up after compact').length, - 'exactly one follow-up send', + prompts.filter((row) => row.text === 'follow-up after compact').length, + 'exactly one follow-up prompt', ).toBe(1) - expect(new Set(sends.map((f) => f.sessionId)).size, 'all sends ride ONE bridge session').toBe(1) + expect(new Set(prompts.map((row) => row.sessionId)), 'all commands use ONE durable Claude session').toEqual(new Set([durableBefore])) expect(await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE)).toBe(durableBefore) // The durable transcript accumulates compaction + follow-up entries. const transcriptDir = path.join(lane.info.homeDir, '.claude', 'projects') const transcriptFile = path.join(transcriptDir, projectSlugOf(lane.projectDir), `${durableBefore}.jsonl`) - const transcriptLines = readJsonl(transcriptFile) - const userTexts = transcriptLines - .filter((l) => l.type === 'user') - .map((l) => l.message?.content?.[0]?.text) - expect(userTexts).toEqual( - expect.arrayContaining(['warm-up turn', '/compact focus the diff', 'follow-up after compact']), - ) + // Native transcript writes may finish just after the result/idle frame. + // Retain the earlier conversation and follow-up, and correlate the saved + // local-command metadata with the actual command submitted to this CLI. + await expect(async () => { + const transcriptLines = readJsonl(transcriptFile) + const userRows = transcriptLines.filter((line) => line.type === 'user') + const textOf = (line: any): string => { + const content = line.message?.content + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((block: any) => typeof block?.text === 'string' ? block.text : '') + .filter(Boolean) + .join('\n') + } + return '' + } + expect(userRows.map(textOf)).toEqual( + expect.arrayContaining(['warm-up turn', 'follow-up after compact']), + ) + const savedCommands = userRows.filter((line) => line.uuid === compactSend.uuid) + expect(savedCommands, 'exactly one saved row for the submitted compact command').toHaveLength(1) + expect(textOf(savedCommands[0])).toContain('/compact') + expect(textOf(savedCommands[0])).toContain('focus the diff') + expect(transcriptLines).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'system', subtype: 'compact_boundary' }), + expect.objectContaining({ type: 'user', isCompactSummary: true }), + ])) + }).toPass({ timeout: 15_000 }) } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1004,7 +939,7 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('capability gate: /fork is offered for claude', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) // One turn so the snapshot sheet is fully hydrated. await sendComposerText(page, 'gate warm-up') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1013,7 +948,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await paneRoot.getByRole('button', { name: 'Slash commands' }).click() const menu = page.getByRole('menu', { name: 'Slash commands' }) await expect(menu).toBeVisible({ timeout: 10_000 }) - await expect(menu.getByRole('menuitem', { name: /^\/compact/ })).toBeVisible() + await expect( + menu.getByRole('group', { name: 'Pane actions' }) + .getByRole('menuitem', { name: /^\/compact Ask the agent to compact its current conversation context$/ }), + ).toBeVisible() await expect( menu.getByRole('menuitem', { name: /^\/fork/ }), 'claude capabilities.fork=true offers /fork in the menu (C14)', @@ -1025,12 +963,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { } }) - test('per-send settings reach the claude sidecar before the send (freshclaude)', async ({ page }) => { + test('per-send settings reach the CLI before the next prompt (freshclaude)', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const paneSessionId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string - expect(paneSessionId, 'the pane bridge session id must be known before the send').toBeTruthy() + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) // Change the permission mode BETWEEN sends through the pane's real // settings gear: freshclaude's registry default is 'default', so picking @@ -1043,43 +979,29 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { .toBe('acceptEdits') await sendComposerText(page, 'settings probe') - // Canonical machinery: the settings-bearing send routes through - // configure_for_send, which writes a `configure` frame and awaits the - // sidecar's ack BEFORE the user message frame — the stdin audit proves - // strict ordering (the knobs provably land before the turn starts). - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'configure' && f?.settings?.permissionMode === 'acceptEdits', - 'configure frame carrying permissionMode:acceptEdits', + // The production sidecar applies controls before it sends the queued + // prompt to the simulated CLI; the ledger records both operations. + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'acceptEdits', + 'the CLI permission-mode control', ) - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'settings probe', - 'send frame for "settings probe"', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'settings probe', + 'settings probe prompt', ) - const frames = readStdinFrames(lane.stdinLog) - const configureIdx = frames.findIndex( - (f) => f?.type === 'configure' && f?.settings?.permissionMode === 'acceptEdits', - ) - const sendIdx = frames.findIndex((f) => f?.type === 'send' && f?.text === 'settings probe') - expect(configureIdx, 'the per-send configure frame must exist in the audit').toBeGreaterThanOrEqual(0) - expect(sendIdx, 'the probe send must exist in the audit').toBeGreaterThanOrEqual(0) - expect(configureIdx, 'configure must land strictly BEFORE the send it applies to').toBeLessThan(sendIdx) - - // The fake sidecar answers with sdk.configured carrying the applied - // settings (the ack configure_for_send awaits): the wire-row proves the - // ack as well as the ordering. - const ack = await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'wire' - && e.frame?.type === 'sdk.configured' - && e.frame?.ok === true - && e.frame?.settings?.permissionMode === 'acceptEdits' - && e.frame?.sessionId === paneSessionId, - 'sdk.configured wire row acknowledging permissionMode:acceptEdits', + const records = readClaudeSimLedger(lane.simLedger) + const configureIdx = records.findIndex( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'acceptEdits', ) - expect(ack.frame.ok).toBe(true) + const sendIdx = records.findIndex((row) => row.kind === 'prompt' && row.text === 'settings probe') + expect(configureIdx, 'the CLI must receive the new permission mode').toBeGreaterThanOrEqual(0) + expect(sendIdx, 'the CLI must receive the settings probe').toBeGreaterThanOrEqual(0) + expect(configureIdx, 'the control must land strictly BEFORE the prompt it applies to').toBeLessThan(sendIdx) + expect(records[sendIdx].sessionId).toBe((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId) + expect(records[sendIdx].permissionMode).toBe('acceptEdits') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') } finally { await lane.server.stop().catch(() => {}) @@ -1128,12 +1050,12 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { 'the raise send carries the non-bypass pane permission mode', ).toBe('default') await card.getByRole('button', { name: 'Allow tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'allow' }) + expect(response.decision).toEqual({ behavior: 'allow' }) await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1144,12 +1066,12 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await banner.getByRole('button', { name: 'A' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(1) - const qRespond = readStdinFrames(lane.stdinLog).find((f) => f?.type === 'question.respond') - expect(qRespond.answers).toEqual({ 'Pick one': 'A' }) + const qAnswer = readClaudeSimLedger(lane.simLedger).find((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + expect(qAnswer.decision.updatedInput.answers).toEqual({ 'Pick one': 'A' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1168,7 +1090,7 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await card.getByRole('button', { name: 'Allow tool use' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'Bash').length, { timeout: 15_000 }, ) .toBe(2) @@ -1184,9 +1106,15 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(harness2, tabId2, 'idle') expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), - 'exactly the two human clicks ever produced responds', - ).toHaveLength(2) + readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'Bash') + .map((row) => row.decision), + 'the two clicks allow the tool; Stop resolves the parked callback as Interrupted', + ).toEqual([ + { behavior: 'allow' }, + { behavior: 'allow' }, + { behavior: 'deny', message: 'Interrupted' }, + ]) } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1196,7 +1124,7 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { test('kilroy crash mid-turn in the background: one ring, Resume (no reload), a reload and restart recover the SAME durable session', async ({ page }) => { const lane = await bootClaudeLane(page, 'kilroy', { KILROY_ENABLED: '1' }) try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) const paneRoot = page.locator('[data-context="fresh-agent"]').last() const tabLocator = page.locator(`[data-context="tab"][data-tab-id="${lane.tabId}"]`) @@ -1206,15 +1134,15 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // conversation in place. await page.evaluate(() => { (window as any).__freshellNoReloadMarker = 'kilroy-crash-lane' }) - // Crash mid-turn while the user is in another tab: the fixture - // exits(1) with no protocol frames (a Node death), 3s after the send. + // Crash mid-turn while the user is in another tab: the simulator ends + // the CLI process, so the production sidecar reports the stream exit. await sendComposerText(page, 'CRASH_NOW') await page.getByRole('button', { name: 'New shell tab' }).click() await lane.harness.waitForTabCount(2) - await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'crash', - 'fixture crash event', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'crash', + 'simulated CLI process exit', ) // The server rings the lost turn exactly once (G05: a crash is a turn // end the user did not witness) and the background tab is highlighted. @@ -1224,13 +1152,6 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { .toBe(seqBefore + 1) expect((await turnCompletion()).attentionByTab?.[lane.tabId], 'the crash highlights the tab').toBe(true) await expect(tabLocator, 'the background tab carries the attention highlight').toHaveClass(/bg-emerald-100/) - // Ground truth: the fixture fabricated no completion; the one ring is - // the server's for the dead sidecar. - expect( - readJsonl(lane.eventsLog).filter((e) => e.kind === 'completion'), - 'no completion may be fabricated for the crashed turn', - ).toEqual([]) - // Back on the tab: the session reads as ended, with Resume, and the // composer is disabled (nothing typed there could be lost). await tabLocator.click() @@ -1244,10 +1165,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // Resume reopens the SAME durable session in place, with no reload. await paneRoot.getByRole('button', { name: 'Resume session' }).click() - await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'resume' && e.data?.id === durableBefore, - 'the replacement sidecar resumed the crashed durable session', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'query' && row.options?.resume === durableBefore, + 'the replacement CLI resumed the crashed durable session', ) expect(await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE, 30_000)).toBe(durableBefore) await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) @@ -1261,10 +1182,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // landing (the sidecar saw the send and completed it), never the // client's optimistic local echo. await sendComposerText(page, 'post-crash recovery turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-crash recovery turn', - 'post-crash send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-crash recovery turn', + 'post-crash recovery prompt', 30_000, ) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1273,9 +1194,9 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // A second crash, recovered by reloading the page instead: the pane // hydrates as ended, and its attach must still resume the SAME durable // session (the server holds a dead-session record for it until then). - const crashes = () => readJsonl(lane.eventsLog).filter((e) => e.kind === 'crash').length - const resumes = () => readJsonl(lane.eventsLog) - .filter((e) => e.kind === 'resume' && e.data?.id === durableBefore).length + const crashes = () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'crash').length + const resumes = () => readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'query' && row.options?.resume === durableBefore).length const resumesBeforeReload = resumes() await sendComposerText(page, 'CRASH_NOW') await expect.poll(crashes, { timeout: 30_000, message: 'the second fixture crash' }).toBe(2) @@ -1296,10 +1217,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await waitForPaneStatus(harness2, tabId2, ['idle', 'connected']) await expect(paneRoot.getByText('This session has ended')).toHaveCount(0) await sendComposerText(page, 'post-reload recovery turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-reload recovery turn', - 'post-reload send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-reload recovery turn', + 'post-reload recovery prompt', 30_000, ) await waitForPaneStatus(harness2, tabId2, 'idle') @@ -1314,10 +1235,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { { timeout: 60_000, message: 'the restarted server resumed the same durable session' }) .toBeGreaterThanOrEqual(resumesBeforeReload + 2) await sendComposerText(page, 'post-restart turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-restart turn', - 'post-restart send frame (lands on the new sidecar process)', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-restart turn', + 'post-restart prompt on the new CLI process', 60_000, ) await waitForPaneStatus(harness2, tabId2, 'idle', 60_000) 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 83e7d6126..96b49d7a1 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 @@ -7,8 +7,7 @@ // end the user WAS watching produces the tab-strip-only watched mark, no // sound, cleared on the next away-and-back. A user-initiated interrupt is // silent, full stop, and shows no error banner. The first test drives the -// deterministic fake claude-SDK sidecar -// (`fixtures/providers/fake-claude-sdk-sidecar.mjs`) end to end — composer → +// production Claude sidecar over the CLI simulator end to end — composer → // Rust server → sidecar → unified `freshAgent.turn.complete` → client fold → // marks — through these lanes of that contract: // @@ -23,11 +22,10 @@ // 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 (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). +// 5. a mid-turn CLI exit ends the production sidecar's SDK stream, rings +// exactly one edge, resolves the pane to exited, and reports +// SIDECAR_EXITED: 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 @@ -36,8 +34,8 @@ // `[ede_diagnostic]` error, T11) rings nothing, marks nothing and shows no // "Agent error" banner, and the next turn still rings. // -// CLOUD-LEGALITY: everything is driven through the fake's deterministic -// program lanes (`FRESHELL_FAKE_PROGRAM` text-matched rules with bounded +// CLOUD-LEGALITY: everything is driven through the simulator's deterministic +// program lanes (`FRESHELL_CLAUDE_SIM_PROGRAM` text-matched rules with bounded // `delayMs` emissions) — there are NO idle-grace-period or shade-transition // timing dependencies here (the patterns that put truly-idle-alerting.spec.ts // itself in CLOUD_SKIP_SPECS). The delayed emissions hand the spec a bounded @@ -49,37 +47,26 @@ // CLOUD_SKIP_SPECS. import fs from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' import type { Page } from '@playwright/test' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') -// The production sidecar and the CLI simulator it loads through its -// FRESHELL_CLAUDE_SDK_QUERY_MODULE seam (both are in the cloud image's source -// tree; the simulator needs no CLI binary and no network). -const CLAUDE_SIDECAR = path.resolve(__dirname, '../../../crates/freshell-claude-sidecar/index.mjs') -const CLAUDE_CLI_SIMULATOR = path.resolve(__dirname, '../../fixtures/claude-cli-sim/query.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' /** - * Deterministic fake-sidecar program. The 3s emission delays are the ONLY - * timing in this spec, and they are lane-controlled (the fake consumes the + * Deterministic CLI simulator program. The 3s emission delays are the ONLY + * timing in this spec, and they are lane-controlled (the simulator consumes the * delay BEFORE the emission fires): each send opens a bounded window in * which the spec moves the user's attention, then the scripted turn ends. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { // Errored turn lane: error_max_turns is a turn end like any other. - on: 'msg:send', + on: 'msg:prompt', match: { text: 'ERROR_TURN' }, emit: [ { @@ -90,32 +77,15 @@ const FAKE_PROGRAM = { ], }, { - // 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', + // The simulated CLI exits mid-turn. The production sidecar reports its + // ended SDK stream and exits; the server rings the cut-off turn. + on: 'msg:prompt', match: { text: 'STREAM_ERROR' }, - emit: [{ kind: 'stream-error', delayMs: 3_000 }], + emit: [{ kind: 'crash', delayMs: 3_000 }], }, ], } -/** Read a JSONL fixture log (the fake's event/wire audit). */ -function readJsonl(filePath: string): any[] { - if (!existsSync(filePath)) return [] - return readFileSync(filePath, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter((row) => row !== null) -} - function findFreshAgentLeaf(node: any): any { if (!node) return null if (node.type === 'leaf' && node.content?.kind === 'fresh-agent') return node @@ -128,8 +98,30 @@ function findFreshAgentLeaf(node: any): any { return null } +/** Select a real boot terminal before opening a provider pane picker. The + * boot picker fades out after the choice, so opening another picker before + * the terminal surface appears can route the click to the fading picker. */ +async function settleBootPicker(page: Page): Promise { + const picker = page.getByRole('toolbar', { name: /pane type picker/i }).last() + if (await picker.isVisible().catch(() => false)) { + for (const name of ['Shell', 'WSL', 'CMD', 'PowerShell', 'Bash']) { + const option = picker.getByRole('button', { name: new RegExp(`^${name}$`, 'i') }) + if (await option.isVisible().catch(() => false)) { + await option.click({ force: true }) + break + } + } + } + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + // The terminal becomes visible before the picker exit transition ends. + // Wait for that old toolbar to leave before openPanePicker looks for an + // existing picker; otherwise a forced Freshclaude click can land on the + // fading boot picker and the directory form never creates a pane. + await expect(picker).toBeHidden({ timeout: 10_000 }) +} + /** - * Boot a server whose claude sidecar is the PRODUCTION sidecar over the CLI + * Boot a server whose Claude sidecar is the PRODUCTION sidecar over the CLI * simulator (with its ledger, and `extraEnv` such as a simulator program), and * open a freshclaude or kilroy pane on its boot tab. */ @@ -152,10 +144,7 @@ async function bootProductionClaudePane( await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_CLAUDE_SDK_QUERY_MODULE: CLAUDE_CLI_SIMULATOR, - FRESHELL_CLAUDE_SIM_LEDGER: simLedger, + ...claudeSimulatorServerEnv({ ledgerPath: simLedger }), // Kilroy is behind its feature flag. ...(flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), ...extraEnv, @@ -178,6 +167,7 @@ async function bootProductionClaudePane( await harness.waitForHarness() await harness.waitForConnection() + await settleBootPicker(page) await page.evaluate(() => { window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', @@ -214,18 +204,14 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) test('errored turns ring the unified signal; watched endings mark the tab strip only', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-error-turn-attention-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined try { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), }, setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') @@ -245,9 +231,9 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) await harness.waitForHarness() await harness.waitForConnection() - // The boot tab shows the pane-type picker (no shell is selected first, - // so the freshclaude pane REPLACES the picker — no split sibling): make - // the fresh-agent options visible, then pick Freshclaude. + // Settle the initial picker and its fade-out before opening the + // fresh-agent picker. The boot shell remains a real sibling pane. + await settleBootPicker(page) await page.evaluate(() => { window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', @@ -309,7 +295,7 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) expect((await turnCompletionState()).seq ?? 0).toBe(0) // ── Step 1: an ERRORED turn ending in a BACKGROUND tab ────────────── - // Send, then move to a fresh shell tab inside the fake's 3s emission + // Send, then move to a fresh shell tab inside the simulator's 3s emission // window — the user is in another tab when the max-turns error lands. await composer.fill('ERROR_TURN') await sendButton.click() @@ -420,7 +406,7 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) // Restore the REAL focus signal before the away-and-back dismissal // (the click-mode dismiss requires the window to be focused), and - // clear the step-4 attention so the stream-error lane below starts + // clear the step-4 attention so the CLI-exit lane below starts // from a clean slate. await page.evaluate(() => { const original = (window as any).__FRESHELL_ORIGINAL_HAS_FOCUS__ @@ -438,10 +424,10 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) .toBeNull() await paneStatus().toBe('idle') - // ── 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: - // sdk.stream.ended as its last line and exit code 3. The server reports - // the exit and rings the cut-off turn, once (C18). + // ── Step 5: the CLI stream ends mid-turn — the sidecar exits ─────── + // The simulated CLI exits while its turn is open. The production + // sidecar reports the ended stream and exits; the server reports the + // sidecar 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() @@ -469,33 +455,10 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) }, { timeout: 30_000, message: 'the pane learns the sidecar exited (SIDECAR_EXITED)' }) .toBe('SIDECAR_EXITED') - // The sidecar process is GONE: the wire rows carry its pid, the last - // one it wrote is the end report, and signal 0 finds no process. - const streamWires = readJsonl(path.join(sharedRoot, 'sidecar-events.jsonl')) - .filter((r) => r.kind === 'wire' && r.frame?.sessionId === bridgeSessionId) - expect(streamWires.length, 'the session produced wire rows to read the sidecar pid from').toBeGreaterThan(0) - const sidecarPid = streamWires[0].pid as number - expect(streamWires.at(-1)?.frame, 'the sidecar wrote sdk.stream.ended last').toMatchObject({ type: 'sdk.stream.ended', reason: 'stream_error' }) - await expect - .poll(() => { - try { - process.kill(sidecarPid, 0) - return 'alive' - } catch { - return 'gone' - } - }, { timeout: 30_000, message: 'the sidecar process exited after its stream ended' }) - .toBe('gone') - // 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.result' || r.frame?.type === 'sdk.turn.complete'), - 'the stream ended with no result, and the sidecar minted no edge', - ).toEqual([]) + // The simulator proves the CLI died during this prompt; the client + // observes the resulting sidecar exit, and the server supplies the one + // attention edge because no result arrived for the interrupted turn. + expect(readClaudeSimLedger(simLedger)).toContainEqual(expect.objectContaining({ kind: 'crash' })) // One edge total for the lane — stable across a settle window (the // cut-off turn rings exactly once). @@ -527,7 +490,7 @@ test.describe('fresh-agent user Stop on the production claude sidecar', () => { }, { timeout: 30_000 }) const turnCompletionState = () => harness.getState().then((s: any) => s?.turnCompletion ?? {}) /** Prompts the simulated CLI has started, in order (its ledger's `prompt` rows). */ - const startedPrompts = () => readJsonl(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')) + const startedPrompts = () => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')) // The CLI reports no session until a prompt (T1), so a new pane is // `connected` until its first turn. @@ -693,7 +656,7 @@ test.describe('fresh-agent crash on the production claude sidecar', () => { // The same durable conversation boots again (a resume, not a new session)... await expect - .poll(() => readJsonl(simLedger).filter((row) => row.kind === 'query' && row.options?.resume === durable).length, + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'query' && row.options?.resume === durable).length, { timeout: 30_000, message: 'Resume boots the CLI with resume: ' }) .toBe(1) await paneStatus().toMatch(/^(connected|idle)$/) @@ -709,7 +672,7 @@ test.describe('fresh-agent crash on the production claude sidecar', () => { await composer.fill('after the resume') await sendButton.click() await expect - .poll(() => readJsonl(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')), + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')), { timeout: 30_000, message: 'the resumed CLI ran the next prompt' }) .toContain('after the resume') await expect 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 b68539ecc..9f9b5479a 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 @@ -10,7 +10,7 @@ * and then fail. G26 keeps this refresh non-blocking (a hidden pane owes one * 'reveal-surface' refresh instead of marking its transcript dirty). * - * 1. an approval raised while tab A is hidden shows its card after the + * 1. an approval delivered while tab A is hidden shows its card after the * reveal, and the overlay is never drawn; * 2. the same approval cancelled while tab A is hidden is gone after the * reveal, and the overlay is never drawn. @@ -24,9 +24,12 @@ * 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 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. + * Trigger: a visible composer starts the turn. The production sidecar streams + * the Bash tool-use transcript while tab A is visible; the test holds only + * the resulting canUseTool approval frame at the WebSocket boundary, switches + * tabs, then delivers that frame while A is hidden. The send and tool-use + * transcript must not be hidden: their transcript changes legitimately test + * the blocking transcript-refresh path instead of the approval-only reveal. * 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 @@ -39,24 +42,18 @@ * 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 + * state (simulator ledger, received WS frames, DOM). This spec must * NOT be added to CLOUD_SKIP_SPECS. */ import fs from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' import type { Page } from '@playwright/test' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const APPROVAL_REQUEST_ID = 'perm-raised-while-hidden' @@ -64,16 +61,17 @@ const APPROVAL_REQUEST_ID = 'perm-raised-while-hidden' const RAISE_TEXT = 'raise an approval while hidden' /** The send raises one Bash approval; the Stop cancels it. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: RAISE_TEXT }, once: true, emit: [ { kind: 'approval', data: { id: APPROVAL_REQUEST_ID, tool: 'Bash', input: { command: 'echo raised-while-hidden' } }, + delayMs: 3_000, }, ], }, @@ -97,46 +95,25 @@ function findFreshAgentLeaf(node: any): any { return null } -function readJsonl(file: string): any[] { - if (!existsSync(file)) return [] - return readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) -} - -/** Every outbound sidecar frame of `type` (the fixture's wire audit rows). */ -function wireFrames(eventsLog: string, type: string): any[] { - return readJsonl(eventsLog) - .filter((row) => row.kind === 'wire' && row.frame?.type === type) - .map((row) => row.frame) -} - -/** Every raw stdin frame of `type` the server wrote to the sidecar. */ -function stdinFrames(stdinLog: string, type: string): any[] { - return readJsonl(stdinLog) - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((frame) => frame?.type === type) -} - async function receivedFreshAgentEvents(harness: TestHarness, eventType: string): Promise { const received = (await harness.getReceivedWsMessages()) as any[] return received.filter((msg) => msg?.type === 'freshAgent.event' && msg?.event?.type === eventType) } +/** POST /api/tabs returns an unfocused tab, leaving the current pane visible. */ +async function createBackgroundShellTab(info: { baseUrl: string; token: string }, cwd: string): Promise { + const res = await fetch(`${info.baseUrl}/api/tabs`, { + method: 'POST', + headers: { 'x-auth-token': info.token, 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'shell', cwd }), + }) + const payload = await res.json() + expect(res.ok, `POST /api/tabs: ${JSON.stringify(payload)}`).toBe(true) + const tabId = payload?.data?.tabId + expect(tabId, 'POST /api/tabs envelope data.tabId').toBeTruthy() + return tabId as string +} + /** Record whether the blocking reveal overlay is EVER drawn from now on. */ async function armRevealOverlayRecorder(page: Page): Promise { await page.evaluate((selector) => { @@ -170,13 +147,19 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without test('an approval raised, then cancelled, while the pane is hidden is current on reveal, with no blocking overlay', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-hidden-approval-')) - const stdinLog = path.join(sharedRoot, 'sidecar-stdin.jsonl') - const eventsLog = path.join(sharedRoot, 'sidecar-events.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined let pauseAfterCancellation = false let deliveryPaused = false let heldResults = 0 + let heldPermissionRequest = false + let releasePermissionRequest: (() => void) | null = null const heldDeliveries: Array<() => void> = [] + const releaseApprovalRequest = () => { + const deliver = releasePermissionRequest + releasePermissionRequest = null + deliver?.() + } const releaseDeliveries = () => { deliveryPaused = false for (const deliver of heldDeliveries.splice(0)) deliver() @@ -192,6 +175,18 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without heldDeliveries.push(() => route.send(message)) return } + // Deliver the streamed tool-use transcript while the pane is + // visible, then hold only the approval surface event until its tab + // is hidden. This isolates the non-blocking approval reveal from + // the separate transcript-dirty reveal path. + if (!heldPermissionRequest + && frame?.type === 'freshAgent.event' + && frame.event?.type === 'freshAgent.permission.request' + && frame.provider === 'claude') { + heldPermissionRequest = true + releasePermissionRequest = () => route.send(message) + return + } route.send(message) if (pauseAfterCancellation && frame?.type === 'freshAgent.event' && frame.event?.type === 'freshAgent.permission.cancelled') { @@ -203,14 +198,8 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: stdinLog, - FRESHELL_FAKE_EVENTS: eventsLog, - }, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') await fs.mkdir(freshellDir, { recursive: true }) @@ -255,6 +244,20 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const bridgeSessionId: string = (await leafA()).content.sessionId expect(bridgeSessionId, 'the pane is bound to its sidecar session').toBeTruthy() + // The provider's saved default is bypassPermissions in a fresh test + // HOME. This scenario needs a real canUseTool approval, so opt into the + // asking mode through the same settings control a user would use. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect.poll(async () => { + const content = findFreshAgentLeaf(await harness.getPaneLayout(tabA))?.content + return content?.permissionMode ?? null + }).toBe('default') + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + )).toBe(true) + const paneA = page.locator('[data-context="fresh-agent"]').first() await expect(paneA).toBeVisible({ timeout: 15_000 }) // The composer is up: the pane's first snapshot has rendered. @@ -265,24 +268,15 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const visibleCard = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(cardInAnyTab, 'no approval before the trigger').toHaveCount(0) - // ── Tab B: the user moves away; tab A (and its pane) is now hidden ──── - await page.getByRole('button', { name: 'New shell tab' }).click() + // Create B without changing focus, then start the turn while A is + // visible. This keeps send acceptance out of the hidden-pane path. + const tabB = await createBackgroundShellTab(info, os.tmpdir()) await harness.waitForTabCount(2) - const tabB = (await harness.getActiveTabId())! expect(tabB).not.toBe(tabA) + expect(await harness.getActiveTabId()).toBe(tabA) 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({ @@ -293,20 +287,86 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without }) }, bridgeSessionId) - // ── 1. An approval RAISED while hidden appears on reveal ───────────── - await sendToHiddenPane() - await expect - .poll(() => wireFrames(eventsLog, 'sdk.permission.request').length, { - timeout: 15_000, - message: 'the sidecar raises the approval', - }) - .toBe(1) + // ── 1. An approval delivered while hidden appears on reveal ───────── + const composer = paneA.getByRole('textbox', { name: 'Chat message input' }) + await composer.fill(RAISE_TEXT) + await paneA.getByRole('button', { name: 'Send' }).click() + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === RAISE_TEXT, + ), { timeout: 15_000, message: 'the visible pane starts the approval-triggering prompt' }).toBe(true) + let sendRequestId = '' + await expect.poll(async () => { + const sends = (await harness.getSentWsMessages()) + .filter((message: any) => message?.type === 'freshAgent.send' && message?.text === RAISE_TEXT) + sendRequestId = sends.at(-1)?.requestId ?? '' + return sendRequestId + }, { timeout: 10_000, message: 'the visible turn has a request id before the tab switch' }).toBeTruthy() + await expect.poll(async () => (await harness.getReceivedWsMessages()).some( + (message: any) => message?.type === 'freshAgent.send.accepted' && message?.requestId === sendRequestId, + ), { timeout: 10_000, message: 'the server accepted the send while tab A is still visible' }).toBe(true) + await expect.poll(async () => (await leafA())?.content?.status, { + timeout: 10_000, + message: 'the visible pane observed the turn start before it is hidden', + }).toBe('running') + // The tool-use transcript and assistant line arrive while A remains + // visible. The matching canUseTool request is held at the WS boundary. + await expect.poll(async () => { + const received = ((await harness.getReceivedWsMessages()) as any[]).filter((message) => ( + message?.type === 'freshAgent.event' && message.sessionId === bridgeSessionId + )) + const assistant = received.find((message) => message.event?.type === 'freshAgent.assistant' + && message.event?.content?.some((block: any) => block?.type === 'tool_use' && block?.name === 'Bash')) + return typeof assistant?.event?.uuid === 'string' + && received.some((message) => message.event?.type === 'freshAgent.session.changed' + && message.event?.reason === 'transcript' + && message.event?.uuid === assistant.event.uuid) + }, { + timeout: 15_000, + message: 'the prompt and Bash tool-use transcript land while the pane is visible', + }).toBe(true) await expect - .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.request')).length, { + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval').length, { timeout: 15_000, - message: 'the client receives the approval while tab A is hidden', + message: 'the simulated CLI invokes the production canUseTool approval path', }) .toBe(1) + await expect.poll(() => releasePermissionRequest !== null, { + timeout: 15_000, + message: 'the pending approval event is held at the WS boundary', + }).toBe(true) + expect(await harness.getActiveTabId(), 'the transcript changed while tab A was visible').toBe(tabA) + // The approval event arrives after A is hidden; no transcript-changing + // event is introduced in the hidden interval before this surface update. + await tabBLocator.click() + await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) + releaseApprovalRequest() + try { + await expect + .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.request')).length, { + timeout: 15_000, + message: 'the client receives the approval while tab A is hidden', + }) + .toBe(1) + } catch (error) { + const rows = readClaudeSimLedger(simLedger) + const received = (await harness.getReceivedWsMessages()) as any[] + const diagnostic = { + severity: 'error', + event: 'e2e.claude_hidden_approval_diagnostic', + queryPermissionModes: rows.filter((row) => row.kind === 'query') + .map((row) => row.options?.permissionMode ?? null), + promptPermissionModes: rows.filter((row) => row.kind === 'prompt') + .map((row) => row.permissionMode ?? null), + approvalEmissionCount: rows.filter((row) => row.kind === 'approval').length, + canUseToolOutcomes: rows.filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, behavior: row.decision?.behavior, rejected: Boolean(row.error) })), + receivedFreshAgentEvents: received + .filter((message) => message?.type === 'freshAgent.event') + .map((message) => ({ type: message.event?.type, code: message.event?.code })) + .slice(-30), + } + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostic)}`) + } expect(await harness.getActiveTabId(), 'the approval arrived while tab A was hidden').toBe(tabB) await armRevealOverlayRecorder(page) @@ -322,17 +382,11 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without pauseAfterCancellation = true await interruptHiddenPane() await expect - .poll(() => stdinFrames(stdinLog, 'interrupt').length, { + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'interrupt').length, { timeout: 15_000, message: 'the server forwards the Stop of the parked turn', }) .toBe(1) - await expect - .poll(() => wireFrames(eventsLog, 'sdk.permission.cancelled').map((frame) => frame.requestId), { - timeout: 15_000, - message: 'the sidecar cancels the pending approval', - }) - .toEqual([APPROVAL_REQUEST_ID]) await expect .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.cancelled')).length, { timeout: 15_000, @@ -358,6 +412,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without message: 'the stopped turn result is delivered after the approval-only reveal', }).toBe(1) } finally { + try { releaseApprovalRequest() } catch { /* The page can already be closed after a failed test. */ } 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 6f04fc705..d1528462b 100644 --- a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts @@ -15,17 +15,18 @@ * `waiting` edge) produces the same mark. * * The pane is a real Freshclaude pane on the real Rust server, driven by the - * deterministic fake Claude-SDK sidecar. Every mark comes from a REAL turn: - * the composer sends the prompt, the Rust server forwards it to the sidecar, - * the sidecar's program lane ends the turn (or raises the approval), the + * production Claude sidecar over the CLI simulator. Every mark comes from a + * real simulated CLI turn: the composer sends the prompt, the Rust server + * forwards it to the sidecar, the simulator ends the turn (or asks for an + * approval through canUseTool), the * server emits the `freshAgent.turn.complete` / `freshAgent.turn.waiting` * edge, and the client's production fold puts the mark on the mobile strip * and switcher. So this spec covers "server edge → mobile mark" end to end; * nothing here injects a WS frame. Each step also checks the edge on the - * sidecar wire and on the client's received WS frames, so a failure says + * simulator ledger and on the client's received WS frames, so a failure says * which hop broke. * - * CLOUD-LEGALITY: the only timing is the fake's lane-controlled `delayMs` + * CLOUD-LEGALITY: the only timing is the simulator's lane-controlled `delayMs` * before the scripted emission (the same pattern as * fresh-agent-error-turn-attention.spec.ts): it gives the spec a bounded * window to move to the other tab BEFORE the turn ends. If that move were @@ -35,38 +36,30 @@ * CLOUD_SKIP_SPECS. */ import fs from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const BACKGROUND_TURN = 'BACKGROUND_TURN' const BACKGROUND_APPROVAL = 'BACKGROUND_APPROVAL' -const APPROVAL_REQUEST_ID = 'perm-mobile-background' - /** - * Deterministic fake-sidecar program. Each scripted emission waits 3 s after - * the sidecar receives the send, so the user is in the other tab when the + * Deterministic CLI simulator program. Each scripted emission waits 3 s after + * the CLI receives the prompt, so the user is in the other tab when the * turn ends or the approval is raised. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: BACKGROUND_TURN }, emit: [ { kind: 'completion', - data: { subtype: 'success', text: 'Fixture turn finished while the user was away.' }, + data: { subtype: 'success', text: 'The simulated turn finished while the user was away.' }, delayMs: 3_000, }, ], @@ -74,12 +67,12 @@ const FAKE_PROGRAM = { { // The approval parks the turn (no completion follows) and the server // mints the waiting edge on the 0→1 pending transition. - on: 'msg:send', + on: 'msg:prompt', match: { text: BACKGROUND_APPROVAL }, emit: [ { kind: 'approval', - data: { id: APPROVAL_REQUEST_ID, tool: 'Bash', input: { command: 'echo mobile-background' } }, + data: { id: 'toolu-mobile-background', tool: 'Bash', input: { command: 'echo mobile-background' } }, delayMs: 3_000, }, ], @@ -99,28 +92,6 @@ function findFreshAgentLeaf(node: any): any { return null } -function readJsonl(file: string): any[] { - if (!existsSync(file)) return [] - return readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) -} - -/** Every outbound sidecar frame of `type` (the fixture's wire audit rows). */ -function wireFrames(eventsLog: string, type: string): any[] { - return readJsonl(eventsLog) - .filter((row) => row.kind === 'wire' && row.frame?.type === type) - .map((row) => row.frame) -} - async function receivedFreshAgentEvents(harness: TestHarness, eventType: string): Promise { const received = (await harness.getReceivedWsMessages()) as any[] return received.filter((msg) => msg?.type === 'freshAgent.event' && msg?.event?.type === eventType) @@ -132,20 +103,13 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { test('background turn endings and approvals mark the mobile strip and switcher; a tap dismisses', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-mobile-attention-')) - const eventsLog = path.join(sharedRoot, 'sidecar-events.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined try { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: eventsLog, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') await fs.mkdir(freshellDir, { recursive: true }) @@ -199,7 +163,7 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { timeout: 30_000, }) .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) - await paneStatus().toBe('idle') + await paneStatus().toMatch(/^(connected|idle)$/) const turnCompletionState = () => harness.getState().then((s: any) => s?.turnCompletion ?? {}) expect((await turnCompletionState()).seq ?? 0).toBe(0) @@ -211,7 +175,7 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { const previousTab = page.getByRole('button', { name: 'Previous tab' }) // ── 1. A turn ends in a BACKGROUND tab ───────────────────────────────── - // Send, then (inside the fake's 3 s window) open a new tab with the + // Send, then (inside the simulator's 3 s window) open a new tab with the // strip's right action — tab A is the last tab, so it reads "New tab". await composer.fill(BACKGROUND_TURN) await sendButton.click() @@ -232,11 +196,10 @@ 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'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( + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'completion' && row.data?.subtype === 'success'), + 'the simulated CLI completed the first turn successfully', + ).toHaveLength(1) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.complete'), 'the server sent exactly one turn-complete edge to the client', @@ -269,6 +232,21 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { // ── 3. An approval wait raised while the user is in another tab ─────── // Send from tab A, then move to tab B inside the 3 s window. Tab A is // first of two now: the strip's right action is "Next tab". + // The server's default Claude permission mode bypasses tool approval. + // Set the user-facing asking mode explicitly so this prompt reaches the + // real canUseTool approval path that the attention assertion covers. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect + .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabA))?.content?.permissionMode ?? null) + .toBe('default') + await expect + .poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + )) + .toBe(true) + await composer.fill(BACKGROUND_APPROVAL) await sendButton.click() await page.getByRole('button', { name: 'Next tab' }).click() @@ -287,16 +265,19 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { 'the approval was raised while the user was in tab B', ).toBeUndefined() expect( - 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 mints no waiting edge').toHaveLength(0) + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval'), + 'the simulated CLI made one real approval request', + ).toHaveLength(1) + expect( + await receivedFreshAgentEvents(harness, 'freshAgent.permission.request'), + 'the production sidecar forwarded the approval request to the client', + ).toHaveLength(1) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.waiting'), 'the server sent exactly one waiting edge to the client', ).toHaveLength(1) - // The approval parks the turn: no second result, no second turn-complete edge. - expect(wireFrames(eventsLog, 'sdk.result')).toHaveLength(1) + // The approval parks the turn: no second result or turn-complete edge. + expect(await receivedFreshAgentEvents(harness, 'freshAgent.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() diff --git a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts new file mode 100644 index 000000000..6ce6320f7 --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts @@ -0,0 +1,123 @@ +import type { Browser } from '@playwright/test' +import { test, expect } from '../helpers/fixtures.js' +import { ClaudeSimLane, onPrompt } from '../helpers/claude-sim-lane.js' +import { ensureMcpServerBuilt, McpStdioClient, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const PROMPT = 'hello from the external MCP client' +const REPLY = 'REST-created Freshclaude reply' +const UI_PROMPT = 'hello to the browser-created pane' +const UI_REPLY = 'Browser-created pane received the MCP prompt' + +async function withLane(browser: Browser, body: (lane: ClaudeSimLane, mcp: McpStdioClient) => Promise) { + const mcpPath = ensureMcpServerBuilt(REPO_ROOT).path + const lane = await ClaudeSimLane.start(browser, { + flavour: 'freshclaude', env: { KILROY_ENABLED: '1' }, + program: { rules: [ + onPrompt(PROMPT, [{ kind: 'text', delayMs: 300, data: { text: REPLY } }]), + onPrompt(UI_PROMPT, [{ kind: 'text', data: { text: UI_REPLY } }]), + onPrompt('kilroy hello', [{ kind: 'text', data: { text: 'Kilroy REST reply' } }]), + ] }, + }) + const mcp = new McpStdioClient({ + command: process.execPath, + args: [mcpPath], + env: { ...process.env, FRESHELL_URL: lane.info.baseUrl, FRESHELL_TOKEN: lane.info.token }, + requestTimeoutMs: 60_000, + }) + try { + await mcp.initialize() + await body(lane, mcp) + } finally { + await lane.attachLogsOnFailure(test.info()) + await mcp.close() + await lane.stop() + } +} + +function leaf(node: any, id: string): any { + if (node?.type === 'leaf') return node.id === id ? node : undefined + return (node?.children ?? []).map((child: any) => leaf(child, id)).find(Boolean) +} + +test.describe('external REST/MCP controls the shared Claude runtime', () => { + test.setTimeout(180_000) + + test('MCP creates and prompts a background Freshclaude tab once, captures it, splits Kilroy, and captures after restart', async ({ browser }) => { + await withLane(browser, async (lane, mcp) => { + const { page, harness } = lane + const originalTab = await harness.getActiveTabId() + // Observe the production notification's audio boundary. End the test + // device's sound immediately so another ring cannot hide in its queue. + await page.evaluate(() => { + const browserWindow = window as typeof window & { __restNotificationPlays?: number } + browserWindow.__restNotificationPlays = 0 + const play = HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = function () { + if (this.src.endsWith('/your-code-is-ready.mp3')) { + browserWindow.__restNotificationPlays! += 1 + queueMicrotask(() => this.dispatchEvent(new Event('ended'))) + return Promise.resolve() + } + return play.call(this) + } + }) + const created = await mcp.callFreshellAction('new-tab', { agent: 'claude', cwd: lane.workspace, prompt: PROMPT }) + expect(created.status).toBe('ok') + const { tabId, paneId, sessionId } = created.data + await expect.poll(() => harness.getPaneLayout(tabId), { timeout: 20_000 }).not.toBeNull() + expect(await harness.getActiveTabId()).toBe(originalTab) + await expect.poll(async () => (await harness.getState()).turnCompletion?.attentionByTab?.[tabId] ?? false, { timeout: 20_000 }).toBe(true) + await expect.poll(() => page.evaluate(() => (window as typeof window & { __restNotificationPlays?: number }).__restNotificationPlays ?? 0), { timeout: 10_000 }).toBe(1) + expect(lane.ledger().filter((row) => row.kind === 'boot' && row.sessionId === sessionId)).toHaveLength(1) + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: paneId }), { + timeout: 20_000, message: 'the CLI flushed the settled turn to saved history', + }).toContain(REPLY) + const captured = await mcp.callFreshellAction('capture-pane', { target: paneId }) + expect(captured).toContain(`user: ${PROMPT}`) + expect(captured).toContain(`assistant: ${REPLY}`) + await mcp.callFreshellAction('select-tab', { target: tabId }) + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`)).toBeVisible() + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`)).toContainText(REPLY) + expect(await page.evaluate(() => (window as typeof window & { __restNotificationPlays?: number }).__restNotificationPlays)).toBe(1) + + const split = await mcp.callFreshellAction('split-pane', { target: paneId, agent: 'kilroy', cwd: lane.workspace, direction: 'vertical' }) + expect(split.status).toBe('ok') + const splitId = split.data.paneId + await expect.poll(async () => leaf(await harness.getPaneLayout(tabId), splitId)?.content?.sessionType, { timeout: 20_000 }).toBe('kilroy') + const kilroySend = await mcp.callFreshellAction('send-keys', { target: splitId, keys: 'kilroy hello\r', literal: true }) + expect(kilroySend.status, JSON.stringify(kilroySend)).toBe('ok') + expect(kilroySend.data.status, JSON.stringify(kilroySend)).toBe('idle') + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: splitId }), { + timeout: 20_000, message: 'the externally prompted Kilroy turn reached saved history', + }).toContain('Kilroy REST reply') + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toBeVisible() + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toContainText('Kilroy REST reply') + + // With no browser to attach or repair a REST pane row, the persisted + // layout and durable transcript must still serve the external client. + await lane.context.close() + await lane.server.restart() + const restored = await mcp.callFreshellAction('capture-pane', { target: paneId }) + expect(restored).toBe(captured) + }) + }) + + test('MCP send and capture work on a pane created through the picker', async ({ browser }) => { + await withLane(browser, async (lane, mcp) => { + const pane = await lane.openPane() + await expect.poll(async () => { + const response = await fetch(`${lane.info.baseUrl}/api/panes`, { headers: { 'x-auth-token': lane.info.token } }) + const body = await response.json() + return JSON.stringify(body).includes(pane.paneId) + }, { timeout: 15_000, message: 'the browser layout reached the shared server layout' }).toBe(true) + const result = await mcp.callFreshellAction('send-keys', { target: pane.paneId, keys: `${UI_PROMPT}\r`, literal: true }) + expect(result.status).toBe('ok') + expect(result.data.status).toBe('idle') + await expect(pane.paneRoot).toContainText(UI_REPLY) + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: pane.paneId }), { + timeout: 20_000, message: 'capture reads the browser-created pane’s saved reply', + }).toContain(UI_REPLY) + expect(lane.ledger().filter((row) => row.kind === 'boot')).toHaveLength(1) + }) + }) +}) diff --git a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts index 39f187d4f..73f639efc 100644 --- a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts @@ -10,15 +10,15 @@ * (decision D2, matching `claude --resume`). * * The spec drives the real Rust server + real browser UI against the - * deterministic fake Claude-SDK sidecar and asserts the EXACT `create` frame - * the server wrote to the sidecar's stdin (FRESHELL_FAKE_STDIN raw audit). + * production Claude sidecar over the CLI simulator and asserts the query + * options it received for fresh and resumed conversations. * The saved default is written through the real `PATCH /api/settings` route * and observed in the client's settings before any pane is created. * * Pre-fix, both create frames carried the registry default `opus[1m]`. * * CLOUD-LEGALITY: no idle-grace or timing dependencies — every wait is a poll - * on observed state (stdin audit rows, pane layout, sidebar rows). + * on observed state (simulator ledger rows, pane layout, sidebar rows). * * Helpers are copied, not imported, per this suite's per-spec-ownership * convention (donor: fresh-agent-control-rust.spec.ts — boot, stdin audit, @@ -29,18 +29,13 @@ import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { fileURLToPath } from 'node:url' import type { Page } from '@playwright/test' import { test, expect } from '../helpers/fixtures.js' import { RustServer, GEMINI_STRIP_ENV_PREFIXES } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -76,22 +71,9 @@ function readJsonl(filePath: string): any[] { .filter((row) => row !== null) } -/** Read the sidecar raw-stdin audit, JSON-parsing each recorded frame. */ -function readStdinFrames(stdinLog: string): any[] { - return readJsonl(stdinLog) - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((frame) => frame !== null) -} - -async function waitForStdinFrame( - stdinLog: string, - pred: (frame: any) => boolean, +async function waitForSimQuery( + simLedger: string, + pred: (options: any) => boolean, what: string, timeoutMs = 20_000, ): Promise { @@ -99,10 +81,10 @@ async function waitForStdinFrame( await expect .poll( () => { - found = readStdinFrames(stdinLog).find(pred) ?? null + found = readClaudeSimLedger(simLedger).find((row) => row.kind === 'query' && pred(row.options ?? {})) ?? null return Boolean(found) }, - { timeout: timeoutMs, message: `timed out waiting for ${what} in ${stdinLog}` }, + { timeout: timeoutMs, message: `timed out waiting for ${what} in ${simLedger}` }, ) .toBe(true) return found @@ -171,19 +153,15 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ harness: TestHarness sharedRoot: string projectDir: string - stdinLog: string + simLedger: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), `freshell-default-model-${flavour}-`)) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const stdinLog = path.join(sharedRoot, 'sidecar-stdin.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const server = new RustServer({ env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: flavour, - FRESHELL_FAKE_STDIN: stdinLog, - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ ledgerPath: simLedger, flavour }), ...(flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), }, stripEnvPrefixes: flavour === 'kilroy' ? [...GEMINI_STRIP_ENV_PREFIXES] : undefined, @@ -216,7 +194,7 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ payload: { claude: true }, }) }) - return { server, info, harness, sharedRoot, projectDir, stdinLog } + return { server, info, harness, sharedRoot, projectDir, simLedger } } catch (error) { await server.stop().catch(() => {}) await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -226,7 +204,7 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ /** Append one user/assistant exchange to the session's CLI transcript * (`~/.claude/projects//.jsonl`, parentUuid-chained like - * the CLI and the fake sidecar write it). */ + * the CLI and simulator write it). */ async function appendTranscriptExchange( homeDir: string, cwd: string, @@ -258,7 +236,7 @@ const LABELS: Record = { freshclaude: 'Freshclaude', kilroy: 'K async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise { const lane = await bootLane(page, flavour) try { - const { harness, info, stdinLog } = lane + const { harness, info, simLedger } = lane const label = LABELS[flavour] const tabId = (await harness.getActiveTabId())! expect(tabId).toBeTruthy() @@ -275,12 +253,12 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && frame?.boot?.kind === 'fresh', - `the new ${label} pane's sidecar create`, + const freshCreate = await waitForSimQuery( + simLedger, + (options) => !options.resume && options.model === 'sonnet', + `the new ${label} pane's simulator query`, ) - expect(freshCreate.model, 'a new pane starts on the saved default model').toBe('sonnet') + expect(freshCreate.options.model, 'a new pane starts on the saved default model').toBe('sonnet') let durableId = '' await expect @@ -296,8 +274,8 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && frame?.boot?.kind === 'resume' && frame?.boot?.resume === durableId, - `the resumed ${label} pane's sidecar create`, + const resumeCreate = await waitForSimQuery( + simLedger, + (options) => options.resume === durableId && options.model === 'haiku', + `the resumed ${label} pane's simulator query`, 30_000, ) - expect(resumeCreate.model, 'a resumed pane starts on the current saved default model').toBe('haiku') + expect(resumeCreate.options.model, 'a resumed pane starts on the current saved default model').toBe('haiku') const resumedLeaf = findFreshAgentLeaf(await harness.getPaneLayout((await harness.getActiveTabId())!)) expect(resumedLeaf?.content?.sessionType).toBe(flavour) diff --git a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts index cb0634de1..1328a3d3c 100644 --- a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts @@ -8,7 +8,7 @@ // idempotent configure stays silent on the bus. // 2. claude: `freshAgent.configure` applies for real through the sidecar's // configure lane (the fake's sdk.configured receipt) and broadcasts the -// metadata frame; the sidecar request log records the configure. +// metadata frame; the simulator ledger records the SDK settings call. // // The codex lane's wire contract is pinned in-crate // (crates/freshell-freshagent/src/codex.rs configure tests against the real @@ -32,11 +32,11 @@ import WebSocket from 'ws' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.js' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const CLAUDE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') /** Raw node-side WS client with a real hello handshake and a frame buffer @@ -273,14 +273,11 @@ test.describe('fresh-agent live model convergence (rust)', () => { test('claude: configure applies through the sidecar lane and broadcasts session metadata', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-conv-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) let ws: WsCapture | null = null @@ -305,8 +302,8 @@ test.describe('fresh-agent live model convergence (rust)', () => { ) const sessionId = created.sessionId as string - // The live-settings convergence lane: the sidecar applies the configure - // for real (setModel + applyFlagSettings through the fake's receipt)… + // The live-settings convergence lane: the production sidecar applies + // the configure through the simulator's SDK controls… ws.send({ type: 'freshAgent.configure', requestId: 'cfg-conv-claude-1', @@ -327,17 +324,18 @@ test.describe('fresh-agent live model convergence (rust)', () => { expect(metadata.event.model).toBe('sonnet') expect(metadata.event.effort).toBe('low') - // …and the sidecar request log proves the configure reached the runtime. + // …and the simulator ledger proves the SDK controls received the update. await expect .poll( - () => readJsonl(sidecarLogPath).filter((e) => e.msg?.type === 'configure').length, + () => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'control').length, { timeout: 30_000 }, ) .toBeGreaterThan(0) - const configureRequest = readJsonl(sidecarLogPath).find((e) => e.msg?.type === 'configure') - expect(configureRequest.msg.sessionId).toBe(sessionId) - expect(configureRequest.msg.settings.model).toBe('sonnet') - expect(configureRequest.msg.settings.effort).toBe('low') + const controls = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'control') + expect(controls).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'setModel', args: ['sonnet'] }), + expect.objectContaining({ name: 'applyFlagSettings' }), + ])) } finally { ws?.close() await server.stop().catch(() => {}) 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 61d3ee3c1..ed6d96cbf 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -39,6 +39,7 @@ import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -47,7 +48,6 @@ const CODEX_FIXTURE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', ) -const CLAUDE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') // --------------------------------------------------------------------------- @@ -185,7 +185,11 @@ async function bootWall( setupHome?: (homeDir: string) => Promise } = {}, ): Promise<{ server: RustServer; info: Awaited>; harness: TestHarness }> { - const server = new RustServer({ env: options.env, setupHome: options.setupHome }) + const server = new RustServer({ + env: options.env, + setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) @@ -240,7 +244,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { test('claude: model, thinking, and permission changes apply to the next message in the same conversation', async ({ page }) => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-next-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') // Deterministic catalog only: the conversation and settings writes run // through the real browser, WebSocket handler, and Rust Claude runtime. await page.route('**/api/fresh-agent/model-capabilities/freshclaude**', (route) => route.fulfill({ json: { @@ -249,7 +253,25 @@ test.describe('fresh-agent settings survive restart (rust)', () => { supportedEffortLevels: ['low', 'medium', 'high'], supportsAdaptiveThinking: false }], } })) const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, FAKE_CLAUDE_SIDECAR_ECHO_SETTINGS: '1' }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + { + on: 'msg:prompt', + match: { text: 'First message before settings change' }, + once: true, + emit: [{ kind: 'text', data: { text: 'settings test first response' } }], + }, + { + on: 'msg:prompt', + match: { text: 'Second message uses the selected settings' }, + once: true, + emit: [{ kind: 'text', data: { text: 'settings test updated response' } }], + }, + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -262,7 +284,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { await expect(composer).toBeEnabled({ timeout: 30_000 }) await composer.fill('First message before settings change') await pane.getByRole('button', { name: 'Send', exact: true }).click() - await expect(pane.getByText(/^Fixture claude turn/)).toBeVisible({ timeout: 30_000 }) + await expect(pane.getByText('settings test first response', { exact: true })).toBeVisible({ timeout: 30_000 }) await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toBeHidden() const before = findFreshAgentLeaf(await harness.getPaneLayout(tabId)).content @@ -276,15 +298,12 @@ test.describe('fresh-agent settings survive restart (rust)', () => { await page.getByRole('button', { name: 'Agent settings', exact: true }).click() await composer.fill('Second message uses the selected settings') await pane.getByRole('button', { name: 'Send', exact: true }).click() - await expect(pane.getByText('Fixture claude turn (sonnet, low, plan)', { exact: true })).toBeVisible({ timeout: 30_000 }) - - const requests = readJsonl(sidecarLogPath).map((row) => row.msg) - const secondSend = requests.findIndex((msg) => msg.type === 'send' && msg.text === 'Second message uses the selected settings') - const configure = requests.findLastIndex((msg) => msg.type === 'configure') - expect(configure).toBeGreaterThan(-1) - expect(configure).toBeLessThan(secondSend) - expect(requests[configure].settings).toMatchObject({ model: 'sonnet', effort: 'low', permissionMode: 'plan' }) - expect(requests.filter((msg) => msg.type === 'create')).toHaveLength(1) + await expect(pane.getByText('settings test updated response', { exact: true })).toBeVisible({ timeout: 30_000 }) + + const ledger = readClaudeSimLedger(simLedger) + const secondPrompt = ledger.find((row) => row.kind === 'prompt' && row.text === 'Second message uses the selected settings') + expect(secondPrompt).toMatchObject({ model: 'sonnet', effort: 'low', permissionMode: 'plan' }) + expect(ledger.filter((row) => row.kind === 'query')).toHaveLength(1) const after = findFreshAgentLeaf(await harness.getPaneLayout(tabId)).content expect(after.sessionId).toBe(before.sessionId) expect(after.sessionRef).toEqual(before.sessionRef) @@ -506,14 +525,12 @@ test.describe('fresh-agent settings survive restart (rust)', () => { test('claude: attach resume request carries model/permissionMode', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), }) let ws: WsCapture | null = null try { @@ -539,7 +556,16 @@ test.describe('fresh-agent settings survive restart (rust)', () => { 'freshAgent.created (claude create)', ) const bridgeSessionId = created.sessionId as string - // sdk.session.init -> freshAgent.session.init carries the durable UUID. + + // The SDK reports its durable identity with the first model turn's + // system init. Drive that turn before waiting for the identity event. + ws.send({ + type: 'freshAgent.send', + provider: 'claude', + sessionId: bridgeSessionId, + sessionType: 'freshclaude', + text: 'settings-resume first claude turn', + }) const init = await ws.waitFor( (f) => f.type === 'freshAgent.event' && @@ -551,14 +577,8 @@ 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 (the fixture ends it with its result; the server rings it). - ws.send({ - type: 'freshAgent.send', - provider: 'claude', - sessionId: bridgeSessionId, - sessionType: 'freshclaude', - text: 'settings-resume first claude turn', - }) + // The production sidecar reports the turn's durable id before forwarding + // its completion frame. await ws.waitFor( (f) => f.type === 'freshAgent.event' && @@ -588,21 +608,22 @@ test.describe('fresh-agent settings survive restart (rust)', () => { sessionRef: { provider: 'claude', sessionId: durable }, }) - // The resume-on-attach spawns a fresh sidecar whose create carries - // a resume boot + the LEDGER-recorded settings (Task 10). + // The resume-on-attach starts a new simulated CLI query with the durable + // resume id and LEDGER-recorded settings (Task 10). await expect .poll( - () => - readJsonl(sidecarLogPath).some((m) => m.msg && m.msg.boot?.resume === durable), + () => readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === durable), { timeout: 30_000 }, ) .toBe(true) - const msgs = fs.readFileSync(sidecarLogPath, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) - const resumeCreate = msgs.filter((m) => m.msg && m.msg.boot?.resume).pop() - expect(resumeCreate, 'restart must issue a resume create').toBeTruthy() - expect(resumeCreate.msg.model, 'resume must carry the recorded model, not null').toBe('opus-x') - expect(resumeCreate.msg.permissionMode, 'resume must carry the recorded permissionMode').toBe('plan') + const resumeBoot = readClaudeSimLedger(simLedger) + .filter((row) => row.kind === 'query' && (row.options as any)?.resume === durable) + .at(-1) + expect(resumeBoot, 'restart must issue a resume query').toBeTruthy() + expect((resumeBoot?.options as any)?.model, 'resume must carry the recorded model, not null').toBe('opus-x') + expect((resumeBoot?.options as any)?.permissionMode, 'resume must carry the recorded permissionMode').toBe('plan') } finally { ws?.close() await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts b/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts index deb6999c3..98bd95351 100644 --- a/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts @@ -1,17 +1,6 @@ -// FRESHCLAUDE FIRST SEND -- the real Claude CLI's session-report order. -// -// The real CLI emits system/init (relayed by the sidecar as sdk.session.init, -// which gives the pane its durable session id and the server's ownership -// coordinator its owner record) only once a prompt runs, and again at the -// start of every turn. So a new freshclaude pane's first message is sent -// before anything owns its session, and its later messages are sent after the -// coordinator owns the session under the durable id, while the pane still -// addresses it by the sidecar's placeholder id. Every message must run. -// -// The fake sidecar's FAKE_CLAUDE_SIDECAR_INIT_AT=turn reproduces that order. -// Every other freshclaude spec uses the fake's default, which reports the -// session right at create, so without this spec no browser test sends a -// message before the session is reported. +// FRESHCLAUDE FIRST SEND -- create-time identity reaches the real browser and +// the production sidecar. The first prompt and a follow-up must both run under +// the same durable session identity. // // Helpers are copied, not imported, per this suite's per-spec-ownership // convention (donor: freshclaude-restart-parity-rust.spec.ts, itself copied @@ -27,13 +16,12 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): derive __dirname. const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -153,17 +141,11 @@ async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: stri }) } -/** The text of every prompt the fake sidecar received, in order (its request - * log is the ground truth for "the message ran"). */ -function sidecarPrompts(requestLog: string): string[] { - if (!fs.existsSync(requestLog)) return [] - return fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((entry) => entry.msg?.type === 'send') - .map((entry) => entry.msg.text) +/** The simulated CLI's started prompts, in order. */ +function simulatedPrompts(ledger: string): string[] { + return readClaudeSimLedger(ledger) + .filter((entry) => entry.kind === 'prompt') + .map((entry) => String(entry.text ?? '')) } // --------------------------------------------------------------------------- @@ -171,18 +153,21 @@ function sidecarPrompts(requestLog: string): string[] { // --------------------------------------------------------------------------- test.describe('freshclaude first send (rust)', () => { - test("a new pane's messages run when the CLI reports the session only once a prompt runs", async ({ page }) => { + test("a new pane accepts its first message after create-time identity is available", async ({ page }) => { test.setTimeout(120_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-first-send-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_INIT_AT: 'turn', - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + // The production sidecar reports the durable session only when the + // simulated CLI starts its first prompt, matching the real CLI order. + program: { + rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'Fixture claude turn' } }] }], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -195,34 +180,31 @@ test.describe('freshclaude first send (rust)', () => { await createFreshclaudePane(page, harness, projectDir) const pane = page.locator('[data-context="fresh-agent"]').last() - // The order under test: the session exists but the CLI has not reported - // it, so the pane has no durable id when the user types. + // Create allocates and returns a durable identity before the user types. await expect - .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabId))?.content?.status, { + .poll(async () => liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))), { timeout: 20_000, }) - .toBe('idle') - expect(liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId)))).toBe('') + .toMatch(CANONICAL_UUID_RE) + const durableBeforeSend = liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) const suffix = Math.random().toString(36).slice(2, 10) const first = `first message ${suffix}` await sendFreshAgentTurn(page, harness, tabId, first) await expect - .poll(() => sidecarPrompts(requestLog), { timeout: 30_000 }) + .poll(() => simulatedPrompts(simLedger), { timeout: 30_000 }) .toEqual([first]) - // Running the first prompt made the CLI report the session: the pane now - // holds its durable id (and the server owns the session under it). await expect .poll(async () => liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))), { timeout: 15_000, }) - .toMatch(CANONICAL_UUID_RE) + .toBe(durableBeforeSend) await expect(pane).toContainText('Fixture claude turn', { timeout: 30_000 }) const second = `second message ${suffix}` await sendFreshAgentTurn(page, harness, tabId, second) await expect - .poll(() => sidecarPrompts(requestLog), { timeout: 30_000 }) + .poll(() => simulatedPrompts(simLedger), { timeout: 30_000 }) .toEqual([first, second]) // Each message was sent once and none was refused. diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index 51338be02..661c35fd1 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -24,11 +24,11 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import { MACHINE_ID_STORAGE_KEY } from '../../../src/store/storage-keys.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // NOTE: `Page` comes from '@playwright/test' (fixtures.ts exports only // `test`/`expect`, as every donor spec does). `openPanePicker` is IMPORTED, // not copied -- the copied `createFreshclaudePane` body calls it (wall :29, -// :448). `fileURLToPath` feeds the FAKE_CLAUDE_SIDECAR_SOURCE constant below -// (wall :34, :39). +// :448). // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules, so derive it -- same convention as every fixture-referencing @@ -36,10 +36,8 @@ import { MACHINE_ID_STORAGE_KEY } from '../../../src/store/storage-keys.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // Canonical claude session id shape (copy of shared/session-contract.ts's -// CLAUDE_SESSION_ID_RE -- per-spec-ownership convention). The fake sidecar +// CLAUDE_SESSION_ID_RE -- per-spec-ownership convention). The simulator // mints a RANDOM canonical UUID per process (council follow-up, PR #562/#563 // close-out: the old static 44444444-... default made every resume-less // create in every sidecar process collide onto one constant id -- @@ -134,7 +132,11 @@ async function bootWall( setupHome?: (homeDir: string) => Promise } = {}, ): Promise<{ server: RustServer; info: E2eServerInfo; harness: TestHarness }> { - const server = new RustServer({ env: options.env, setupHome: options.setupHome }) + const server = new RustServer({ + env: options.env, + setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) @@ -155,6 +157,18 @@ function findFreshAgentLeaf(node: any): any { return null } +function findPaneById(node: any, paneId: string): any { + if (!node) return null + if (node.type === 'leaf' && node.id === paneId) return node + if (node.type === 'split') { + for (const child of node.children ?? []) { + const found = findPaneById(child, paneId) + if (found) return found + } + } + return null +} + /** The durable session id this window's persisted layout names for the tab's * fresh-agent pane — read from the per-window envelope the product actually * writes (freshell.layout.v3., derived exactly like @@ -191,6 +205,33 @@ async function persistedFreshAgentIdentity(page: Page, tabId: string): Promise { + const seededIdentity = await page.evaluate(({ id, legacyPlaceholder }) => { + const layoutWindowId = sessionStorage.getItem('freshell.layout-window-id.v1') + if (!layoutWindowId) throw new Error('Missing layout-window-id while seeding the old pane shape') + const key = `freshell.layout.v3.${layoutWindowId}` + const raw = localStorage.getItem(key) + if (!raw) throw new Error('Missing persisted layout while seeding the old pane shape') + const layout = JSON.parse(raw) + const visit = (node: any): any => { + if (!node) return null + if (node.type === 'leaf' && node.content?.kind === 'fresh-agent') return node + for (const child of node.children ?? []) { + const found = visit(child) + if (found) return found + } + return null + } + const leaf = visit(layout?.panes?.layouts?.[id]) + if (!leaf) throw new Error(`Missing persisted fresh-agent pane ${id}`) + leaf.content.sessionId = legacyPlaceholder + localStorage.setItem(key, JSON.stringify(layout)) + return leaf.content.sessionRef?.sessionId ?? null + }, { id: tabId, legacyPlaceholder: placeholder }) + expect(seededIdentity, 'the old placeholder must coexist with a durable Claude sessionRef').toBeTruthy() +} + /** Send one chat turn in the last fresh-agent pane and wait for idle. For a * claude pane the idle wait does not prove the turn ran: the client never marks * a claude pane running on send (only opencode), and the composer hands the @@ -228,13 +269,13 @@ async function sendFreshAgentTurn( .toBe('idle') } -/** The fake sidecar's transcript for `durable` (its ground truth for what - * reached it): the user texts in order and the number of assistant replies. */ +/** The simulated CLI transcript for `durable`: user texts and assistant replies. */ async function readFixtureTranscript( homeDir: string, durable: string, ): Promise<{ userTexts: string[]; assistantTurns: number }> { - const transcriptFile = path.join(homeDir, '.claude', 'projects', '-fixture', `${durable}.jsonl`) + const transcriptFile = await findTranscriptFile(path.join(homeDir, '.claude', 'projects'), `${durable}.jsonl`) + if (!transcriptFile) return { userTexts: [], assistantTurns: 0 } const lines = (await fs.readFile(transcriptFile, 'utf-8')) .trim() .split('\n') @@ -248,6 +289,24 @@ async function readFixtureTranscript( } } +async function findTranscriptFile(root: string, basename: string): Promise { + let entries + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return null + } + for (const entry of entries) { + const candidate = path.join(root, entry.name) + if (entry.isFile() && entry.name === basename) return candidate + if (entry.isDirectory()) { + const nested = await findTranscriptFile(candidate, basename) + if (nested) return nested + } + } + return null +} + /** The page's freshAgent.send frames since the last clear, each with when it * left the page relative to `since` (negative: before), and whether the server * accepted it. */ @@ -328,8 +387,7 @@ async function expectFixtureTranscript( } } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -375,10 +433,18 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { test('durable identity survives browser reload, then SIGKILL restart resumes the SAME conversation', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-identity-freshclaude-')) const projectDir = path.join(sharedRoot, 'project') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - // EXACT leg-G options (wall :1174-1177): - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt('first turn before reload', [{ kind: 'text', data: { text: 'IDENTITY_FIRST_REPLY' } }]), + onPrompt('second turn after restart', [{ kind: 'text', data: { text: 'IDENTITY_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -396,7 +462,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { // harness, exactly as the wall's leg G does (:1180). await createFreshclaudePane(page, harness, projectDir) await sendFreshAgentTurn(page, harness, tabId!, 'first turn before reload') - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_FIRST_REPLY', { timeout: 30_000 }) // THE FOLD (shipped behavior: FreshAgentView.tsx's durable-identity // merge effect -- cited at plan-time base 7508149b as :1798-1830; on @@ -445,8 +511,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { .poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfterReload!))), { timeout: 30_000 }) .toBe(originalDurable) await sendFreshAgentTurn(page, harness, tabIdAfterReload!, 'second turn after restart') - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn', { timeout: 30_000 }) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('first turn before reload', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_SECOND_REPLY', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_FIRST_REPLY', { timeout: 30_000 }) // Every create sent across the reload+restart window targeted the // ORIGINAL session -- no identity-losing re-create. A create IS @@ -480,6 +546,65 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { } }) + test('old persisted placeholder with a durable sessionRef reloads and accepts another send', async ({ page }) => { + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-identity-legacy-placeholder-')) + const projectDir = path.join(sharedRoot, 'project') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + await fs.mkdir(projectDir, { recursive: true }) + const { server, info, harness } = await bootWall(page, { + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), + }) + try { + await selectShellIfPickerShowing(page) + const tabId = (await harness.getActiveTabId())! + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + await createFreshclaudePane(page, harness, projectDir) + await sendFreshAgentTurn(page, harness, tabId, 'C26 first turn') + await expect.poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId)))) + .toMatch(CANONICAL_UUID_RE) + const originalDurable = durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) + await expectFixtureTranscript(harness, server, info.homeDir, originalDurable, { + userTexts: ['C26 first turn'], + minAssistantTurns: 1, + }) + const bootsBeforeReload = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'boot').length + const queriesBeforeReload = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'query').length + + await flushPersistence(page) + await expect.poll(() => persistedFreshAgentIdentity(page, tabId)).toBe(originalDurable) + await seedLegacyFreshAgentPlaceholder(page, tabId, 'fc-e2e-legacy-placeholder') + await harness.clearSentWsMessages() + await reloadAndReconnect(page, harness) + + const reloadedTabId = (await harness.getActiveTabId())! + await expect.poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(reloadedTabId)))) + .toBe(originalDurable) + await expect.poll(async () => (await harness.getSentWsMessages() as any[]).some( + (message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable, + ), { message: 'reload attaches using the pane’s durable Claude reference' }).toBe(true) + const durableAttach = (await harness.getSentWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.sessionId === originalDurable, + ) + expect(durableAttach?.sessionRef).toEqual({ provider: 'claude', sessionId: originalDurable }) + await sendFreshAgentTurn(page, harness, reloadedTabId, 'C26 second turn after reload') + await expectFixtureTranscript(harness, server, info.homeDir, originalDurable, { + userTexts: ['C26 first turn', 'C26 second turn after reload'], + minAssistantTurns: 2, + }) + + const ledgerAfterReload = readClaudeSimLedger(simLedger) + expect(ledgerAfterReload.filter((row) => row.kind === 'boot')).toHaveLength(bootsBeforeReload) + expect(ledgerAfterReload.filter((row) => row.kind === 'query')).toHaveLength(queriesBeforeReload) + } finally { + await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }) + } + }) + test('real-user reload: identity survives with NO manual persist flush (natural persistence path only)', async ({ page }) => { // COUNCIL FOLLOW-UP (PR #562/#563 close-out, user-advocate's held // finding): every other journey in this suite (and the wall's leg G) @@ -503,7 +628,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -570,7 +695,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) let coldContext: BrowserContext | null = null @@ -631,9 +756,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - // Same exact leg-G options as test 1 (env: fake sidecar via - // FAKE_CLAUDE_SIDECAR_SOURCE; setupHome: seedWallConfig claude+freshAgent). - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + // Use the same production-sidecar simulator lane as the other identity tests. + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -648,7 +772,10 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { await expect .poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId!))), { timeout: 15_000 }) .toMatch(CANONICAL_UUID_RE) - const staleDurable: string = durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId!))) + const originalPane = findFreshAgentLeaf(await harness.getPaneLayout(tabId!)) + const originalPaneId = originalPane?.id as string | undefined + expect(originalPaneId).toBeTruthy() + const staleDurable: string = durableIdentity(originalPane) await flushPersistence(page) // Make the persisted identity STALE: delete every server-side artifact @@ -657,15 +784,15 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { expect(deleted.length, `expected transcript artifacts for ${staleDurable} under ${info.homeDir}`).toBeGreaterThan(0) // SIGKILL, then reload IMMEDIATELY -- the OLD page must never - // reconnect and fire a recovery create-with-resume: the fake sidecar - // re-creates the transcript on ANY create carrying resumeSessionId - // (fake-claude-sidecar.mjs:95, fs.openSync(..., 'a')), after which the - // session is Present again and dead_session can never surface. No + // reconnect and fire a recovery create-with-resume. The simulator + // refuses a resume after its transcript has been deleted, so the + // server can surface the stale session claim. No // waitForWsReady on the old page here, by design. await harness.clearSentWsMessages() await server.restartAbrupt() await reloadAndReconnect(page, harness) const tabIdAfter = await harness.getActiveTabId() + expect(tabIdAfter).toBe(tabId) // LOUD: the dead-session adjudication surfaces the stale claim. await expect @@ -675,7 +802,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { return entries.some((e: any) => e?.kind === 'fresh-agent' && e?.sessionRef?.sessionId === staleDurable) }, { timeout: 30_000 }) .toBe(true) - const leaf = findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfter!)) + const leaf = findPaneById(await harness.getPaneLayout(tabIdAfter!), originalPaneId!) + expect(leaf?.content?.kind).toBe('fresh-agent') expect(leaf?.content?.restoreError?.reason).toBe('durable_artifact_missing') // Batched dead-session adjudication is a real modal dialog // (DeadSessionPanel.tsx:20-24, role="dialog" aria-label="Dead sessions"), @@ -700,7 +828,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { // create that might land during the settle window, so asserting on it // here would not actually prove "never silent" (follow-up from the PR // #562 council review). - const leafAfterSettle = findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfter!)) + const leafAfterSettle = findPaneById(await harness.getPaneLayout(tabIdAfter!), originalPaneId!) // NEVER silent: identity not swapped, the loud restoreError still // standing (post-settle re-check -- council follow-up, PR #562/#563 // close-out: the pre-settle restoreError read alone could not prove @@ -710,6 +838,27 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { expect(leafAfterSettle?.content?.restoreError?.reason).toBe('durable_artifact_missing') const sent = await harness.getSentWsMessages() expect(sent.filter((m: any) => m?.type === 'freshAgent.create')).toEqual([]) + + // Reload once more to prove the failed durable claim remains attached + // to this exact pane and stays visible after hydration, rather than + // existing only in the first page's transient adjudication state. + await harness.clearSentWsMessages() + await reloadAndReconnect(page, harness) + const secondReloadTabId = await harness.getActiveTabId() + expect(secondReloadTabId).toBe(tabId) + const leafAfterSecondReload = findPaneById( + await harness.getPaneLayout(secondReloadTabId!), + originalPaneId!, + ) + expect(leafAfterSecondReload?.content?.kind).toBe('fresh-agent') + expect(leafAfterSecondReload?.content?.sessionRef?.sessionId).toBe(staleDurable) + expect(leafAfterSecondReload?.content?.restoreError?.reason).toBe('durable_artifact_missing') + await expect(page.getByRole('alert').filter({ + hasText: 'This session cannot be resumed because the saved session artifact is no longer available.', + })).toBeVisible({ timeout: 10_000 }) + await page.waitForTimeout(1_000) + const sentAfterSecondReload = await harness.getSentWsMessages() + expect(sentAfterSecondReload.filter((m: any) => m?.type === 'freshAgent.create')).toEqual([]) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }) diff --git a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts index 6741f205c..54257a19c 100644 --- a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts @@ -1,20 +1,17 @@ // FRESHCLAUDE RESTART PARITY -- restart-resilience plan §2.8 items 2-4 (Lane A2). -// Proves the server-side resume path end to end against the extended fake sidecar: +// Proves the server-side resume path end to end against the production sidecar +// and the committed Claude CLI simulator: // 1. restartAbrupt -> WS auto-reconnect -> client re-attaches -> server resumes // IN PLACE: no INVALID_SESSION_ID lost frame, no client-driven re-create. // 2. History rehydrates via GET /api/fresh-agent/threads/... (transcript adapter). -// 3. The next send continues the SAME conversation (fixture request log shows -// create carried a resume boot of the original durable UUID). +// 3. The next send continues the SAME conversation (the simulator ledger +// shows the resumed SDK query used the original durable UUID). // 4. A pane BUSY at restart un-wedges (idle status snapshot from the attach arm). // exists only on RustServer). NOTE: no page.reload() in test 1/2 -- the reload leg // is the contract wall's freshclaude test; this spec owns the reconnect leg. // // Helpers are copied, not imported, from restore-contract-wall-rust.spec.ts per // that suite's per-spec-ownership convention (donor line refs on each helper). -// The only mechanical adaptation: the donor imports 'node:fs/promises' as `fs`; -// this spec needs sync fs for the fixture request log, so promises live on `fsp` -// and the copied helper bodies use `fsp.` where the donor wrote `fs.`. -import fs from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -25,6 +22,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules -- derive it, same shim as the donor spec @@ -32,8 +30,6 @@ import type { Page } from '@playwright/test' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --------------------------------------------------------------------------- // Copied helpers (donor: restore-contract-wall-rust.spec.ts) // --------------------------------------------------------------------------- @@ -171,8 +167,8 @@ async function sendFreshAgentTurn( .toBe('idle') } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) (donor :436-467) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator; +// donor :436-467) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -212,12 +208,19 @@ test.describe('freshclaude restart parity (rust)', () => { test('SIGKILL restart: attach resumes in place, history rehydrates, send continues the same conversation', async ({ page }) => { test.setTimeout(120_000) // restart + resume + snapshot legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-parity-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + const prompt = 'parity first turn' + const secondPrompt = 'parity second turn' const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt(prompt, [{ kind: 'text', data: { text: 'PARITY_FIRST_REPLY' } }]), + onPrompt(secondPrompt, [{ kind: 'text', data: { text: 'PARITY_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -232,13 +235,12 @@ test.describe('freshclaude restart parity (rust)', () => { const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) await createFreshclaudePane(page, harness, projectDir) - const prompt = `parity first turn ${Math.random().toString(36).slice(2, 10)}` await sendFreshAgentTurn(page, harness, tabId, prompt) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('PARITY_FIRST_REPLY') - // Durable identity = the fixture's canonical UUID (via liveDurableIdentity; + // Durable identity = the simulator's canonical UUID (via liveDurableIdentity; // content.sessionId stays the create-time nanoid on a live claude pane). - // The fake sidecar mints a RANDOM canonical UUID per process (council + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static default was collision-blind), so gate on // the canonical-UUID SHAPE (which the fc-e2e-* nanoid never matches) // and CAPTURE what this run minted. @@ -300,13 +302,9 @@ test.describe('freshclaude restart parity (rust)', () => { // Server-side resume proof: the post-restart sidecar create carried // options.resume = the original durable UUID (spec item 2 verification). - const resumedCreates = fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .filter((e) => e.msg?.type === 'create' && e.msg?.boot?.resume === originalDurable) - expect(resumedCreates.length).toBeGreaterThanOrEqual(1) + const resumedBoots = readClaudeSimLedger(simLedger) + .filter((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) + expect(resumedBoots.length).toBeGreaterThanOrEqual(1) // History rehydrated (snapshot adapter): the PRE-restart prompt is back in // the pane after the snapshot fetch folds durable turns in. @@ -315,9 +313,8 @@ test.describe('freshclaude restart parity (rust)', () => { }) // Same conversation continues: next send round-trips on the resumed session. - const secondPrompt = `parity second turn ${Math.random().toString(36).slice(2, 10)}` await sendFreshAgentTurn(page, harness, tabId, secondPrompt) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('PARITY_SECOND_REPLY') const layout = await harness.getPaneLayout(tabId) expect(liveDurableIdentity(findFreshAgentLeaf(layout))).toBe(originalDurable) } finally { @@ -329,14 +326,18 @@ test.describe('freshclaude restart parity (rust)', () => { test('a pane BUSY at restart un-wedges and the next send works', async ({ page }) => { test.setTimeout(120_000) // restart + resume legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-busy-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') - const holdMarker = path.join(sharedRoot, 'hold-once.marker') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + const postWedgePrompt = 'post-wedge turn recovery' const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER: holdMarker, - }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + { on: 'msg:prompt', match: { text: 'wedge me' }, emit: [{ kind: 'hang' }] }, + onPrompt(postWedgePrompt, [{ kind: 'text', data: { text: 'POST_WEDGE_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -359,7 +360,7 @@ test.describe('freshclaude restart parity (rust)', () => { }) .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) - // First send wedges (fixture holds the first turn forever): status stuck busy. + // First send wedges (the simulator holds this model request forever): status stuck busy. // Wait for idle BEFORE filling (donor sendFreshAgentTurn's pre-send poll, // :371-391) -- but do NOT wait for idle after: this turn never completes. await expect @@ -389,14 +390,10 @@ test.describe('freshclaude restart parity (rust)', () => { .toBe('idle') // And the conversation is live again on the SAME durable session. - await sendFreshAgentTurn(page, harness, tabId, `post-wedge turn ${Date.now()}`) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') - const resumed = fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some((e) => e.msg?.type === 'create' && e.msg?.boot?.resume === busyDurable) + await sendFreshAgentTurn(page, harness, tabId, postWedgePrompt) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('POST_WEDGE_REPLY') + const resumed = readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === busyDurable) expect(resumed).toBe(true) } finally { await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts index 68745a853..1017f5d59 100644 --- a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts @@ -4,15 +4,11 @@ * restart (RustServer.restartAbrupt(): SIGKILL + revive on the same * home/port/token) and resume in place -- never be adjudicated dead. * - * Why this spec exists: the fake sidecar creates the transcript file AT - * SESSION CREATE, before any turn -- a 0-byte .jsonl. (Validated: the REAL - * CLI materializes the transcript only at first turn; the fixture's shape - * stands in for any on-disk-but-R10b-excluded transcript, e.g. crash-window - * partial writes.) That file fails the session index's R10b cwd gate, so pre-fix the - * reconcile existence probe answered Absent while the attach arm's raw-file - * check would happily resume it => DeadSession{session_not_on_disk} and the - * 'Dead sessions' dialog. Every OTHER freshclaude-restart spec sends a turn - * before restarting (which writes a cwd-bearing line and masks the bug); + * Why this spec exists: the real CLI does not materialize a transcript until + * the first turn. A create-time durable identity with no transcript must + * still survive restart without a false dead-session adjudication. Every + * OTHER freshclaude-restart spec sends a turn before restarting (which writes + * a cwd-bearing line and masks the no-transcript case); * hidden-pane-rebind-rust.spec.ts covers the HIDDEN zero-turn pane; this * spec covers the VISIBLE one. DO NOT add a turn before the restart -- * zero-turn is the entire point. @@ -31,15 +27,7 @@ import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' - -// ESM project ("type": "module" in package.json): __dirname does not exist in -// ESM modules, so derive it -- same convention as every fixture-referencing -// donor spec (e.g. restore-contract-wall-rust.spec.ts:39-40). -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- donor: restore-contract-wall-rust.spec.ts) @@ -124,8 +112,7 @@ function findFreshAgentLeaf(node: any): any { return null } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -166,10 +153,15 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { test.setTimeout(180_000) test('visible zero-turn freshclaude pane resumes after abrupt restart', async ({ page }) => { - // Sidecar REQUEST log: the post-restart resume proof reads it. - const requestLog = path.join(os.tmpdir(), `freshell-e2e-claude-sidecar-${Date.now()}.jsonl`) + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-e2e-zero-turn-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLog }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [onPrompt('zero-turn post-restart send', [{ kind: 'text', data: { text: 'ZERO_TURN_REPLY' } }])], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -185,10 +177,9 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { return c?.sessionId && c?.createRequestId ? true : null }, { timeout: 30_000 }) .not.toBeNull() - // Wait for the DURABLE identity (sdk.session.init merge writes the - // canonical UUID to sessionRef.sessionId + resumeSessionId). The fake - // sidecar mints a RANDOM canonical UUID per process, so gate on the - // canonical-UUID SHAPE and capture what this run minted. + // Rust assigns the durable UUID as part of create, before any turn. + // Capture that acknowledgement identity rather than depending on an + // SDK init event that only arrives after a prompt. await expect .poll(async () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content @@ -198,41 +189,40 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { const contentBefore = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! const originalDurable = (contentBefore.sessionRef?.sessionId ?? contentBefore.resumeSessionId) as string const createRequestIdBefore = contentBefore.createRequestId as string + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === createRequestIdBefore, + ) + expect(createdFrame?.sessionRef, 'the create acknowledgement assigns the durable identity before any turn') + .toEqual({ provider: 'claude', sessionId: originalDurable }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { message: 'the create-time durable identity has a live fresh-agent owner' }).toBe('fresh-agent') // ZERO TURNS, pane stays VISIBLE (no extra tab, no hide): the // transcript on disk is the sidecar's create-time 0-byte file -- // exactly the R10b-excluded shape this regression pins. + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) - // Positive proof (same discriminator as the hidden-pane spec): the - // sidecar request log must contain a `create` whose resume boot names - // originalDurable -- only the restart-parity resume arm emits that, and - // the initial create boots fresh, so a match is unambiguous - // post-restart evidence. - // The parity arm resumes by durable UUID or by the transcript's - // .jsonl PATH; both carry the durable UUID -- accept either shape. + // A new query is not required when the original live writer survives + // the server restart. Prove the post-restart attach carries the + // create-time durable reference and the ready handshake reports its + // live owner; both are server-originated lifecycle evidence. await expect .poll(async () => { - const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content - const status = c?.status ?? '' - const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - const log = await fs.readFile(requestLog, 'utf-8').catch(() => '') - const resumed = log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === originalDurable || - e.msg.boot.resume.endsWith(`/${originalDurable}.jsonl`)), - ) - return usable && resumed ? `resumed:${status}` : null + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable) ?? null }, { timeout: 30_000 }) - .not.toBeNull() + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: originalDurable } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection observes the fresh-agent owner' }).toBe('fresh-agent') // Negative proof (the pre-fix failure shape must be gone): the // zero-turn session must NOT be adjudicated dead. @@ -243,6 +233,15 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { deadEntries.some((e: any) => e?.sessionRef?.sessionId === originalDurable), 'zero-turn session must not appear in dead-session adjudication', ).toBe(false) + const paneRoot = page.locator('[data-context="fresh-agent"]').last() + const composer = paneRoot.getByRole('textbox', { name: 'Chat message input' }) + await composer.fill('zero-turn post-restart send') + await paneRoot.getByRole('button', { name: 'Send' }).click() + await expect(paneRoot.getByText('ZERO_TURN_REPLY', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === 'zero-turn post-restart send' && row.sessionId === originalDurable, + ), { timeout: 15_000, message: 'the resumed zero-turn conversation receives the next send under its original UUID' }) + .toBe(true) const contentAfter = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! expect(contentAfter.restoreError?.reason ?? null).not.toBe('durable_artifact_missing') // In-place resume: the client's .lost re-create fallback must NOT have @@ -250,6 +249,7 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { expect(contentAfter.createRequestId).toBe(createRequestIdBefore) } finally { await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) } }) }) 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 7584a21e0..b1fec6f28 100644 --- a/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts @@ -1,7 +1,7 @@ /** * HARNESS-03 — deterministic provider fixtures: fixture-only contract spec. * - * Invokes each of the seven fake provider executables DIRECTLY (no Freshell + * Invokes the six remaining fake provider executables DIRECTLY (no Freshell * server boots — the fixtures are the deliverable; later TERM/AGENT items wire * them into the real pane picker/server later) and asserts, per provider: * @@ -10,9 +10,8 @@ * 2. scripted session/activity/approval/question/completion/crash/resume * events land in the normalized event ledger in scripted order; * 3. the provider's wire surface carries the real protocol shape (stdout - * markers + bare BEL for terminal CLIs; newline-JSON sdk.* frames for - * the kilroy/claude sidecar; WS JSON-RPC notifications for the codex - * app-server; SSE frames for the opencode server); + * markers + bare BEL for terminal CLIs; WS JSON-RPC notifications for + * the codex app-server; SSE frames for the opencode server); * 4. crash exits with the scripted code after recording the crash event; * 5. resume: each provider's real resume argv shape yields a resume event. * @@ -151,161 +150,6 @@ test.describe('terminal CLI fixture: amplifier', () => { }) }) -// ── Kilroy / Claude-SDK sidecar ───────────────────────────────────────────── -// The kilroy/freshclaude providers are ONE protocol family: the Node sidecar -// speaking the newline-JSON bridge of crates/freshell-claude-sidecar/index.mjs -// (created FIRST, sdk.* after). Program rules key on bridge message types -// (`msg:create`, `msg:send`, …). - -const SIDECAR_PROGRAM = { - sessionId: '66666666-6666-4666-8666-666666666666', - rules: [ - { - on: 'msg:send', - match: { text: 'please approve' }, - emit: [ - { kind: 'approval', data: { id: 'perm-1', tool: 'Bash', input: { command: 'rm -rf /tmp/x' } } }, - { kind: 'question', data: { id: 'q-1', text: 'which file should I edit?' } }, - { kind: 'completion', delayMs: 20, data: { subtype: 'success' } }, - ], - }, - { - on: 'msg:send', - match: { text: 'explode' }, - emit: [{ kind: 'crash', data: { code: 5 }, delayMs: 10 }], - }, - ], -} - -async function sendSidecar(fixture: LaunchedFixture, msg: Record) { - fixture.proc.stdin?.write(`${JSON.stringify(msg)}\n`) -} - -async function readSidecarLine(fixture: LaunchedFixture, pred: (obj: any) => boolean, what: string) { - const deadline = Date.now() + 10_000 - for (;;) { - const lines = fixture.stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.startsWith('{')) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) - const match = lines.find(pred) - if (match) return match - if (Date.now() > deadline) { - throw new Error(`sidecar: timed out waiting for ${what}. stdout: ${fixture.stdout}`) - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } -} - -for (const provider of ['kilroy', 'freshclaude'] as const) { - test.describe(`claude-sdk sidecar fixture (${provider} flavour)`, () => { - let fixture: LaunchedFixture - test.afterEach(async () => { - await fixture?.stop() - }) - - test('create/send protocol with controllable approval, question, completion', async () => { - fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: SIDECAR_PROGRAM, - env: { - ...PROBE_ENV, - HARNESS03_PROBE: `probe-${provider}`, - FRESHELL_FAKE_PROVIDER: provider, - }, - }) - await sendSidecar(fixture, { type: 'create', requestId: 'req-1', cwd: fixture.cwd, model: 'fixture-model' }) - const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - expect(created.requestId).toBe('req-1') - const sessionId = created.sessionId as string - expect(sessionId).toBeTruthy() - // Asserted after the first protocol exchange so the child is provably past - // appendLaunchLedger (unlike terminal fixtures, a sidecar prints no prompt - // to wait on). - expectLedgerRow(fixture, provider, []) - - const init = await readSidecarLine(fixture, (o) => o.type === 'sdk.session.init', 'sdk.session.init') - // created must precede every sdk.* frame (claude.rs read_created discards - // earlier lines) — verify wire order. - const raw = fixture.stdout - expect(raw.indexOf('"created"')).toBeLessThan(raw.indexOf('"sdk.session.init"')) - expect(init.cliSessionId).toBe('66666666-6666-4666-8666-666666666666') - const sessionEvent = await fixture.waitEvent('session') - expect(sessionEvent.data.cliSessionId).toBe('66666666-6666-4666-8666-666666666666') - - fixture.proc.stdin?.write( - `${JSON.stringify({ type: 'send', sessionId, text: 'please approve' })}\n`, - ) - const perm = await readSidecarLine(fixture, (o) => o.type === 'sdk.permission.request', 'sdk.permission.request') - expect(perm).toMatchObject({ - sessionId, - requestId: 'perm-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'rm -rf /tmp/x' } }, - }) - 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 }) - // 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).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 - // (D1-F2); the program-emission assertions filter to program kinds. - const kinds = fixture.readEvents().map((event) => event.kind).filter((kind) => kind !== 'wire') - expect(kinds).toEqual(['session', 'activity', 'approval', 'question', 'completion']) - - fixture.proc.stdin?.write(`${JSON.stringify({ type: 'send', sessionId, text: 'explode' })}\n`) - expect(await fixture.exited()).toBe(5) - expect(fixture.readEvents().map((event) => event.kind).at(-1)).toBe('crash') - }) - - test('resume: a resume boot keeps the durable id and snapshots', async () => { - fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: { rules: [] }, - env: { - ...PROBE_ENV, - HARNESS03_PROBE: `probe-${provider}`, - FRESHELL_FAKE_PROVIDER: provider, - }, - }) - await sendSidecar(fixture, { - type: 'create', - protocol: 2, - requestId: 'req-resume', - cwd: fixture.cwd, - boot: { kind: 'resume', resume: '77777777-7777-4777-8777-777777777777' }, - }) - await readSidecarLine(fixture, (o) => o.type === 'sdk.session.init', 'init') - const initRaw = fixture.stdout - expect(initRaw).toContain('77777777-7777-4777-8777-777777777777') - await readSidecarLine(fixture, (o) => o.type === 'sdk.session.snapshot', 'snapshot') - const resume = await fixture.waitEvent('resume') - expect(resume.data.id).toBe('77777777-7777-4777-8777-777777777777') - - // 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, stopId: 'stop-1' }) + '\n') - fixture.proc.stdin?.write(JSON.stringify({ type: 'shutdown' }) + '\n') - expect(await fixture.exited()).toBe(0) - }) - }) -} - // ── Codex app-server fixture ──────────────────────────────────────────────── // WebSocket JSON-RPC, mirroring test/fixtures/coding-cli/codex-app-server/'s // wire surface: initialize-gated RPCs, thread/start + rollout session_meta, @@ -719,26 +563,6 @@ test.describe('provider fixtures are hermetic', () => { }) } - test('kilroy sidecar: create+turn with scrubbed PATH, no children', async () => { - const fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: SIDECAR_PROGRAM, - env: { ...PROBE_ENV, HARNESS03_PROBE: 'probe-kilroy', FRESHELL_FAKE_PROVIDER: 'kilroy' }, - scrub: true, - }) - try { - await sendSidecar(fixture, { type: 'create', requestId: 'scrub-req', cwd: fixture.cwd }) - const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - fixture.proc.stdin?.write( - `${JSON.stringify({ type: 'send', sessionId: created.sessionId, text: 'please approve' })}\n`, - ) - await fixture.waitEvent('completion') - expect(childPidsOf(fixture.pid)).toEqual([]) - } finally { - await fixture.stop() - } - }) - test('codex app-server: RPC contract with scrubbed PATH, no children', async () => { const port = await freePort() const listen = `ws://127.0.0.1:${port}` 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 3d35893ea..a71af4fdc 100644 --- a/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts @@ -22,7 +22,7 @@ import { FAKE_GEMINI_DEFAULT_TEXT, } from '../helpers/harness-06/fake-ai.js' import { - spawnFakeKilroy, + spawnKilroyRuntime, readKilroyLedger, } from '../helpers/harness-06/kilroy-runtime.js' import { @@ -283,32 +283,37 @@ test.describe('harness-06 misc fixtures smoke', () => { } }) - test('fake Kilroy runtime: create handshake + full success turn + request ledger', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'freshell-h06-smoke-kilroy-')) - const logPath = path.join(dir, 'requests.jsonl') - const rt = await spawnFakeKilroy({ FAKE_KILROY_LOG: logPath }) + test('Kilroy production sidecar: native create + full success turn + SDK ledger', async () => { + const rt = await spawnKilroyRuntime({ + timeScale: 0.02, + program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'kilroy smoke reply' } }] }] }, + }) try { - rt.send({ type: 'create', requestId: 'smoke-1', cwd: '/tmp/smoke', model: 'claude-opus-4-6' }) - const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } - expect(created.requestId).toBe('smoke-1') - const init = (await rt.nextEvent('sdk.session.init')) as { cliSessionId: string } - expect(init.cliSessionId).toMatch(/^[0-9a-f-]{36}$/) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'kilroy smoke turn' }) - 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') - 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) - expect(ledger.map((row) => (row.msg as { type: string }).type)).toEqual(['create', 'send']) + const sessionId = crypto.randomUUID() + rt.send({ type: 'create', protocol: 2, sessionType: 'kilroy', sessionId, requestId: 'smoke-1', cwd: rt.cwd, model: 'claude-opus-4-6', boot: { kind: 'fresh' } }) + expect(await rt.nextEvent('created')).toMatchObject({ requestId: 'smoke-1', sessionId, protocol: 2 }) + expect(rt.events().filter((event) => event.type === 'sdk.session.init')).toEqual([]) + + const uuid = crypto.randomUUID() + rt.send({ type: 'send', sessionId, uuid, text: 'kilroy smoke turn' }) + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ cliSessionId: sessionId, sessionId }) + const assistant = await rt.nextEvent('sdk.assistant') + expect(assistant.content).toEqual([{ type: 'text', text: 'kilroy smoke reply' }]) + expect(await rt.nextEvent('sdk.result')).toMatchObject({ sessionId, subtype: 'success', isError: false }) + expect(rt.events().filter((event) => event.type === 'sdk.turn.complete' || event.type === 'sdk.turn.waiting')).toEqual([]) + + const ledger = await readKilroyLedger(rt.ledgerPath) + const queries = ledger.filter((row) => row.kind === 'query') + expect(queries).toHaveLength(1) + expect(queries[0].options).toMatchObject({ sessionId, model: 'claude-opus-4-6', cwd: rt.cwd }) + expect(ledger.filter((row) => row.kind === 'prompt')).toEqual([ + expect.objectContaining({ sessionId, uuid, text: 'kilroy smoke turn' }), + ]) } finally { await rt.kill() - fs.rmSync(dir, { recursive: true, force: true }) + expect(rt.proc.exitCode).toBe(0) + expect(rt.proc.signalCode).toBeNull() + expect(fs.existsSync(rt.cwd)).toBe(false) } }) diff --git a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts index 8abe59953..9861bb8c9 100644 --- a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts +++ b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts @@ -10,7 +10,7 @@ * - Test 1 (terminal): the replacement terminal is claimed in the hidden * pane's terminal.interest snapshot without attaching. Reveal then attaches * and proves that the replacement shell accepts input and has real geometry. - * - Test 2 (fresh-agent): the sidecar request log records a resume using the + * - Test 2 (fresh-agent): the simulator ledger records a resume using the * original durable session ID while the pane remains hidden, and the pane * keeps its createRequestId instead of creating a duplicate session. * @@ -24,6 +24,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -36,8 +37,6 @@ import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- donor: restore-contract-wall-rust.spec.ts) // --------------------------------------------------------------------------- @@ -141,8 +140,7 @@ async function createTabViaRest(info: E2eServerInfo, body: object): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -281,12 +279,16 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { }) test('hidden fresh-agent pane recovers after abrupt restart without reveal', async ({ page }) => { - // Sidecar REQUEST log (FAKE_CLAUDE_SIDECAR_LOG, fake-claude-sidecar.mjs - // Task 7 knob): the post-restart resume proof below reads it. NOT the - // terminal-CLI argv log -- the fresh-agent path never spawns the CLI. - const requestLog = path.join(os.tmpdir(), `freshell-e2e-claude-sidecar-${Date.now()}.jsonl`) + // The simulator query ledger proves the durable identity used after restart. + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-hidden-rebind-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness, info } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLog }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [onPrompt('hidden pane post-restart send', [{ kind: 'text', data: { text: 'HIDDEN_REBIND_REPLY' } }])], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -299,7 +301,7 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { // Create a freshclaude pane in the current tab (helper copied from the wall). const freshTabId = (await harness.getActiveTabId())! // Helper signature is (page, harness, cwd) -- the wall spec's call sites - // pass a project dir; any existing directory works for the fake sidecar. + // pass a project dir; any existing directory works for the simulator. await createFreshclaudePane(page, harness, os.tmpdir()) await expect .poll(async () => { @@ -307,12 +309,10 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { return c?.sessionId && c?.createRequestId ? true : null }, { timeout: 30_000 }) .not.toBeNull() - // Wait for the DURABLE identity (sdk.session.init merge writes the - // canonical UUID to sessionRef.sessionId + resumeSessionId, - // FreshAgentView.tsx mergePaneContent) -- the post-restart attach must - // deterministically carry it so the server's restart-parity resume arm - // (claude.rs handle_attach decision table) can engage. - // The fake sidecar mints a RANDOM canonical UUID per process (council + // Rust assigns the durable UUID in the create acknowledgement before + // any prompt, so the hidden rebind can carry it without waiting for an + // SDK init event. + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static default was collision-blind), so gate on // the canonical-UUID SHAPE and capture what this run minted below. await expect @@ -323,6 +323,11 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) const contentBefore = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! const originalDurable = (contentBefore.sessionRef?.sessionId ?? contentBefore.resumeSessionId) as string + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === contentBefore.createRequestId, + ) + expect(createdFrame?.sessionRef, 'the create acknowledgement carries the durable Claude identity') + .toEqual({ provider: 'claude', sessionId: originalDurable }) // COMBINED-TREE CONTRACT (F8 rebind x freshclaude restart parity): with // the restart-parity attach arm merged, a post-restart attach carrying // the durable UUID is resumed IN PLACE (sidecar respawn with @@ -340,42 +345,34 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { await revealTab(page, harness, shellTabId) await expect.poll(async () => harness.getActiveTabId(), { timeout: 15_000 }).not.toBe(freshTabId) + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) // TARGET CONTRACT (F8): WITHOUT reveal, the hidden fresh-agent pane's // session recovers to a usable state. DISCRIMINATING evidence -- stale // pre-restart Redux state also shows `sessionId` + 'idle', so client - // state alone proves nothing. The discriminator is SERVER-SIDE: the - // sidecar request log must contain a `create` carrying - // a resume boot of originalDurable, which only the restart-parity - // resume arm emits, and it only runs when THIS hidden pane's - // post-restart freshAgent.attach actually went out (the rebind queue - // under test driving the attach arm). The initial pane create carries - // a fresh boot, so a matching entry is unambiguous post-restart - // evidence. Do not weaken either conjunct. + // state alone proves nothing. Require the hidden pane's actual + // post-restart attach and the server's ready-replayed live owner before + // revealing it. A surviving writer need not start another SDK query. + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable) ?? null + }, { timeout: 30_000, message: 'the hidden pane reattaches under its durable session reference' }) + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: originalDurable } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection reports the hidden session live owner' }).toBe('fresh-agent') await expect .poll(async () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content const status = c?.status ?? '' const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - const log = await fs.readFile(requestLog, 'utf-8').catch(() => '') - // The parity arm resumes by durable UUID when the session's original - // cwd survives, or by the transcript's `.jsonl` PATH when it does - // not (claude.rs decision table, ledger A15). Both carry the durable - // UUID; accept either shape. - const resumed = log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === originalDurable || - e.msg.boot.resume.endsWith(`/${originalDurable}.jsonl`)), - ) - return usable && resumed ? `resumed:${status}` : null + const durable = c?.sessionRef?.sessionId ?? c?.resumeSessionId ?? '' + return usable && durable === originalDurable ? `usable:${status}` : null }, { timeout: 30_000 }) .not.toBeNull() @@ -392,8 +389,16 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { return ['connected', 'idle', 'running'].includes(leaf?.content?.status ?? '') }, { timeout: 15_000 }) .toBe(true) + const paneRoot = page.locator('[data-context="fresh-agent"]').last() + await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('hidden pane post-restart send') + await paneRoot.getByRole('button', { name: 'Send' }).click() + await expect(paneRoot.getByText('HIDDEN_REBIND_REPLY', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === 'hidden pane post-restart send' && row.sessionId === originalDurable, + ), { timeout: 15_000, message: 'the revealed hidden pane still sends on the original durable session' }).toBe(true) } finally { await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) } }) diff --git a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts index afb94cdd3..fc280c625 100644 --- a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts @@ -2,12 +2,12 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { fileURLToPath } from 'node:url' import { test, expect } from '@playwright/test' import { RustServer } from '../helpers/rust-server.js' import { McpStdioClient, ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' /** * MCP bridge pin -- Slice 2 of the agent-API + MCP parity spec @@ -172,13 +172,6 @@ test.describe('MCP bridge -- Rust QA lever pin (Slice 2)', () => { test.describe('MCP bridge -- b8ke Task 10 respawn/attach ownership recovery', () => { test.setTimeout(180_000) - const __filename = fileURLToPath(import.meta.url) - const __dirname = path.dirname(__filename) - const FAKE_CLAUDE_SDK_SIDECAR = path.resolve( - __dirname, - '../fixtures/providers/fake-claude-sdk-sidecar.mjs', - ) - /** Flatten a pane layout tree into its leaf nodes. */ function collectLeaves(node: any): any[] { if (!node) return [] @@ -277,7 +270,16 @@ if (sid) { fs.mkdirSync(dir, { recursive: true }) const transcript = path.join(dir, sid + '.jsonl') if (!fs.existsSync(transcript)) { - fs.writeFileSync(transcript, JSON.stringify({ type: 'user', message: { role: 'user', content: 'seed' }, sessionId: sid, timestamp: new Date().toISOString() }) + '\\n') + // A recorded cwd lets Rust resume by the session UUID while the original + // workspace still exists. Without it, Rust correctly falls back to the + // transcript path, which the simulator treats as a distinct session id. + fs.writeFileSync(transcript, JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'seed' }, + sessionId: sid, + cwd: process.cwd(), + timestamp: new Date().toISOString(), + }) + '\\n') } } process.stdout.write('fake-claude ready\\r\\n') @@ -419,9 +421,9 @@ process.stdin.resume() test('ownership conflict through MCP returns typed owner info', async ({ page }) => { const { path: mcpBinPath } = ensureMcpServerBuilt(REPO_ROOT) const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-mcp-conflict-')) - // Both halves of the b8ke scenario on one server: the fake `claude` CLI - // (terminal panes) AND the fake SDK sidecar (the fresh-agent lane the - // handoff targets). + // The terminal Claude CLI and fresh-agent Claude sidecar are separate + // launch paths. The terminal fixture remains local to this ownership test; + // fresh-agent recovery uses the production sidecar and CLI simulator. const fake = await installFakeClaudeCli(sharedRoot) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) @@ -429,9 +431,10 @@ process.stdin.resume() const server = new RustServer({ env: { CLAUDE_CMD: fake.binPath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SDK_SIDECAR, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { rules: [] }, + }), }, setupHome: seedConfig({ providers: ['claude'], freshAgent: true }), }) @@ -447,9 +450,9 @@ process.stdin.resume() // LayoutStore-only), carrying the server-preallocated session id S. const picker = await openPanePicker(page) await picker.getByRole('button', { name: /^Claude CLI$/i }).click({ force: true }) - await page - .getByRole('combobox', { name: /Starting directory for Claude CLI/i }) - .press('Enter') + const startingDirectory = page.getByRole('combobox', { name: /Starting directory for Claude CLI/i }) + await startingDirectory.fill(projectDir) + await startingDirectory.press('Enter') const leaf = await expect.poll(async () => { const layout = await harness.getPaneLayout(tabId!) diff --git a/test/e2e-browser/specs/reconcile-completion-rust.spec.ts b/test/e2e-browser/specs/reconcile-completion-rust.spec.ts index a57345948..f2a1c372b 100644 --- a/test/e2e-browser/specs/reconcile-completion-rust.spec.ts +++ b/test/e2e-browser/specs/reconcile-completion-rust.spec.ts @@ -7,9 +7,8 @@ * reconcile round-trip names the fresh-agent pane; the pane converges on * the SAME durable identity; the happy path never marks the session lost * and never lands a restoreError); - * 2. two clients resuming one fresh-agent sessionRef spawn exactly ONE - * sidecar (the D8 fresh-agent lease + create dedup, proven via the fake - * sidecar's request log); + * 2. two clients resuming one fresh-agent sessionRef start exactly ONE + * simulated CLI query through the production sidecar; * 3. a page.reload storm never spawns an identity-less resume (the * pre-verdict create hold: every post-watermark spawn carries the resume * identity; live panes across reloads take attach verdicts -- zero @@ -27,6 +26,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -35,7 +35,6 @@ import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') // --------------------------------------------------------------------------- @@ -64,21 +63,10 @@ function hasFlagPair(argv: string[], flag: string, value: string): boolean { return idx >= 0 && argv[idx + 1] === value } -/** - * Read the fake claude SIDECAR request log (FAKE_CLAUDE_SIDECAR_LOG, - * fake-claude-sidecar.mjs: JSONL rows `{pid, t, msg}` -- one per inbound - * stdio request; `msg.type === 'create'` rows carry the protocol-v2 `boot`). - */ -async function readSidecarLog( - logPath: string, -): Promise> { - const raw = await fs.readFile(logPath, 'utf8').catch(() => '') - if (!raw) return [] - return raw - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as { pid: number; t: number; msg: { type?: string; boot?: { kind?: string; resume?: string } } }) +type SimQueryRow = { kind: 'query'; options?: { resume?: string } } + +function simQueryRows(ledgerPath: string): SimQueryRow[] { + return readClaudeSimLedger(ledgerPath).filter((row) => row.kind === 'query') as SimQueryRow[] } /** Dismiss the initial pane-type picker by choosing the first visible shell. */ @@ -327,13 +315,10 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-recompl-verdicts-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootSpec(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -395,13 +380,10 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-recompl-onesidecar-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, info, harness } = await bootSpec(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) let pageB: Page | null = null @@ -425,17 +407,17 @@ test.describe('reconcile completion (rust server, real SPA)', () => { await harnessB.waitForHarness() await harnessB.waitForConnection() - const watermark = (await readSidecarLog(sidecarLogPath)).length + const watermark = simQueryRows(simLedgerPath).length await server.restartAbrupt() await waitForWsReady(page) await waitForWsReady(pageB) - // Both recovery rounds settle; exactly ONE resume reaches a sidecar. + // Both recovery rounds settle; exactly ONE resume reaches the simulated CLI. const countResumes = async () => - (await readSidecarLog(sidecarLogPath)) + simQueryRows(simLedgerPath) .slice(watermark) - .filter((r) => r.msg?.type === 'create' && r.msg?.boot?.resume === identity).length + .filter((row) => row.options?.resume === identity).length await expect.poll(countResumes, { timeout: 45_000 }).toBeGreaterThan(0) // STABLE-COUNT settle (donor: restore-contract-wall-rust.spec.ts): // accept only when two samples >=5s apart agree, so a tail-latency @@ -484,7 +466,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const argvLogPath = path.join(sharedRoot, 'claude-argv.jsonl') - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const fakeClaudePath = await installFakeCli( FAKE_CLAUDE_CLI_SOURCE, 'claude', @@ -495,8 +477,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { env: { CLAUDE_CMD: fakeClaudePath, FAKE_CLAUDE_ARGV_LOG: argvLogPath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, + ...claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), }, setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) @@ -523,7 +504,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { await flushPersistence(page) const watermarkArgv = (await readArgvLog(argvLogPath)).length - const watermarkSidecar = (await readSidecarLog(sidecarLogPath)).length + const watermarkSidecar = simQueryRows(simLedgerPath).length for (let i = 0; i < 3; i += 1) { await page.reload({ waitUntil: 'domcontentloaded' }) @@ -562,14 +543,12 @@ test.describe('reconcile completion (rust server, real SPA)', () => { // is the non-negotiable assertion.) expect(spawns.length).toBe(0) - // Fresh-agent: every post-watermark sidecar create carries the resume + // Fresh-agent: every post-watermark simulator query carries the resume // identity (and on the attach path there should be none at all). - const creates = (await readSidecarLog(sidecarLogPath)) - .slice(watermarkSidecar) - .filter((r) => r.msg?.type === 'create') + const creates = simQueryRows(simLedgerPath).slice(watermarkSidecar) expect( - creates.every((r) => r.msg?.boot?.resume === freshclaudeIdentity), - `identity-less fresh-agent create detected: ${JSON.stringify(creates)}`, + creates.every((row) => row.options?.resume === freshclaudeIdentity), + `identity-less fresh-agent query detected: ${JSON.stringify(creates)}`, ).toBe(true) } finally { await server.stop() diff --git a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts index 33f1fe9ee..f04c84cdc 100644 --- a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts +++ b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts @@ -24,24 +24,15 @@ * transition while NO close frame exists (fresh-eyes F3); after thaw, * a normal reconnect + input round trip proves recovery. * 6. fresh-agent pane reattaches and round-trips after a bare socket drop. - * FIXTURE LIMITATION (per Task 8 execution note): the fake claude - * sidecar's real scripted affordances (create/send/interrupt/shutdown + - * HOLD_TURN knobs) offer NO scriptable unsolicited post-creation - * emission, so there is no "server-side-only marker while the client is - * down" hook to use. The discriminator here is therefore the - * post-reconnect composer round trip itself, made non-vacuous two ways: - * (a) server-side the sidecar request log must gain a NEW send after - * the reconnect (the prompt genuinely crossed the new connection), and - * (b) client-side the pane must render a strictly GREATER count of the - * fixture's fixed reply text than before the drop (the response - * genuinely came back over the reattached subscription -- fresh-eyes - * F3-2: render-only assertions can pass on surviving local state with a - * fully broken reattach). + * The production sidecar runs over the CLI simulator. Its ledger counts + * prompts the simulated CLI actually started, and the pane must render a + * new reply after reconnect; together those prove both directions of the + * live round trip. * * (socket-drop/freeze revival; drives RustServer + forceDisconnect + * SIGSTOP). Not in CLOUD_SKIP_SPECS -- no real provider binaries needed - * (every claude interaction uses the committed fakes via CLAUDE_CMD / - * FRESHELL_CLAUDE_SIDECAR env seams). + * (terminal lanes use committed CLI fakes; fresh-agent lanes use the + * production sidecar and the committed Claude CLI simulator). * * Helpers are COPIED from the donor specs (hidden-pane-rebind-rust, * reconcile-client-adoption-rust, restore-contract-wall-rust, @@ -53,6 +44,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -65,7 +57,6 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const noDeadEndText = /still running on the server|\[Restore failed\]/ @@ -330,8 +321,8 @@ async function writeClaudeTranscript(input: { await fs.writeFile(path.join(projectDir, `${sessionId}.jsonl`), `${lines.join('\n')}\n`, 'utf8') } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via -// the production env seam FRESHELL_CLAUDE_SIDECAR; donor: hidden-pane-rebind +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator; +// donor: hidden-pane-rebind // :153-182 / freshclaude-identity-persistence :183-219) --- async function createFreshclaudePane(page: Page, cwd: string): Promise { @@ -381,35 +372,9 @@ async function sendFreshAgentTurn( .toBe('idle') } -/** Count rendered copies of the fixture's fixed assistant reply text inside - * the LAST fresh-agent pane. Per-turn render count is >=1 per turn, so a - * strictly-greater count after the second turn is proof the second reply - * genuinely rendered (the fixture's reply text is constant, so a distinct- - * text assertion is impossible -- count growth is the discriminator). */ -async function fixtureReplyCount(page: Page): Promise { - return page - .locator('[data-context="fresh-agent"]') - .last() - .getByText('Fixture claude turn', { exact: false }) - .count() -} - -/** Count `send` entries in the fake claude sidecar's request log. */ -async function sidecarSendCount(requestLogPath: string): Promise { - const raw = await fs.readFile(requestLogPath, 'utf8').catch(() => '') - if (!raw) return 0 - return raw - .trim() - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as { msg?: { type?: string } } - } catch { - return {} - } - }) - .filter((e) => e.msg?.type === 'send').length +/** Count prompts the simulated CLI actually started. */ +function simulatedPromptCount(ledgerPath: string): number { + return readClaudeSimLedger(ledgerPath).filter((row) => row.kind === 'prompt').length } // --------------------------------------------------------------------------- @@ -706,9 +671,17 @@ test.describe('reconnect revive (rust)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-rr-freshclaude-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const requestLogPath = path.join(sharedRoot, 'claude-sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLogPath }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt('rr fresh agent turn one', [{ kind: 'text', data: { text: 'RR_FIRST_REPLY' } }]), + onPrompt('rr fresh agent turn two', [{ kind: 'text', data: { text: 'RR_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -726,11 +699,9 @@ test.describe('reconnect revive (rust)', () => { // One completed turn BEFORE the drop (the plain happy path). await sendFreshAgentTurn(page, harness, freshTabId, 'rr fresh agent turn one') - const replyCountBeforeDrop: number = await expect - .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) - .toBeGreaterThan(0) - .then(() => fixtureReplyCount(page)) - const sendsBeforeDrop = await sidecarSendCount(requestLogPath) + await expect(page.locator('[data-context="fresh-agent"]').last().getByText('RR_FIRST_REPLY', { exact: true })) + .toBeVisible({ timeout: 30_000 }) + const sendsBeforeDrop = simulatedPromptCount(simLedger) expect(sendsBeforeDrop).toBe(1) const contentBeforeDrop = findFreshAgentLeaf( @@ -759,16 +730,14 @@ test.describe('reconnect revive (rust)', () => { contentBeforeDrop.resumeSessionId, ) - // Discriminating round trip (see this file's FIXTURE LIMITATION note): - // (a) the pane renders strictly MORE replies than survived the drop... + // Discriminating round trip: the scenario-specific second reply is + // visible after reconnect, and the ledger proves its prompt crossed the + // new server connection to the still-live sidecar. await sendFreshAgentTurn(page, harness, freshTabId, 'rr fresh agent turn two') + await expect(page.locator('[data-context="fresh-agent"]').last().getByText('RR_SECOND_REPLY', { exact: true })) + .toBeVisible({ timeout: 30_000 }) await expect - .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) - .toBeGreaterThan(replyCountBeforeDrop) - // ...(b) and the sidecar request log proves the second send genuinely - // crossed the (new) server connection to the still-live sidecar. - await expect - .poll(async () => sidecarSendCount(requestLogPath), { timeout: 15_000 }) + .poll(async () => simulatedPromptCount(simLedger), { timeout: 15_000 }) .toBe(2) // ...and no dead-end text ever surfaces in the pane chrome. await expect(page.getByText(noDeadEndText)).toHaveCount(0) diff --git a/test/e2e-browser/specs/recover-my-panes-rust.spec.ts b/test/e2e-browser/specs/recover-my-panes-rust.spec.ts index 28ec7cae7..6c4682cfe 100644 --- a/test/e2e-browser/specs/recover-my-panes-rust.spec.ts +++ b/test/e2e-browser/specs/recover-my-panes-rust.spec.ts @@ -30,6 +30,7 @@ import { request, type BrowserContext, type Page } from '@playwright/test' import { RustServer, ensureRustServerBuilt } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' import { openPanePicker } from '../helpers/pane-picker.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -242,8 +243,8 @@ async function shapeEvidenceExcluding(opts: { } /** - * Donor: hidden-pane-rebind-rust.spec.ts:153 (fixture: fake-claude-sidecar.mjs - * via the production env seam FRESHELL_CLAUDE_SIDECAR). + * Donor: hidden-pane-rebind-rust.spec.ts:153 (production Claude sidecar over + * the committed CLI simulator). */ async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -378,11 +379,10 @@ test.describe('recover-my-panes browser-loss recovery (rust only)', () => { env: { CLAUDE_CMD: fakeClaude, FAKE_CLAUDE_ARGV_LOG: argLog, - // Scenario 5's freshclaude lane: the fake SDK-bridge sidecar via the - // production env seam (read only at freshclaude sidecar spawn — inert - // for scenarios 1-4). restart() re-merges this env on every boot. - FRESHELL_CLAUDE_SIDECAR: path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs'), - FAKE_CLAUDE_SIDECAR_LOG: path.join(sharedRoot, 'claude-sidecar-requests.jsonl'), + // Scenario 5's freshclaude lane: the production sidecar and CLI + // simulator (read only at fresh-agent spawn — inert for scenarios 1-4). + // restart() re-merges this env on every boot. + ...claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), }, setupHome: async (homeDir: string) => { capturedHome = homeDir diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 23b912444..3bfbb627a 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -41,6 +41,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules, so derive it -- same convention as every fixture-referencing @@ -58,7 +59,6 @@ const FAKE_CODEX_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-codex-cl const FAKE_OPENCODE_TERMINAL_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode-terminal.mjs') const FAKE_OPENCODE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const FAKE_CODEX_APP_SERVER_SOURCE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', @@ -479,8 +479,7 @@ async function createFreshopencodePane(page: Page, cwd: string): Promise { }) } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -1326,7 +1325,23 @@ test.describe('Restore Contract Wall (P0.1)', () => { await fs.mkdir(projectDir, { recursive: true }) const { server, harness, info, context, page } = await bootWall(browser, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { + rules: [ + { + on: 'msg:prompt', + match: { text: 'wall freshclaude turn' }, + emit: [{ kind: 'text', data: { text: 'RESTORE_WALL_REPLY' } }], + }, + { + on: 'msg:prompt', + match: { text: 'wall freshclaude liveness turn' }, + emit: [{ kind: 'text', data: { text: 'RESTORE_WALL_LIVENESS_REPLY' } }], + }, + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -1340,36 +1355,14 @@ test.describe('Restore Contract Wall (P0.1)', () => { await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) await createFreshclaudePane(page, harness, projectDir) await sendFreshAgentTurn(page, harness, tabId, 'wall freshclaude turn') - // Pre-kill turn proof via the HARNESS (Redux), not UI chrome (corrected, - // council fix round): the fresh-agent transcript renders exclusively - // from the REST thread snapshot (FreshAgentView.tsx:1302,1782 -- - // getFreshAgentThreadSnapshot -> snapshot?.turns). The claim that "the - // Rust router has NO claude snapshot adapter -> 503" is FALSE at HEAD -- - // crates/freshell-freshagent/src/snapshot.rs:133-146 routes - // freshclaude/kilroy + claude through get_claude_snapshot(), a - // disk+env adapter over the CLI's own transcript store; - // FRESH_AGENT_RUNTIME_UNAVAILABLE now only fires for session types - // with no adapter registered at all. This assertion still reads via - // the harness (not DOM) because the snapshot fetch can race pane - // creation, not because the adapter is missing. The sidecar protocol - // itself is verified end-to-end regardless: freshAgent.event/ - // freshAgent.assistant arrives on the wire and folds into the - // freshAgent slice (turns[]) -- assert THAT (createFreshclaudePane's - // note: assert pane state via the harness, never error-free UI chrome - // for freshclaude). - await expect - .poll(async () => { - const sessions = (await harness.getState())?.freshAgent?.sessions ?? {} - return Object.values(sessions).some((s: any) => - (s?.turns ?? []).some((turn: any) => - turn?.role === 'assistant' - && (turn?.items ?? []).some( - (item: any) => typeof item?.text === 'string' && item.text.includes('Fixture claude turn'), - ), - ), - ) - }, { timeout: 20_000 }) - .toBe(true) + // Verify the unique scripted reply in the pane's live transcript. The + // SDK assistant frame is folded into the live-transcript slice; the + // `freshAgent.sessions` Redux cache is populated by REST snapshots and + // is not the live-stream source. After restart, the later assertion + // checks the same reply was rehydrated from the durable transcript. + await expect( + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_REPLY', { exact: true }), + ).toBeVisible({ timeout: 20_000 }) const originalSessionId: string = await expect .poll(async () => leafDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) ?? null, { @@ -1405,7 +1398,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { }) .toBe(originalSessionId) await expect( - page.locator('[data-context="fresh-agent"]').last().getByText('Fixture claude turn'), + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_REPLY', { exact: true }), ).toBeVisible({ timeout: 30_000 }) const finalLeaf = findFreshAgentLeaf(await harness.getPaneLayout(rehydratedTabId)) expect(finalLeaf?.content?.status).not.toBe('error') @@ -1433,49 +1426,59 @@ test.describe('Restore Contract Wall (P0.1)', () => { expect(create.resumeSessionId ?? create.sessionRef?.sessionId, JSON.stringify(create)).toBe(originalSessionId) } - // LIVENESS (council follow-up, PR #562/#563 close-out): everything - // above proves the durable IDENTITY survived, but a mutation where the - // attach arm sets pane status and never wires the event stream (the - // literal historical P0.2 bug shape) would leave every assertion above - // green over a dead socket. Mirror lane test 1's post-restart turn on - // THIS ordering (SIGKILL restart THEN reload, attach-resolved): send a - // second turn and require the assistant reply to ROUND-TRIP through - // the live event stream into the freshAgent slice (turns[] is fed by - // wire events -- freshAgent.assistant folds, fresh-agent-ws.ts -- so a - // dead socket can never grow it), plus the fixture transcript on disk - // gaining the second user line (the server->sidecar leg). - const countFoldedAssistantTurns = async (): Promise => { - const sessions = (await harness.getState())?.freshAgent?.sessions ?? {} - return Object.values(sessions).reduce( - (n: number, s: any) => - n + (s?.turns ?? []).filter((t: any) => t?.role === 'assistant').length, - 0, - ) - } - const assistantTurnsBefore = await countFoldedAssistantTurns() + // LIVENESS: after this SIGKILL restart and reload, send another turn + // through the same pane and require its unique answer in the rendered + // conversation. The normalized session cache's `turns` array is + // snapshot-only; assistant frames are folded into the transcript view, + // so the pane and durable JSONL are the behavior-level checks here. await sendFreshAgentTurn(page, harness, rehydratedTabId, 'wall freshclaude liveness turn') - await expect - .poll(countFoldedAssistantTurns, { timeout: 30_000 }) - .toBeGreaterThan(assistantTurnsBefore) + await expect( + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_LIVENESS_REPLY', { exact: true }), + ).toBeVisible({ timeout: 30_000 }) + const claudeProjectSlug = projectDir.replace(/[^A-Za-z0-9]/g, '-') const transcriptFile = path.join( info.homeDir, '.claude', 'projects', - '-fixture', + claudeProjectSlug, `${originalSessionId}.jsonl`, ) - const transcriptLines = (await fs.readFile(transcriptFile, 'utf8')) - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) + let transcriptLines: any[] = [] + await expect.poll(async () => { + const raw = await fs.readFile(transcriptFile, 'utf8').catch(() => '') + transcriptLines = raw.trim().split('\n').filter(Boolean).flatMap((line) => { + try { return [JSON.parse(line)] } catch { return [] } + }) + const currentUserTexts = transcriptLines + .filter((line: any) => line.type === 'user') + .map((line: any) => line.message?.content?.[0]?.text) + const currentAssistantTexts = transcriptLines + .filter((line: any) => line.type === 'assistant') + .flatMap((line: any) => line.message?.content ?? []) + .filter((block: any) => block?.type === 'text') + .map((block: any) => block.text) + return currentUserTexts.includes('wall freshclaude turn') + && currentUserTexts.includes('wall freshclaude liveness turn') + && currentAssistantTexts.includes('RESTORE_WALL_REPLY') + && currentAssistantTexts.includes('RESTORE_WALL_LIVENESS_REPLY') + }, { + timeout: 20_000, + message: 'the same durable Claude transcript contains both turns and replies', + }).toBe(true) const userTexts = transcriptLines .filter((l: any) => l.type === 'user') .map((l: any) => l.message?.content?.[0]?.text) expect(userTexts).toEqual( expect.arrayContaining(['wall freshclaude turn', 'wall freshclaude liveness turn']), ) - expect(transcriptLines.filter((l: any) => l.type === 'assistant').length).toBeGreaterThanOrEqual(2) + const assistantTexts = transcriptLines + .filter((line: any) => line.type === 'assistant') + .flatMap((line: any) => line.message?.content ?? []) + .filter((block: any) => block?.type === 'text') + .map((block: any) => block.text) + expect(assistantTexts).toEqual( + expect.arrayContaining(['RESTORE_WALL_REPLY', 'RESTORE_WALL_LIVENESS_REPLY']), + ) } finally { await context.close().catch(() => {}) await server.stop() @@ -1621,6 +1624,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { const codexArgLog = path.join(sharedRoot, 'codex-argv.jsonl') const opencodeArgLog = path.join(sharedRoot, 'opencode-argv.jsonl') const opencodeAuditLog = path.join(sharedRoot, 'opencode-audit.jsonl') + const claudeSimLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const binDir = path.join(sharedRoot, 'bin') const fakeClaudePath = await installFakeCli(FAKE_CLAUDE_CLI_SOURCE, 'claude', binDir) const fakeCodexPath = await installDualRoleCodex(binDir, codexArgLog) @@ -1633,7 +1637,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { CODEX_CMD: fakeCodexPath, FAKE_CODEX_ARGV_LOG: codexArgLog, OPENCODE_CMD: fakeOpencodePath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, + ...claudeSimulatorServerEnv({ ledgerPath: claudeSimLedger }), }, setupHome: async (homeDir) => { await seedWallConfig({ providers: ['claude', 'codex', 'opencode'], freshAgent: true })( @@ -2368,8 +2372,12 @@ test.describe('Restore Contract Wall (P0.1)', () => { await fs.mkdir(projectDir, { recursive: true }) const { server, harness, context, page } = await bootWall(browser, { env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_HOLD_TURN: '1', + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { + rules: [{ on: 'msg:prompt', match: { text: 'busy turn' }, emit: [{ kind: 'hang' }] }], + }, + }), }, setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) @@ -2381,7 +2389,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) await createFreshclaudePane(page, harness, projectDir) - // Send a turn that NEVER completes (HOLD_TURN) -> status running. + // Send a turn that NEVER completes (the simulator hangs this prompt) -> status running. const paneRoot = page.locator('[data-context="fresh-agent"]').last() await expect .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabId))?.content?.status, { diff --git a/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts b/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts index bd44cb606..7930a7676 100644 --- a/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts +++ b/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts @@ -13,7 +13,6 @@ import { } from '../helpers/unified-agent-names.js' import { bootJourney, - collectFreshAgentSessionIds, connect, findPaneIdOfSession, postJsonRetry429, @@ -245,7 +244,6 @@ test.describe('cross-mode unified agent names', () => { // to the unified store or the generator. const journey = await bootJourney('freshclaude', { env: { - FRESHELL_FAKE_PROVIDER: 'kilroy', KILROY_ENABLED: '1', GEMINI_CMD: path.join(REPO_ROOT, 'test', 'e2e-browser', 'fixtures', 'providers', 'fake-gemini.mjs'), }, @@ -356,24 +354,55 @@ test.describe('cross-mode unified agent names', () => { 'an excluded provider pane never carries a unified nameRef', ).toBeUndefined() - // (4) The excluded kilroy runtime: kilroy panes keep their existing - // naming behavior and never enter the unified store (no session-names - // record is created for a kilroy session, and no generation arms). - const kilroy = await createNamedAgent(harness, 'freshclaude', { entry: 'ui', page, server: journey.server }) + // (4) Create a real Kilroy pane through the supported REST agent path. + // FRESHELL_FAKE_PROVIDER only selected the retired protocol fake's + // flavor; the production sidecar uses the pane's actual sessionType. + const kilroyResponse = await postJsonRetry429( + `${journey.server.info.baseUrl}/api/tabs`, + { agent: 'kilroy', cwd: journey.server.projectDir }, + journey.server.info.token, + ) + const kilroyText = await kilroyResponse.text() + expect(kilroyResponse.ok, kilroyText).toBe(true) + const kilroyData = (JSON.parse(kilroyText) as { data: { tabId: string; paneId: string } }).data + await harness.waitForTabCount(5, 15_000) + await expect.poll(async () => ( + await paneContentFromLayoutSnapshot(journey.server, kilroyData.tabId, kilroyData.paneId) + )?.sessionType ?? null, { timeout: 15_000 }).toBe('kilroy') + const kilroyContent = await paneContentFromLayoutSnapshot(journey.server, kilroyData.tabId, kilroyData.paneId) + expect(kilroyContent?.namingHandle, 'Kilroy creates no unified pending-name handle').toBeUndefined() + expect(kilroyContent?.nameRef, 'Kilroy creates no unified session-name reference').toBeUndefined() + + const kilroy = { tabId: kilroyData.tabId, paneId: kilroyData.paneId, nameRef: null } + await page.locator(`[data-context="tab"][data-tab-id="${kilroy.tabId}"]`).click() + const kilroyPane = page.locator( + `[data-pane-id="${kilroy.paneId}"] [data-context="fresh-agent"], [data-pane-id="${kilroy.paneId}"][data-context="fresh-agent"]`, + ).first() + await expect(kilroyPane).toBeVisible({ timeout: 15_000 }) await sendFirstMessage(page, harness, kilroy, 'freshclaude', 'Kilroy keeps its naming') - await page.waitForTimeout(4_000) - const state = await harness.getState() - const kilroySessionIds = collectFreshAgentSessionIds(state) - expect(kilroySessionIds.length).toBeGreaterThan(0) - for (const sessionId of kilroySessionIds) { - const read = await fetch(`${journey.server.info.baseUrl}/api/session-names/read`, { - method: 'POST', - headers: { 'x-auth-token': journey.server.info.token, 'content-type': 'application/json' }, - body: JSON.stringify({ refs: [{ kind: 'session', provider: 'claude', sessionId } as SessionNameRef] }), - }) - const payload = await read.json() as { names: unknown[] } - expect(payload.names, `kilroy session ${sessionId} never enters the unified store`).toEqual([]) - } + await expect(kilroyPane).toContainText('YouKilroy keeps its naming', { timeout: 30_000 }) + + let kilroySessionId = '' + await expect.poll(async () => { + const content = await paneContentFromLayoutSnapshot(journey.server, kilroy.tabId, kilroy.paneId) + const sessionRef = content?.sessionRef as { provider?: unknown; sessionId?: unknown } | undefined + if (content?.sessionType !== 'kilroy' || sessionRef?.provider !== 'claude' + || typeof sessionRef.sessionId !== 'string') return '' + kilroySessionId = sessionRef.sessionId + return kilroySessionId + }, { + timeout: 15_000, + message: 'the real Kilroy sidecar materialized a Claude sessionRef after the first turn', + }).not.toBe('') + const read = await fetch(`${journey.server.info.baseUrl}/api/session-names/read`, { + method: 'POST', + headers: { 'x-auth-token': journey.server.info.token, 'content-type': 'application/json' }, + body: JSON.stringify({ refs: [{ kind: 'session', provider: 'claude', sessionId: kilroySessionId } as SessionNameRef] }), + }) + const readText = await read.text() + expect(read.ok, readText).toBe(true) + const payload = JSON.parse(readText) as { names: unknown[] } + expect(payload.names, `real Kilroy session ${kilroySessionId} never enters the unified store`).toEqual([]) await context.close() } finally { await journey.cleanup() diff --git a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts index 9b1909e05..3789f7a3a 100644 --- a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts +++ b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts @@ -34,11 +34,10 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --- helpers (per-spec copies; donors named inline) ------------------------ /** Donor: pane-ledger-restart-rust.spec.ts:29 */ @@ -313,7 +312,7 @@ test.describe('wave-A cross-lane interactions', () => { test('A2xA3: one restart restores BOTH claude identity stores with no cross-talk', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wavea-a2a3-')) - // The fake sidecar mints a RANDOM canonical UUID per process (council + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static 44444444-... default was collision-blind), // so the fresh-agent durable id is CAPTURED from the run below. let FRESH_DURABLE = '' @@ -321,14 +320,12 @@ test.describe('wave-A cross-lane interactions', () => { try { const fakeClaude = await installFakeCli(path.join(sharedRoot, 'bin'), 'claude', 'fake-claude-cli.mjs') const argvLog = path.join(sharedRoot, 'claude-argv.jsonl') - const sidecarLog = path.join(sharedRoot, 'sidecar-requests.jsonl') const seed = seedConfig({ providers: ['claude'], freshAgent: true }) const server = new RustServer({ env: { CLAUDE_CMD: fakeClaude, FAKE_CLAUDE_ARGV_LOG: argvLog, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLog, + ...claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), }, setupHome: async (homeDir: string) => { capturedHome = homeDir @@ -362,8 +359,8 @@ test.describe('wave-A cross-lane interactions', () => { }, { timeout: 15_000 }) .not.toBe('') - // Identity store 2 (A2): freshclaude pane -> sidecar cliSessionId index. - // Gate on the canonical-UUID SHAPE, then capture what this run minted. + // Identity store 2 (A2): freshclaude pane -> create-time durable UUID. + // Gate on the canonical-UUID SHAPE, then capture the create acknowledgement. await createFreshclaudePane(page, sharedRoot) await expect .poll(async () => { @@ -375,6 +372,16 @@ test.describe('wave-A cross-lane interactions', () => { const c = freshAgentLeaf(await harness.getPaneLayout(tabId))?.content FRESH_DURABLE = (c?.sessionRef?.sessionId ?? c?.resumeSessionId) as string } + const freshContent = freshAgentLeaf(await harness.getPaneLayout(tabId))!.content! + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === freshContent.createRequestId, + ) + expect(createdFrame?.sessionRef, 'freshclaude create acknowledges its durable UUID before any prompt') + .toEqual({ provider: 'claude', sessionId: FRESH_DURABLE }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === FRESH_DURABLE)?.ownerKind ?? null + }, { message: 'the freshclaude UUID is registered as a fresh-agent runtime owner' }).toBe('fresh-agent') expect(terminalSession).not.toBe(FRESH_DURABLE) // FIXTURE REALISM (reconcile adoption): real claude writes @@ -399,6 +406,7 @@ test.describe('wave-A cross-lane interactions', () => { ) // ── ONE abrupt restart; both stores must restore, independently. ── + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) @@ -416,25 +424,20 @@ test.describe('wave-A cross-lane interactions', () => { const rowsAfter = await readClaudeBindingRows(ledgerDir) expect(rowsAfter.some((r) => r.sessionId === terminalSession)).toBe(true) - // A2 path: the fresh-agent pane resumed in place — server-side proof - // is the sidecar create carrying a resume boot (UUID or transcript - // path shape, claude.rs decision table). - await expect - .poll(async () => { - const log = await fs.readFile(sidecarLog, 'utf-8').catch(() => '') - return log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === FRESH_DURABLE || - e.msg.boot.resume.endsWith(`/${FRESH_DURABLE}.jsonl`)), - ) - }, { timeout: 30_000 }) - .toBe(true) + // A2 path: the fresh-agent pane reattaches under the durable UUID. + // A surviving writer does not need to start a second SDK query; the + // post-restart attach and ready-replayed owner are the server evidence. + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === FRESH_DURABLE) ?? null + }, { timeout: 30_000, message: 'the fresh-agent pane attaches using its create-time UUID' }) + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: FRESH_DURABLE } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === FRESH_DURABLE)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection reports the fresh-agent owner' }).toBe('fresh-agent') await expect .poll(async () => { const c = freshAgentLeaf(await harness.getPaneLayout(tabId))?.content diff --git a/test/fixtures/claude-cli-sim/local-commands.mjs b/test/fixtures/claude-cli-sim/local-commands.mjs index 6a1dbb16c..ebf732c3d 100644 --- a/test/fixtures/claude-cli-sim/local-commands.mjs +++ b/test/fixtures/claude-cli-sim/local-commands.mjs @@ -71,8 +71,11 @@ export async function runLocalCommand(cli, turn, { name, args }) { await cli.sleep(T.localCommand) cli.emit(cli.systemInit()) replay(commandText, cmd.uuid) - cli.transcript.append({ type: 'user', uuid: cmd.uuid, message: { role: 'user', content: commandText } }) + // T16: mode metadata exists by the result; the local-command records + // reach B in the normal buffered write after that boundary. + cli.transcript.appendVerbatim({ type: 'mode', mode: 'normal', sessionId: cli.sessionId }) cli.transcript.flush() + recordCommand('') cli.modelTurnsSinceCompact = 0 return localResult('') } diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 2eca574a9..f1fbd0cfc 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -72,8 +72,9 @@ // 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) +// applyFlagSettings, with its arguments), +// `emission` (program steps), and `result` +// (a turn's result reached the SDK stream) import fs from 'node:fs' import path from 'node:path' @@ -359,6 +360,9 @@ class SimulatedCli { emit(message) { if (this.dead || this.closing) return + // Fault injection for consumers that must follow an init identity even + // when the reset announcement is missing. The default trace is intact. + if (message.type === 'conversation_reset' && this.env.FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET === '1') return this.channel.push({ ...message, uuid: message.uuid ?? randomUUID(), session_id: message.session_id ?? this.sessionId }) } @@ -472,6 +476,9 @@ class SimulatedCli { if (resume) { const parentFile = findTranscript(this.configDir, resume) if (!parentFile) return { message: `No conversation found with session ID: ${resume}` } + // T5 path: the path selects a saved file; it never becomes the native + // conversation id or a second transcript filename after a resume. + if (!forkSession) this.sessionId = path.basename(parentFile, '.jsonl') const parentLines = readTranscriptLines(parentFile) let prefix try { prefix = forkPrefix(parentLines, resumeSessionAt) } catch (error) { return { message: error.message } } @@ -1004,6 +1011,12 @@ class SimulatedCli { ...(turn.cmd?.uuid && subtype === 'success' ? { user_message_uuid: turn.cmd.uuid } : {}), } if (isError || subtype !== 'success') result.usage = usage(0, 0) + this.ledger('result', { + sessionId: this.sessionId, + uuid: turn.cmd?.uuid ?? null, + subtype, + isError, + }) this.emit(result) await this.endCommand(turn, lifecycle ?? (success ? 'completed' : 'cancelled')) } diff --git a/test/fixtures/claude-cli-sim/transcript.mjs b/test/fixtures/claude-cli-sim/transcript.mjs index 3497055d1..16b7595d3 100644 --- a/test/fixtures/claude-cli-sim/transcript.mjs +++ b/test/fixtures/claude-cli-sim/transcript.mjs @@ -26,8 +26,11 @@ export function projectSlug(cwd) { return String(cwd).replace(/[^A-Za-z0-9]/g, '-') } -/** Locate `.jsonl` in any project directory, or null. */ +/** Locate a saved conversation by native id or absolute transcript path. */ export function findTranscript(configDir, sessionId) { + if (typeof sessionId === 'string' && path.isAbsolute(sessionId)) { + try { return fs.statSync(sessionId).isFile() ? sessionId : null } catch { return null } + } const projects = path.join(configDir, 'projects') let dirs try { dirs = fs.readdirSync(projects, { withFileTypes: true }) } catch { return null } diff --git a/test/fixtures/fresh-agent/claude-create-defaults.json b/test/fixtures/fresh-agent/claude-create-defaults.json new file mode 100644 index 000000000..6c6eb2bf7 --- /dev/null +++ b/test/fixtures/fresh-agent/claude-create-defaults.json @@ -0,0 +1,83 @@ +[ + { + "name": "freshclaude registry defaults", + "sessionType": "freshclaude", + "settings": {}, + "expected": { "model": "opus[1m]", "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "kilroy has independent saved defaults", + "sessionType": "kilroy", + "settings": { + "freshAgent": { + "defaultPlugins": ["/workspace/plugin"], + "providers": { + "freshclaude": { "effort": "low" }, + "kilroy": { "modelSelection": { "kind": "exact", "modelId": "claude-sonnet-custom" }, "defaultPermissionMode": "plan", "effort": "max" } + } + } + }, + "expected": { "model": "claude-sonnet-custom", "modelSelection": { "kind": "exact", "modelId": "claude-sonnet-custom" }, "permissionMode": "plan", "effort": "max", "plugins": ["/workspace/plugin"] } + }, + { + "name": "terminal model does not set a fresh pane default", + "sessionType": "freshclaude", + "settings": { "codingCli": { "providers": { "claude": { "model": "terminal-model", "permissionMode": "default" } } } }, + "expected": { "model": "opus[1m]", "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "saved static effort is trimmed", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { "effort": " xhigh ", "defaultPermissionMode": "default" } } } }, + "expected": { "model": "opus[1m]", "permissionMode": "default", "effort": "xhigh", "plugins": [] } + }, + { + "name": "probed model clamps to its saved levels", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": ["low", "max"] }, + "effort": "medium" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": ["low", "max"], "permissionMode": "bypassPermissions", "effort": "low", "plugins": [] } + }, + { + "name": "probed model with no effort levels omits effort", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": [] }, + "effort": "high" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": [], "permissionMode": "bypassPermissions", "plugins": [] } + }, + { + "name": "stale levels from a different model are ignored", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "old-model", "levels": ["low"] }, + "effort": "not-a-level" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "probed model retains a supported default effort even when it is not first", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": ["max", "high"] } + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": ["max", "high"], "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "static model ignores stamped levels", + "sessionType": "kilroy", + "settings": { "freshAgent": { "providers": { "kilroy": { + "modelSelection": { "kind": "exact", "modelId": "opus[1m]" }, + "modelEffortLevels": { "modelId": "opus[1m]", "levels": [] }, + "effort": "max" + } } } }, + "expected": { "model": "opus[1m]", "modelSelection": { "kind": "exact", "modelId": "opus[1m]" }, "modelEffortLevels": [], "permissionMode": "bypassPermissions", "effort": "max", "plugins": [] } + } +] diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 95fb1cfad..f9baf0f30 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -169,6 +169,17 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(r.observations.afterRefusals.files.filter((f: string) => /F2|F3|Z/.test(path.basename(f)))).toEqual([]) }, SCENARIO_TIMEOUT) + it('T5 path: a transcript path resumes after its cwd is removed, keeping its UUID and original saved file', async () => { + const r = await real('resume-path') + expect(r.observations.pathBoot).toEqual({ ok: true }) + expect(r.observations.pathResult.sessionIds).toEqual(['P']) + expect(r.observations.pathResult.shape).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'user', uuid: 'u1' }), + expect.objectContaining({ type: 'user', uuid: 'u2' }), + ])) + expect(r.observations.afterResume.files.map((file: string) => path.basename(file))).toEqual(['P.jsonl']) + }, SCENARIO_TIMEOUT) + it('T6: a lazy fork boots with no prompt, writes nothing until its first prompt, can be re-spawned before it, then holds the parent prefix with its uuids', async () => { const r = await real('fork') const { parent, forkBeforePrompt, forkAfterPrompt } = r.observations @@ -388,7 +399,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(messagesOf(r, 'S').find((m) => m.subtype === 'compact_boundary')?.compact_metadata?.trigger).toBe('manual') }, SCENARIO_TIMEOUT) - it('T16: /clear sends conversation_reset stamped with the old id, then the new session id arrives in the next init and the result; its transcript exists at once and resumes', async () => { + it('T16: /clear sends the old-id reset then new-id init/result; B has mode metadata at once, flushes only its local command, and resumes', async () => { const r = await real('clear') const events = eventsOf(r, 'S') const reset = messagesOf(r, 'S').find((m) => m.type === 'conversation_reset') @@ -401,6 +412,16 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(results(r, 'S').map((m) => m.session_id)).toEqual(['A', 'B', 'B']) expect(lifecycleStates(messagesOf(r, 'S'), 'u2')).toEqual(['queued', 'started', 'completed']) expect(r.observations.afterClear.files.map((f: string) => path.basename(f)).sort()).toEqual(['A.jsonl', 'B.jsonl']) + expect(r.observations.afterClear.hasModeMetadata).toBe(true) + expect(r.observations.afterClearFlush.lineTypes).toEqual(['mode', 'user', 'user', 'system']) + expect(r.observations.afterClearFlush.messages).toEqual([ + { type: 'user', isMeta: true, content: expect.stringContaining('') }, + { type: 'user', isMeta: false, content: expect.stringContaining('/clear') }, + ]) + expect(JSON.stringify(r.observations.afterClearFlush.messages)).not.toContain('hello') + expect(r.observations.afterClearFlush.localCommands).toEqual([ + { subtype: 'local_command', content: '', isMeta: false }, + ]) expect(eventsOf(r, 'resumeB').some((e) => e.k === 'init-resolved')).toBe(true) }, SCENARIO_TIMEOUT) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 12d1c8297..bcbdede8e 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -421,6 +421,37 @@ const SCENARIOS = { await s.close() }, + /** T5: a saved transcript can resume by path after its original cwd is gone. */ + async 'resume-path'(ctx) { + const P = ctx.uuid('P') + const seed = ctx.open('seed', { options: { sessionId: P } }) + seed.push('seed the path-resumed history', 'u1') + await seed.waitForResults(1) + await seed.waitForCount(isStateIdle, 1, 5000, 'idle') + ctx.need(await ctx.pollTranscript(P, 5000, 2), 'seed history flushed') + await seed.close() + const file = path.join(ctx.fixture.claudeConfigDir, (await ctx.files()).find((f) => f.endsWith(`${P}.jsonl`))) + const replacement = path.join(ctx.fixture.root, 'replacement') + await fs.mkdir(replacement) + await fs.rm(ctx.fixture.cwd, { recursive: true }) + const resumed = ctx.open('path', { options: { resume: file, cwd: replacement } }) + const init = await resumed.init() + ctx.observe('pathBoot', { ok: init.ok, ...(init.message ? { message: init.message } : {}) }) + if (init.ok) { + resumed.push('continue the saved history', 'u2') + await resumed.waitForResults(1) + await resumed.waitForCount(isStateIdle, 1, 5000, 'idle') + const saved = ctx.need(await ctx.pollTranscript(P, 5000, 1, (lines) => + lines.some((line) => line.uuid === ctx.uuidOf('u2')) && lines.filter((line) => line.type === 'assistant').length >= 2), 'continued history flushed') + ctx.observe('pathResult', { + sessionIds: resumed.messages((message) => message.type === 'result').map((message) => message.session_id), + shape: transcriptShape(saved), + }) + } + await resumed.close() + ctx.observe('afterResume', { files: await ctx.files() }) + }, + /** T5 T6: lazy fork with a guard, re-spawned before its prompt; then the three boot refusals. */ async fork(ctx) { const P = ctx.uuid('P') @@ -670,7 +701,23 @@ const SCENARIOS = { const inits = s.messages((m) => m.type === 'system' && m.subtype === 'init') const B = ctx.need(inits.at(-1)?.session_id, 'post-reset session id') ctx.label(B, 'B') - ctx.observe('afterClear', { files: await ctx.files() }) + const cleared = ctx.need(await ctx.fixture.readTranscript(B), 'post-reset transcript exists') + ctx.observe('afterClear', { + files: await ctx.files(), + hasModeMetadata: cleared.some((row) => row.type === 'mode' && row.mode === 'normal'), + }) + const flushed = ctx.need(await ctx.pollTranscript(B, 5000, 1, (rows) => + rows.some((row) => row.type === 'user' && typeof row.message?.content === 'string' + && row.message.content.includes('/clear')) + && rows.some((row) => row.type === 'system' && row.subtype === 'local_command')), + 'post-reset local-command rows flushed') + ctx.observe('afterClearFlush', { + lineTypes: flushed.map((row) => row.type), + messages: flushed.filter((row) => row.type === 'user' || row.type === 'assistant') + .map((row) => ({ type: row.type, isMeta: row.isMeta === true, content: row.message?.content })), + localCommands: flushed.filter((row) => row.type === 'system' && row.subtype === 'local_command') + .map((row) => ({ subtype: row.subtype, content: row.content, isMeta: row.isMeta === true })), + }) s.push('after clear', 'u3') await s.waitForResults(3) await sleep(500) @@ -858,12 +905,12 @@ export async function runScenario(target, name, { repoRoot = DEFAULT_REPO_ROOT } return value }, files: () => fixture.transcripts(), - /** Poll until `.jsonl` holds at least `minLines` lines; returns the lines or null. */ - async pollTranscript(id, ms, minLines = 1) { + /** Poll until the saved lines satisfy the requested count and optional predicate. */ + async pollTranscript(id, ms, minLines = 1, predicate) { const started = Date.now() while (Date.now() - started <= ms) { const lines = await fixture.readTranscript(id) - if (lines && lines.length >= minLines) { + if (lines && lines.length >= minLines && (!predicate || predicate(lines))) { trace.add({ k: 'transcript-seen', id, lines: lines.length }) return lines } diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 3740a1328..029a92238 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -729,12 +729,13 @@ describe('the real sidecar over the simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE) it('creates a session, runs a turn and reports its result through the sidecar protocol', async () => { const { home, frames, until, send } = await startSidecar() - send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'r1', cwd: home.cwd, permissionMode: 'bypassPermissions' }) + const sessionId = randomUUID() + send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'r1', sessionId, 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 + expect(frames.find((f) => f.type === 'created')).toMatchObject({ sessionId }) 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.session.init')).toMatchObject({ sessionId, cliSessionId: sessionId }) 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-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs index 2ed16a743..dad524c41 100644 --- a/test/integration/providers/claude-freshell-mcp.mjs +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -44,6 +44,7 @@ const receipt = { verdict: 'FAIL', sdkVersion: pinned.sdkVersion, bundledCliVersion: pinned.cliVersion, + sendRefusals: [], cliArgvHasMcpConfig: false, cliArgvHasToken: null, probe: null, @@ -87,6 +88,7 @@ try { let frame try { frame = JSON.parse(line) } catch { return } frames.push(frame) + if (frame.type === 'sdk.send.rejected') receipt.sendRefusals.push(frame) for (const waiter of [...waiters]) if (waiter.match(frame)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(frame) } }) const waitFor = (match, ms, what) => new Promise((resolve, reject) => { @@ -99,14 +101,16 @@ try { failureStage = 'create' const requestId = randomUUID() + const nativeSessionId = randomUUID() write({ - type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, + type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions', hostedMcp: false, freshellMcp: { command: process.execPath, args: [PROBE_SERVER, reportFile] }, freshellContext: context, }) const created = await waitFor(frame => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 45_000, 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') failureStage = 'cli argv' const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') @@ -115,7 +119,7 @@ try { receipt.cliArgvHasToken = argv.some(arg => arg.includes(token)) failureStage = 'turn' - write({ type: 'send', sessionId: created.sessionId, text: PROMPT }) + write({ type: 'send', sessionId: created.sessionId, uuid: randomUUID(), text: PROMPT }) const init = await waitFor(frame => frame.type === 'sdk.session.init', 45_000, 'the turn init') // The sidecar forwards the CLI's tool list as `{ name }` objects. receipt.toolNames = (init.tools ?? []).map(tool => (typeof tool === 'string' ? tool : tool?.name)).filter(name => String(name).startsWith('mcp__')) diff --git a/test/integration/providers/claude-freshell-mcp.test.ts b/test/integration/providers/claude-freshell-mcp.test.ts index 617899eb9..915f6ee14 100644 --- a/test/integration/providers/claude-freshell-mcp.test.ts +++ b/test/integration/providers/claude-freshell-mcp.test.ts @@ -52,6 +52,7 @@ describe('Freshell MCP in a Claude sidecar against the pinned CLI and a local pr expect(receipt, `${lines[0]}\n${stderr}`).toMatchObject({ sdkVersion: PINNED_SDK_VERSION, bundledCliVersion: PINNED_CLI_VERSION, + sendRefusals: [], cliArgvHasMcpConfig: true, cliArgvHasToken: false, probeToolListed: true, diff --git a/test/integration/providers/claude-live-frames.mjs b/test/integration/providers/claude-live-frames.mjs index 289417bff..f9a8bc802 100644 --- a/test/integration/providers/claude-live-frames.mjs +++ b/test/integration/providers/claude-live-frames.mjs @@ -160,9 +160,11 @@ try { const resultCount = () => frames.filter((frame) => frame.type === 'sdk.result').length const requestId = randomUUID() - write({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions' }) + const nativeSessionId = randomUUID() + write({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions' }) const created = await waitFor((frame) => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') const sessionId = created.sessionId poller = pollTranscripts() @@ -177,7 +179,7 @@ try { await waitFor(() => resultCount() > results, `the ${name} result`) scenarios[name].end = frames.length } - const send = (text) => write({ type: 'send', sessionId, text }) + const send = (text) => write({ type: 'send', sessionId, uuid: randomUUID(), text }) const configure = async (requestId, settings) => { write({ type: 'configure', sessionId, requestId, settings }) const answer = await waitFor((frame) => frame.type === 'sdk.configured' && frame.requestId === requestId, `configure ${requestId}`) diff --git a/test/integration/providers/claude-live-frames.test.ts b/test/integration/providers/claude-live-frames.test.ts index c557430eb..3dccfbf2d 100644 --- a/test/integration/providers/claude-live-frames.test.ts +++ b/test/integration/providers/claude-live-frames.test.ts @@ -80,6 +80,7 @@ const isAnnouncement = (frame: Frame) => frame.type === 'sdk.session.changed' && describe(`the Claude sidecar's live frames over the pinned CLI ${PINNED_CLI_VERSION} and a local provider`, () => { it('runs every scenario on the pinned SDK and CLI, and the sidecar then exits cleanly', () => { + expect(ofType(all(), 'sdk.send.rejected'), 'native command acceptance').toEqual([]) expect(receipt.failure, stderr.slice(-2000)).toBeUndefined() expect(receipt).toMatchObject({ sdkVersion: PINNED_SDK_VERSION, bundledCliVersion: PINNED_CLI_VERSION, fixtureRemoved: true, sidecarExit: { code: 0, signal: null } }) expect(Object.keys(receipt.scenarios)).toEqual(['text', 'edit', 'agent', 'retry', 'compact-ok', 'more', 'compact-fail', 'plan', 'configure-stated']) @@ -160,18 +161,28 @@ describe(`the Claude sidecar's live frames over the pinned CLI ${PINNED_CLI_VERS .toEqual([['warn', 1, 529], ['warn', 2, 529]]) }) - it('/compact: compacting, running again, the manual boundary and its transcript lines, then the result, with no notice', () => { + it('/compact: compacting, its successful null status, the manual boundary and its transcript lines, then the result, with no notice', () => { const list = frames('compact-ok') - const order = list.filter((frame) => ['sdk.status', 'sdk.compact_boundary', 'sdk.result', 'sdk.notice'].includes(frame.type)).map((frame) => frame.status ?? frame.trigger ?? frame.type) - expect(order).toEqual(['compacting', 'running', 'manual', 'sdk.result']) + const order = list.filter((frame) => ['sdk.status', 'sdk.compact_boundary', 'sdk.result', 'sdk.notice'].includes(frame.type)) + expect(order).toEqual([ + expect.objectContaining({ type: 'sdk.status', status: 'compacting' }), + expect.objectContaining({ type: 'sdk.status', status: null, compactResult: 'success' }), + expect.objectContaining({ type: 'sdk.compact_boundary', trigger: 'manual' }), + expect.objectContaining({ type: 'sdk.result', result: 'success' }), + ]) const boundary = list.findIndex((frame) => frame.type === 'sdk.compact_boundary') expect(list[boundary + 1]).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript' }) }) - it('a /compact whose summary request fails: a COMPACTION_FAILED notice with the CLI\'s reason, running again, the result, no error frame', () => { + it('a /compact whose summary request fails: its failed null status, a COMPACTION_FAILED notice with the CLI\'s reason, then the result, no error frame', () => { const list = frames('compact-fail') - const order = list.filter((frame) => ['sdk.status', 'sdk.notice', 'sdk.result', 'sdk.error'].includes(frame.type)).map((frame) => frame.status ?? frame.code ?? frame.type) - expect(order).toEqual(['compacting', 'COMPACTION_FAILED', 'running', 'sdk.result']) + const order = list.filter((frame) => ['sdk.status', 'sdk.notice', 'sdk.result', 'sdk.error'].includes(frame.type)) + expect(order).toEqual([ + expect.objectContaining({ type: 'sdk.status', status: 'compacting' }), + expect.objectContaining({ type: 'sdk.status', status: null, compactResult: 'failed', compactError: expect.stringContaining('API Error: 400') }), + expect.objectContaining({ type: 'sdk.notice', code: 'COMPACTION_FAILED' }), + expect.objectContaining({ type: 'sdk.result', result: 'success' }), + ]) expect(ofType(list, 'sdk.notice')[0]).toMatchObject({ level: 'error', message: expect.stringMatching(/^Error during compaction: API Error: 400/) }) expect(receipt.logRecords.find((record) => record.code === 'COMPACTION_FAILED')).toMatchObject({ severity: 'error', error: expect.stringMatching(/^Error during compaction/) }) }) diff --git a/test/integration/providers/claude-permission-contract.mjs b/test/integration/providers/claude-permission-contract.mjs index 6348fcca1..2203bbb10 100644 --- a/test/integration/providers/claude-permission-contract.mjs +++ b/test/integration/providers/claude-permission-contract.mjs @@ -191,9 +191,11 @@ async function runScenario(name, spec) { }) const requestId = randomUUID() - send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'default' }) + const nativeSessionId = randomUUID() + send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'default' }) const created = await waitFor((frame) => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') sessionId = created.sessionId if (spec.plan) { send({ type: 'configure', sessionId, requestId: 'to-plan', settings: { permissionMode: 'plan' } }) @@ -205,7 +207,7 @@ async function runScenario(name, spec) { state.step = 0 const results = frames.filter((frame) => frame.type === 'sdk.result').length log('info', 'turn.start', { scenario: name, turn }) - send({ type: 'send', sessionId, text: `contract turn ${turn + 1}` }) + send({ type: 'send', sessionId, uuid: randomUUID(), text: `contract turn ${turn + 1}` }) await waitFor(() => frames.filter((frame) => frame.type === 'sdk.result').length > results, `the result of turn ${turn + 1}`) } // A status the CLI reports after a result still reaches the sidecar. diff --git a/test/integration/providers/claude-permission-contract.test.ts b/test/integration/providers/claude-permission-contract.test.ts index 2ef6fedf7..663ba4017 100644 --- a/test/integration/providers/claude-permission-contract.test.ts +++ b/test/integration/providers/claude-permission-contract.test.ts @@ -93,6 +93,7 @@ describe(`Claude permission suggestions and plan approvals over the pinned CLI $ 'remember-local', 'remember-session', 'shapes', 'plan-default', 'plan-accept-edits', 'plan-bypass', 'plan-file', 'plan-empty', ]) for (const name of Object.keys(receipt.scenarios)) { + expect(ofType(receipt.scenarios[name]!.frames, 'sdk.send.rejected'), `${name}: the native send was accepted`).toEqual([]) expect(scenario(name), name).toMatchObject({ fixtureRemoved: true, sidecarExit: { code: 0, signal: null } }) expect(ofType(scenario(name).frames, 'sdk.error'), name).toEqual([]) } diff --git a/test/integration/providers/claude-sdk-for-rust-runtime.mjs b/test/integration/providers/claude-sdk-for-rust-runtime.mjs new file mode 100644 index 000000000..3de757795 --- /dev/null +++ b/test/integration/providers/claude-sdk-for-rust-runtime.mjs @@ -0,0 +1,112 @@ +// Rust runtime tests use the production index.mjs over the contract simulator. +// This adapter records actual SDK options and subsequent sidecar input; it +// never emits a sidecar protocol frame. Faults stay at SDK method boundaries. +import fs from 'node:fs' +import path from 'node:path' +import { query as simulateQuery } from '../../fixtures/claude-cli-sim/query.mjs' + +function append(file, row) { + if (file) fs.appendFileSync(file, `${JSON.stringify(row)}\n`) +} + +let recording = false +function recordInput() { + if (recording) return + recording = true + let partial = '' + // Installed during query(), after production readline consumed create. + // Observe the same stream without replacing its consumer or its replies. + process.stdin.on('data', (chunk) => { + partial += String(chunk) + for (;;) { + const end = partial.indexOf('\n') + if (end < 0) break + const line = partial.slice(0, end) + partial = partial.slice(end + 1) + let request + try { request = JSON.parse(line) } catch { continue } + if (['configure', 'send', 'permission.respond', 'question.respond'].includes(request.type)) { + append(process.env.FRESHELL_TEST_CLAUDE_RESPOND_LOG, request) + } + if (request.type === 'interrupt' && process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG) { + fs.appendFileSync(process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG, `${request.sessionId}\n`) + } + } + }) +} + +function closeWhenGuardEnds(runtime) { + const guard = process.env.FRESHELL_TEST_CLAUDE_RUNTIME_GUARD + if (!guard) return + let watcher + let closed = false + const close = () => { + if (closed) return + closed = true + watcher?.close() + runtime.close() + } + const ended = () => !fs.existsSync(guard) || fs.existsSync(`${guard}.stop`) + try { + watcher = fs.watch(path.dirname(guard), { persistent: false }, () => { + if (ended()) close() + }) + watcher.unref() + watcher.on('error', close) + if (ended()) close() + } catch { + close() + } +} + +export function query(params) { + // Keep credentials and callbacks out of fixture diagnostics. + const { env, ...options } = params.options + append(process.env.FRESHELL_TEST_CLAUDE_SPAWN_LOG, { + pid: process.pid, + options: { ...options, envKeys: Object.keys(env ?? {}).sort() }, + }) + recordInput() + const runtime = simulateQuery(params) + // Rare SDK notices have no local-provider CLI recording. These explicit + // per-test SDK messages still pass through production index.mjs mapping. + const injected = JSON.parse(process.env.FRESHELL_TEST_CLAUDE_SDK_MESSAGES ?? '[]') + const messages = runtime[Symbol.asyncIterator].bind(runtime) + runtime[Symbol.asyncIterator] = async function* () { + let emitted = false + for await (const message of { [Symbol.asyncIterator]: messages }) { + append(process.env.FRESHELL_TEST_CLAUDE_SDK_LOG, message) + yield message + if (!emitted && message.type === 'assistant') { + emitted = true + for (const notice of injected) { + const injectedMessage = { session_id: message.session_id, ...notice } + append(process.env.FRESHELL_TEST_CLAUDE_SDK_LOG, injectedMessage) + yield injectedMessage + } + } + } + } + closeWhenGuardEnds(runtime) + const initialize = runtime.initializationResult.bind(runtime) + runtime.initializationResult = async () => { + const delay = Number(process.env.FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS ?? 0) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) + if (process.env.FRESHELL_TEST_CLAUDE_FAIL_CREATE === '1') throw new Error('Injected SDK initialization failure') + return initialize() + } + for (const [method, key] of [ + ['setModel', 'FRESHELL_TEST_CLAUDE_REJECT_MODEL'], + ['applyFlagSettings', 'FRESHELL_TEST_CLAUDE_REJECT_EFFORT'], + ]) { + const original = runtime[method].bind(runtime) + runtime[method] = async (value) => { + const requested = method === 'setModel' ? value : value?.effortLevel + if (process.env[key] !== undefined && requested === process.env[key]) { + throw new Error(`Injected SDK ${method} rejection: ${JSON.stringify(requested)}`) + } + return original(value) + } + } + return runtime +} diff --git a/test/integration/providers/claude-sdk-lease-runtime.mjs b/test/integration/providers/claude-sdk-lease-runtime.mjs new file mode 100644 index 000000000..bf2e6d6b7 --- /dev/null +++ b/test/integration/providers/claude-sdk-lease-runtime.mjs @@ -0,0 +1,41 @@ +// Lifecycle faults at the SDK query boundary for Rust lease integration tests. +// The production sidecar owns every protocol frame and response. +import fs from 'node:fs' +import { spawn } from 'node:child_process' +import { query as recordedQuery } from './claude-sdk-for-rust-runtime.mjs' + +export function query(params) { + let first = false + try { + fs.closeSync(fs.openSync(process.env.FRESHELL_TEST_CLAUDE_FIRST_LAUNCH, 'wx')) + first = true + } catch (error) { + if (error.code !== 'EEXIST') throw error + } + const runtime = recordedQuery(params) + if (first && process.env.FRESHELL_TEST_CLAUDE_DECOY === '1') { + // A tagged detached descendant ignores TERM so the lease expiry must + // confirm its SIGKILL sweep before allowing a replacement writer. + const child = spawn('sh', ['-c', 'trap "" TERM; echo armed; exec sleep 300 >/dev/null'], { + detached: true, + stdio: ['ignore', 'pipe', 'ignore'], + }) + child.stdout.once('data', () => { + child.stdout.destroy() + fs.appendFileSync(process.env.FRESHELL_CLAUDE_SIM_LEDGER, `${JSON.stringify({ + kind: 'decoy', pid: process.pid, childPid: child.pid, + })}\n`) + }) + child.unref() + } + const initialize = runtime.initializationResult.bind(runtime) + runtime.initializationResult = async () => { + if (first && process.env.FRESHELL_TEST_CLAUDE_CRASH_FIRST === '1') process.exit(1) + const gate = process.env.FRESHELL_TEST_CLAUDE_INITIALIZATION_GATE + while (first && gate && fs.existsSync(gate)) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return initialize() + } + return runtime +} diff --git a/test/setup/runtime-build-preflight.ts b/test/setup/runtime-build-preflight.ts new file mode 100644 index 000000000..6d6eb67b5 --- /dev/null +++ b/test/setup/runtime-build-preflight.ts @@ -0,0 +1,39 @@ +import type { TestProject } from 'vitest/node' +import { + RUNTIME_BUILD_MANIFEST_ENV, + hasPreparedRuntimeBuilds, + prepareRuntimeBuilds, + runtimeBuildRecipesForFiles, + type RuntimeBuildCommand, +} from '../../scripts/testing/runtime-build-preflight.js' + +type Project = { + config: { root: string; env?: NodeJS.ProcessEnv } + vitest: { state: { getPaths(): string[] } } + onTestsRerun(callback: () => void): void +} + +export async function setupRuntimeBuildPreflight(project: Project, options: { command?: RuntimeBuildCommand } = {}): Promise<(() => void) | undefined> { + const env = { ...process.env, ...project.config.env } + const selected = runtimeBuildRecipesForFiles(project.config.root, project.vitest.state.getPaths(), env) + if (selected.length === 0 || hasPreparedRuntimeBuilds(project.config.root, selected, env)) return + let prepared = prepareRuntimeBuilds(project.config.root, selected, { env, ...options }) + const projectEnv = project.config.env ??= {} + const inherited = projectEnv[RUNTIME_BUILD_MANIFEST_ENV] + projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = prepared.manifestPath + project.onTestsRerun(() => { + const next = prepareRuntimeBuilds(project.config.root, selected, { env, ...options }) + prepared.cleanup() + prepared = next + projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = prepared.manifestPath + }) + return () => { + prepared.cleanup() + if (inherited === undefined) delete projectEnv[RUNTIME_BUILD_MANIFEST_ENV] + else projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = inherited + } +} + +export default function setup(project: TestProject): Promise<(() => void) | undefined> { + return setupRuntimeBuildPreflight(project) +} diff --git a/test/unit/claude-sidecar/conversation-reset.test.ts b/test/unit/claude-sidecar/conversation-reset.test.ts new file mode 100644 index 000000000..42aedb8c0 --- /dev/null +++ b/test/unit/claude-sidecar/conversation-reset.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { startRealSidecar, type RealSidecar } from './harness.js' + +async function sendAndSettle(bridge: RealSidecar, sessionId: string, text: string) { + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text }) + await bridge.waitFor( + (frame) => frame.type === 'sdk.command' && frame.uuid === uuid && frame.state === 'completed', + `the completed ${text} command`, + ) + return uuid +} + +describe('conversation reset identity', () => { + it.each([false, true])('announces the actual post-clear session before its init (warm=%s)', async (warm) => { + const bridge = await startRealSidecar() + const durable = randomUUID() + const sessionId = await bridge.create({ sessionId: durable }) + if (warm) await sendAndSettle(bridge, sessionId, 'before clear') + + const beforeClear = bridge.frames.length + const clearUuid = await sendAndSettle(bridge, sessionId, '/clear') + const cleared = bridge.frames.slice(beforeClear) + const init = cleared.find((frame) => frame.type === 'sdk.session.init')! + expect(init.cliSessionId).not.toBe(durable) + const rekeys = cleared.filter((frame) => frame.type === 'sdk.session.rekeyed') + // T16: conversation_reset.new_conversation_id is a reset nonce. The next + // init supplies the actual durable session UUID, which must be followed. + expect(rekeys).toEqual([{ + type: 'sdk.session.rekeyed', sessionId, + newSessionId: init.cliSessionId, reason: 'conversation_reset', + }]) + expect(cleared.indexOf(rekeys[0])).toBeLessThan(cleared.indexOf(init)) + expect(cleared.find((frame) => frame.type === 'sdk.command' && frame.uuid === clearUuid && frame.state === 'completed')).toBeTruthy() + + // The sidecar's control identity survives the durable rekey. Its next + // prompt runs in the new conversation and creates no second rekey. + await sendAndSettle(bridge, sessionId, 'after clear') + const prompt = await bridge.waitForPrompt(warm ? 3 : 2) + expect(prompt.durable).toBe(init.cliSessionId) + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.rekeyed')).toHaveLength(1) + }) + + it('does not treat ordinary per-turn init frames as conversation resets', async () => { + const bridge = await startRealSidecar() + const durable = randomUUID() + const sessionId = await bridge.create({ sessionId: durable }) + await sendAndSettle(bridge, sessionId, 'first') + await sendAndSettle(bridge, sessionId, 'second') + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.init').map((frame) => frame.cliSessionId)).toEqual([durable, durable]) + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.rekeyed')).toEqual([]) + }) +}) diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts index fc9b98b32..b5d00757d 100644 --- a/test/unit/cli/action-capabilities.test.ts +++ b/test/unit/cli/action-capabilities.test.ts @@ -72,6 +72,50 @@ describe('standalone CLI capability contract', () => { }) }) + it.each([ + ['new-tab', 'claude'], + ['new-tab', 'kilroy'], + ['split-pane', 'claude'], + ['split-pane', 'kilroy'], + ])('%s sends %s fresh-agent options and canonical Claude resume identity', async (action, agent) => { + const bodies: Array<{ url: string; body: Record }> = [] + const server = createServer(async (request, response) => { + response.setHeader('content-type', 'application/json') + if (request.method === 'POST') { + let text = '' + for await (const chunk of request) text += String(chunk) + bodies.push({ url: request.url ?? '', body: JSON.parse(text) }) + response.end(JSON.stringify({ status: 'ok', data: { tabId: 't1', paneId: 'new-pane' } })) + } else if (request.url === '/api/tabs') { + response.end(JSON.stringify({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' })) + } else { + response.end(JSON.stringify({ panes: [{ id: 'p1', index: 0, kind: 'terminal' }] })) + } + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test server did not listen') + try { + const args = [action, '--agent', agent, '--model', 'catalog-model', '--effort', 'max', '--resume', '550e8400-e29b-41d4-a716-446655440000'] + if (action === 'split-pane') args.push('--target', 'p1') + const result = await runCli(args, `http://127.0.0.1:${address.port}`) + expect(result.code, result.stderr).toBe(0) + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ + url: action === 'new-tab' ? '/api/tabs' : '/api/panes/p1/split', + body: { + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + }, + }) + expect(bodies[0].body).not.toHaveProperty('resumeSessionId') + } finally { + server.close() + await once(server, 'close') + } + }) + it.each([ ['run', 'echo', 'blocked'], ['fresh-send', 'blocked'], diff --git a/test/unit/client/fresh-agent-pane-migration.test.ts b/test/unit/client/fresh-agent-pane-migration.test.ts index 97a1bb6cc..4412604b4 100644 --- a/test/unit/client/fresh-agent-pane-migration.test.ts +++ b/test/unit/client/fresh-agent-pane-migration.test.ts @@ -134,6 +134,38 @@ describe('fresh-agent pane migration', () => { expect((migrated as { sessionRef?: unknown }).sessionRef).toBeUndefined() }) + it('retains a canonical same-provider sessionRef when an existing pane has a recoverable restore error', () => { + const sessionId = '00000000-0000-4000-8000-000000000789' + const migrated = migrateLegacyFreshAgentContent({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-lost-durable', + status: 'idle', + sessionId: 'old-runtime-placeholder', + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + + expect(migrated).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId, + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + expect(validatePaneTree({ type: 'leaf', id: 'pane-lost', content: migrated } as never).valid).toBe(true) + expect(validatePaneTree({ + type: 'leaf', + id: 'pane-invalid-restore-identity', + content: { + ...(migrated as Record), + sessionRef: { provider: 'codex', sessionId: 'codex-durable-id' }, + }, + } as never).valid).toBe(false) + }) + it('keeps a bad Claude sessionRef alias as a restore error even when resumeSessionId is canonical', () => { const canonical = '00000000-0000-4000-8000-000000000777' const migrated = migrateLegacyFreshAgentContent({ diff --git a/test/unit/client/lib/fresh-agent-create-content.test.ts b/test/unit/client/lib/fresh-agent-create-content.test.ts new file mode 100644 index 000000000..4f9d3eef3 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-create-content.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import fixtures from '../../../fixtures/fresh-agent/claude-create-defaults.json' +import { createDefaultServerSettings, type ServerSettings } from '@shared/settings' +import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' + +describe('fresh-agent create defaults shared with REST', () => { + it.each(fixtures)('$name', ({ sessionType, settings, expected }) => { + const defaultServerSettings = createDefaultServerSettings() + const entry = resolveFreshAgentType(sessionType)! + const input = settings as Partial + const content = buildFreshAgentPaneContent({ + entry, + createRequestId: 'fixture-create', + codingCliSettings: { + ...defaultServerSettings.codingCli, + ...input.codingCli, + }, + freshAgentSettings: { + ...defaultServerSettings.freshAgent, + ...input.freshAgent, + }, + }) + const { model, modelSelection, modelEffortLevels, permissionMode, effort, plugins } = content + expect(JSON.parse(JSON.stringify({ model, modelSelection, modelEffortLevels, permissionMode, effort, plugins }))) + .toEqual(expected) + expect(content).toMatchObject({ + kind: 'fresh-agent', provider: 'claude', sessionType, + createRequestId: 'fixture-create', status: 'creating', + }) + }) +}) diff --git a/test/unit/client/store/crossTabSync.test.ts b/test/unit/client/store/crossTabSync.test.ts index df822aee4..c432126e0 100644 --- a/test/unit/client/store/crossTabSync.test.ts +++ b/test/unit/client/store/crossTabSync.test.ts @@ -1472,7 +1472,7 @@ describe('crossTabSync', () => { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', - sessionId: 'sdk-local-current', + sessionId: canonicalSessionId, createRequestId: 'req-local-current', status: 'running', resumeSessionId: canonicalSessionId, @@ -1513,7 +1513,7 @@ describe('crossTabSync', () => { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', - sessionId: 'sdk-local-current', + sessionId: canonicalSessionId, createRequestId: 'req-local-current', status: 'running', resumeSessionId: canonicalSessionId, @@ -1590,7 +1590,7 @@ describe('crossTabSync', () => { provider: 'claude', sessionId: canonicalSessionId, }) - expect(paneContent.sessionId).toBe('sdk-local-current') + expect(paneContent.sessionId).toBe(canonicalSessionId) expect(paneContent.createRequestId).toBe('req-local-current') expect(paneContent.status).toBe('running') }) diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts index 3b6dcec27..8634c6a8e 100644 --- a/test/unit/client/store/panesSlice.test.ts +++ b/test/unit/client/store/panesSlice.test.ts @@ -3436,6 +3436,24 @@ describe('panesSlice', () => { expect(content.resumeSessionId).toBe(OPENCODE_DURABLE_ID) }) + it('retains a non-Claude durable ref without rewriting its distinct live sessionId', () => { + const liveSessionId = 'opencode-live-session-handle' + const brokenDurable: FreshAgentPaneContent = { + ...durableOpencodeContent(), + sessionId: liveSessionId, + restoreError: DEAD_HANDLE_RESTORE_ERROR, + } + + const updated = panesReducer( + initialState, + initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: brokenDurable }), + ) + const content = freshAgentLeaf(updated) + expect(content.sessionRef).toEqual({ provider: 'opencode', sessionId: OPENCODE_DURABLE_ID }) + expect(content.sessionId).toBe(liveSessionId) + expect(content.restoreError).toEqual(DEAD_HANDLE_RESTORE_ERROR) + }) + it('hydrate: restoreError on a DURABLE incoming identity applies unchanged', () => { const brokenDurable: FreshAgentPaneContent = { ...durableOpencodeContent(), @@ -3458,6 +3476,73 @@ describe('panesSlice', () => { expect(content.sessionId).toBe(OPENCODE_DURABLE_ID) }) + it('hydrate: retains a valid same-provider Claude sessionRef with a recoverable restore error', () => { + const claudeSessionId = VALID_CLAUDE_SESSION_ID + const brokenClaude: FreshAgentPaneContent = { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-claude-hydrate-restore-error', + status: 'idle', + sessionId: claudeSessionId, + sessionRef: { provider: 'claude', sessionId: claudeSessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + } + + const hydrated = panesReducer( + initialState, + hydratePanes(stateWithLayout({ + 'tab-1': { type: 'leaf', id: 'pane-1', content: brokenClaude }, + })), + ) + const content = freshAgentLeaf(hydrated) + expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: claudeSessionId }) + expect(content.sessionId).toBe(claudeSessionId) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + + it('keeps a validated same-provider Claude sessionRef through restore-error folds and later updates', () => { + const claudeSessionId = VALID_CLAUDE_SESSION_ID + const claudeContent: FreshAgentPaneContent = { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-claude-restore-error', + status: 'idle', + sessionId: claudeSessionId, + resumeSessionId: claudeSessionId, + sessionRef: { provider: 'claude', sessionId: claudeSessionId }, + } + const seeded = panesReducer( + initialState, + initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: claudeContent }), + ) + + const markedLost = panesReducer( + seeded, + mergePaneContent({ + tabId: 'tab-1', + paneId: 'pane-1', + updates: { restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' } }, + }), + ) + const afterRefresh = panesReducer( + markedLost, + mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { status: 'idle' } }), + ) + + const content = freshAgentLeaf(afterRefresh) + expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: claudeSessionId }) + expect(content.sessionId).toBe(claudeSessionId) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + it('deliberate reset: restoreError + placeholder under a NEW createRequestId applies unchanged', () => { const resetStale = placeholderOpencodeContent('req-reset-generation', { restoreError: DEAD_HANDLE_RESTORE_ERROR, diff --git a/test/unit/client/store/persisted-state.fresh-agent.test.ts b/test/unit/client/store/persisted-state.fresh-agent.test.ts index 9baee7083..a04dc6b6d 100644 --- a/test/unit/client/store/persisted-state.fresh-agent.test.ts +++ b/test/unit/client/store/persisted-state.fresh-agent.test.ts @@ -85,6 +85,56 @@ function storageWith(values: Record): Pick { beforeEach(seedWindow) + it.each(['freshclaude', 'kilroy'])('restores %s placeholder panes under their durable identity', (sessionType) => { + const sessionId = '00000000-0000-4000-8000-000000000779' + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('legacy', { + kind: 'fresh-agent', sessionType, provider: 'claude', + sessionId: 'old-placeholder', sessionRef: { provider: 'claude', sessionId }, + serverInstanceId: 'old-server', initialCwd: '/repo', + }), + })) + expect(collectLeafContents(parsed!.panes.layouts['tab-1'])[0]).toMatchObject({ + sessionId, sessionRef: { provider: 'claude', sessionId }, + createRequestId: 'req-legacy', initialCwd: '/repo', + }) + }) + + it('retains a validated same-provider Claude sessionRef alongside a persisted restore error', () => { + const sessionId = '00000000-0000-4000-8000-000000000780' + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('lost-claude', { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId: 'legacy-runtime-placeholder', + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }), + })) + + expect(collectLeafContents(parsed!.panes.layouts['tab-1'])[0]).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId, + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + }) + + it('leaves a legacy zero-turn placeholder without a durable reference available for lost-session recovery', () => { + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('legacy', { + kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', + sessionId: 'old-zero-turn-placeholder', serverInstanceId: 'old-server', + }), + })) + const content = collectLeafContents(parsed!.panes.layouts['tab-1'])[0] + expect(content.sessionId).toBe('old-zero-turn-placeholder') + expect(content.sessionRef).toBeUndefined() + }) + it('migrates persisted agent-chat panes to fresh-agent panes in the combined layout key shape', () => { const parsed = parsePersistedLayoutRaw(layoutRaw({ 'tab-1': { diff --git a/test/unit/client/store/storage-migration.test.ts b/test/unit/client/store/storage-migration.test.ts index 7e5b68376..8fb13f83c 100644 --- a/test/unit/client/store/storage-migration.test.ts +++ b/test/unit/client/store/storage-migration.test.ts @@ -206,6 +206,59 @@ describe('storage-migration', () => { }) }) + it('preserves a validated Claude sessionRef alongside a recoverable restore error during boot migration', async () => { + localStorage.setItem('freshell_version', '5') + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ + persistedAt: Date.now(), + version: 3, + tabs: { + activeTabId: 'tab-claude', + tabs: [{ id: 'tab-claude', title: 'Claude', createdAt: 1, mode: 'freshclaude' }], + }, + panes: { + version: 6, + layouts: { + 'tab-claude': { + type: 'leaf', + id: 'pane-claude', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-restore-error', + status: 'idle', + sessionId: VALID_CLAUDE_SESSION_ID, + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }, + }, + }, + activePane: { 'tab-claude': 'pane-claude' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + + await importFreshStorageMigration() + + const migratedRaw = localStorage.getItem(LAYOUT_STORAGE_KEY) + expect(migratedRaw).not.toBeNull() + const migrated = JSON.parse(migratedRaw!) + const content = migrated.panes.layouts['tab-claude'].content + expect(content.sessionRef).toEqual({ + provider: 'claude', + sessionId: VALID_CLAUDE_SESSION_ID, + }) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + it('migrates durable legacy Codex recovery_failed panes to creating resume panes', async () => { localStorage.setItem('freshell_version', '3') localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index 78cd9ec8c..fe0fa1b9a 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -127,6 +127,29 @@ describe('focus neutrality (MCP wrapper)', () => { }) describe('executeAction -- tab actions', () => { + it.each(['claude', 'kilroy'])('new-tab supports agent %s and maps resume sugar to Claude identity', async (agent) => { + mockClient.post.mockResolvedValue({ data: { tabId: 'tab-new', paneId: 'pane-new' } }) + const result = await executeAction('new-tab', { agent, model: 'catalog-model', effort: 'max', resume: '550e8400-e29b-41d4-a716-446655440000' }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + }) + + it.each(['claude', 'kilroy'])('split-pane supports agent %s and maps resume sugar to Claude identity', async (agent) => { + mockClient.get.mockImplementation((url: string) => Promise.resolve(url === '/api/tabs' + ? { data: { tabs: [{ id: 'tab-a', activePaneId: 'pmain' }], activeTabId: 'tab-a' } } + : { data: { panes: [{ id: 'pmain', tabId: 'tab-a', index: 0 }] } })) + mockClient.post.mockResolvedValue({ data: { paneId: 'pane-new' } }) + const result = await executeAction('split-pane', { target: 'pmain', agent, model: 'catalog-model', effort: 'max', resume: '550e8400-e29b-41d4-a716-446655440000' }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith('/api/panes/pmain/split', expect.objectContaining({ + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + }) + it('new-tab calls POST /api/tabs with name and mode', async () => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { name: 'Work', mode: 'claude' }) @@ -366,19 +389,23 @@ describe('executeAction -- tab actions', () => { resume: 'thread-pre-durable', }) - expect(result).toEqual(expect.objectContaining({ error: "Only agent 'opencode' is supported with the Rust Freshell server." })) + expect(result).toEqual(expect.objectContaining({ error: 'This fresh-agent provider is unavailable with the Rust Freshell server.' })) expect(mockClient.post).not.toHaveBeenCalled() }) - it.each(['claude', 'kilroy'])('new-tab with agent: %s is locally rejected by the Rust capability contract', async (agent) => { + it.each(['claude', 'kilroy'])('new-tab with agent %s accepts the resumeSessionId alias', async (agent) => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { agent, - resume: '550e8400-e29b-41d4-a716-446655440000', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440000', }) - expect(mockClient.post).not.toHaveBeenCalled() + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + agent, + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + expect(mockClient.post.mock.calls[0][1]).not.toHaveProperty('resumeSessionId') }) it('list-tabs calls GET /api/tabs', async () => { diff --git a/test/unit/tooling/run-rust-tests-fixture.ts b/test/unit/tooling/run-rust-tests-fixture.ts index 4d4f50150..f0695c040 100644 --- a/test/unit/tooling/run-rust-tests-fixture.ts +++ b/test/unit/tooling/run-rust-tests-fixture.ts @@ -43,7 +43,22 @@ export function fixture() { const result = cp.spawnSync(process.execPath, [file], {env:process.env, stdio:'inherit'}); process.exit(result.status ?? 1); } - fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'cargo', args, + const target = (await import('node:path')).default.resolve(process.cwd(), process.env.CARGO_TARGET_DIR || 'target'); + const binary = target + '/debug/freshell-server'; + if (args[0] === 'build' && !Number(process.env.BUILD_EXIT || 0)) { + fs.mkdirSync(target + '/debug', { recursive: true }); + fs.writeFileSync(binary, '#!/usr/bin/env node\\nprocess.stdout.write(' + JSON.stringify(process.cwd()) + ')\\n'); + fs.chmodSync(binary, 0o755); + } + if (args[0] === 'test') { + const cp = await import('node:child_process'); + const actual = cp.spawnSync(process.env.FRESHELL_SERVER_BIN, [], { encoding: 'utf8' }); + if (actual.status !== 0 || actual.stdout !== process.cwd()) { + console.error('the selected server was not built for this checkout', process.env.FRESHELL_SERVER_BIN, actual.error); + process.exit(91); + } + } + fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'cargo', args, target, testThreads:process.env.RUST_TEST_THREADS, tokioThreads:process.env.TOKIO_WORKER_THREADS, binary:process.env.FRESHELL_SERVER_BIN, route:process.env.FRESHELL_RUST_TEST_SANDBOX, jobs:process.env.CARGO_BUILD_JOBS, tmp:process.env.TMPDIR, executableTmp:process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR})+'\\n'); if (args[0] === 'test' && process.env.HOLD_TEST) { @@ -59,9 +74,10 @@ export function fixture() { import fs from 'node:fs'; import cp from 'node:child_process'; const args = process.argv.slice(2); - fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'sandbox', args})+'\\n'); + fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'sandbox', args, ownerGuard:process.env.FRESHELL_SANDBOX_OWNER_GUARD})+'\\n'); if (process.env.SANDBOX_EXIT) process.exit(Number(process.env.SANDBOX_EXIT)); const command = args[1] + .replaceAll('/freshell-test-artifacts/targets/', ${JSON.stringify(artifacts)} + '/targets/') .replace('mktemp -d /freshell-test-artifacts/.freshell-rust-tests-', 'mktemp -d ' + ${JSON.stringify(artifacts)} + '/.freshell-rust-tests-') .replace('mktemp -d /freshell-test-artifacts/XXX', 'mktemp -d ' + ${JSON.stringify(runtimeTemplate)}) .replace('mktemp -d /workspace/target/', 'mktemp -d ' + ${JSON.stringify(root)} + '/target/') @@ -74,7 +90,7 @@ export function fixture() { run(env: NodeJS.ProcessEnv = {}, args: string[] = []) { const result = spawnSync(process.execPath, ['--import', tsx, path.join(root, 'scripts/testing/run-rust-tests.ts'), ...args], { env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, RECEIPT: receipt, - FRESHELL_RUST_TEST_SANDBOX: '0', FRESHELL_SERVER_BIN: '/host/stale-server', ...env }, + FRESHELL_RUST_TEST_SANDBOX: '0', CARGO_TARGET_DIR: '', FRESHELL_SERVER_BIN: '/host/stale-server', ...env }, encoding: 'utf8', timeout: 15_000, }) const events = existsSync(receipt) ? readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [] diff --git a/test/unit/tooling/run-rust-tests.test.ts b/test/unit/tooling/run-rust-tests.test.ts index 3e97580f0..779591e94 100644 --- a/test/unit/tooling/run-rust-tests.test.ts +++ b/test/unit/tooling/run-rust-tests.test.ts @@ -27,6 +27,35 @@ describe('Rust test launcher children', () => { } finally { f.cleanup() } }) + it.each(['relative target with spaces', '/absolute'])('executes the server built in the effective native Cargo target directory (%s)', target => { + const f = fixture() + try { + const targetDirectory = target === '/absolute' ? path.join(f.root, 'absolute target') : target + const result = f.run({ CARGO_TARGET_DIR: targetDirectory }) + expect(result.status, result.stderr).toBe(0) + const effectiveTarget = path.resolve(f.root, targetDirectory) + expect(result.events.map(e => e.target)).toEqual([effectiveTarget, effectiveTarget]) + expect(result.events[1].binary).toBe(path.join(effectiveTarget, 'debug/freshell-server')) + } finally { f.cleanup() } + }) + + it('keeps a stable sandbox Cargo namespace per host worktree and replaces a host target override', () => { + const first = fixture() + const second = fixture() + try { + const one = first.run({ FRESHELL_RUST_TEST_SANDBOX: '1', CARGO_TARGET_DIR: '/host/foreign-target' }) + const repeated = first.run({ FRESHELL_RUST_TEST_SANDBOX: '1' }) + const other = second.run({ FRESHELL_RUST_TEST_SANDBOX: '1' }) + for (const result of [one, repeated, other]) expect(result.status, result.stderr).toBe(0) + const target = one.events[1].target + expect(path.dirname(target)).toBe(path.join(first.artifacts, 'targets')) + expect(repeated.events[4].target).toBe(target) + expect(path.basename(other.events[1].target)).not.toBe(path.basename(target)) + expect(one.events[0].ownerGuard).toBe('1') + expect(one.events[1]).toMatchObject({ jobs: '3', testThreads: '4', tokioThreads: '2' }) + } finally { first.cleanup(); second.cleanup() } + }) + it('preserves build failure and does not launch tests', () => { const f = fixture() try { @@ -56,7 +85,7 @@ describe('Rust test launcher children', () => { expect(result.events[1].route).toBe('0') expect(result.events[1].jobs).toBe('3') expect(result.events[2].args).toEqual(['test', '--workspace', '--locked', ...args.slice(1)]) - expect(result.events[2].binary).toBe(path.join(f.root, 'target/debug/freshell-server')) + expect(result.events[2].binary).toBe(path.join(result.events[1].target, 'debug/freshell-server')) expect(existsSync(result.events[2].tmp)).toBe(false) expect(existsSync(result.events[2].executableTmp)).toBe(false) } finally { f.cleanup() } diff --git a/test/unit/tooling/run-standard-tests-cloud-output.test.ts b/test/unit/tooling/run-standard-tests-cloud-output.test.ts new file mode 100644 index 000000000..cdd35b59a --- /dev/null +++ b/test/unit/tooling/run-standard-tests-cloud-output.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node + +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('../../../', import.meta.url)) + +async function runCloudFixture(mode: 'progress' | 'volume' | 'failure') { + const fixture = mkdtempSync(path.join(os.tmpdir(), 'freshell-cloud-output-')) + const script = path.join(fixture, 'cloud.mjs') + const release = path.join(fixture, 'release') + writeFileSync(script, `#!${process.execPath} +import { existsSync } from 'node:fs'; +const mode = ${JSON.stringify(mode)}; +process.stderr.write('cloud diagnostic\\n'); +if (mode === 'progress') { + process.stdout.write('cloud progress\\n'); + const deadline = Date.now() + 4000; + const timer = setInterval(() => { + if (existsSync(${JSON.stringify(release)})) { + clearInterval(timer); + process.stdout.write('cloud complete\\n'); + } else if (Date.now() > deadline) { + clearInterval(timer); + process.exitCode = 17; + } + }, 10); +} else if (mode === 'volume') { + process.stdout.write('x'.repeat(2 * 1024 * 1024)); + process.stdout.write('\\ncloud complete\\n'); +} else { + process.exitCode = 7; +} +`, { mode: 0o755 }) + const child = spawn(process.execPath, [ + '--import', 'tsx', 'scripts/run-standard-tests.ts', + 'test/unit/cloud-output-fixture.test.ts', + ], { + cwd: root, + env: { + ...process.env, + FRESHELL_VITEST_BACKEND: 'cloud', + FRESHELL_VITEST_CLOUD_SCRIPT: script, + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + if (stdout.includes('cloud progress')) writeFileSync(release, '') + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + return { code, stdout, stderr } + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +} + +describe('standard runner cloud output', () => { + it('streams progress while the cloud process is still running', async () => { + const result = await runCloudFixture('progress') + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('cloud complete') + expect(result.stderr).toContain('cloud diagnostic') + }, 15_000) + + it('accepts a successful cloud run with more than the execFile buffer limit of output', async () => { + const result = await runCloudFixture('volume') + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('x'.repeat(2 * 1024 * 1024)) + expect(result.stdout).toContain('cloud complete') + }, 15_000) + + it('preserves the cloud diagnostic and reports its failing exit code', async () => { + const result = await runCloudFixture('failure') + expect(result.code).toBe(1) + expect(result.stderr).toContain('cloud diagnostic') + expect(result.stderr).toContain('Cloud Vitest phase exited with code 7') + }, 15_000) +}) diff --git a/test/unit/tooling/testing/runtime-build-preflight.test.ts b/test/unit/tooling/testing/runtime-build-preflight.test.ts new file mode 100644 index 000000000..ebca142d6 --- /dev/null +++ b/test/unit/tooling/testing/runtime-build-preflight.test.ts @@ -0,0 +1,220 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupRuntimeBuildPreflight } from '../../../setup/runtime-build-preflight.js' +import { RUNTIME_BUILD_MANIFEST_ENV, preparedRuntimeBinary } from '../../../../scripts/testing/runtime-build-preflight.js' +import { RuntimeHarness } from '../../../../scripts/testing/runtime-sandbox.js' +import { ManagedRuntimeBrowserRig } from '../../../e2e-browser/helpers/managed-runtime.js' + +const owned: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const directory of owned.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) +}) + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'freshell-build-preflight-')) + owned.push(root) + execFileSync('git', ['init', '--quiet', root]) + execFileSync('git', ['-C', root, '-c', 'user.name=Dan Shapiro', '-c', 'user.email=3732858+danshapiro@users.noreply.github.com', 'commit', '--allow-empty', '--quiet', '-m', 'fixture']) + const cargo = path.join(root, 'cargo.cjs') + fs.writeFileSync(cargo, `const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.BUILD_LOG, JSON.stringify(args) + '\\n'); +if (process.env.FAIL_BUILD && args.includes(process.env.FAIL_BUILD)) process.exit(17); +if (process.env.BUILD_DELAY_MS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(process.env.BUILD_DELAY_MS)); +const profile = args.includes('--release') ? 'release' : 'debug'; +const features = args.includes('--features') ? args[args.indexOf('--features') + 1] : 'default'; +const stamp = process.env.BUILD_STAMP ? fs.readFileSync(process.env.BUILD_STAMP, 'utf8') : ''; +const target = path.resolve(process.cwd(), process.env.CARGO_TARGET_DIR || 'target', profile); +fs.mkdirSync(target, {recursive:true}); +for (let i=0; i [path.join(root, 'test/integration/server/managed-provider-parity.test.ts')] } }, + onTestsRerun: () => {}, + } + const command = { program: process.execPath, args: [cargo] } + const rows = () => fs.existsSync(env.BUILD_LOG) ? fs.readFileSync(env.BUILD_LOG, 'utf8').trim().split('\n').map(row => JSON.parse(row) as string[]) : [] + return { root, project, command, rows } +} + +describe('managed runtime build preparation before suite startup', () => { + it('does no builds or artifact work for an unrelated selected unit file', async () => { + const f = fixture() + f.project.vitest.state.getPaths = () => [path.join(f.root, 'test/unit/ordinary.test.ts')] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(f.rows()).toEqual([]) + expect(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)).toBeUndefined() + await teardown?.() + }) + + it('builds selected variants once and preserves their exact executable copies across later Cargo builds', async () => { + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, path.join(os.tmpdir(), 'unrelated-parent-build-manifest')) + const f = fixture() + f.project.vitest.state.getPaths = () => [ + path.join(f.root, 'test/integration/server/managed-provider-parity.test.ts'), + path.join(f.root, 'test/integration/server/fresh-agent-parity.test.ts'), + path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts'), + ] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(f.rows()).toHaveLength(5) + const terminal = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! + const fresh = preparedRuntimeBinary(f.root, 'server-managed-fresh-agent-fixtures', f.project.config.env)! + expect(fs.readFileSync(terminal, 'utf8')).toContain('release:managed-provider-qualification') + expect(fs.readFileSync(fresh, 'utf8')).toContain('release:managed-fresh-agent-fixtures') + expect(fs.readFileSync(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)!, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') + expect(fs.statSync(terminal).mode & 0o111).not.toBe(0) + await teardown?.() + expect(fs.existsSync(terminal)).toBe(false) + expect(fs.existsSync(fresh)).toBe(false) + }) + + it('reuses the parent invocation artifacts for nested parity runners without compiling or owning their cleanup', async () => { + const f = fixture() + f.project.vitest.state.getPaths = () => [path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts')] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + const binary = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! + const nested = { ...f.project, config: { ...f.project.config, env: { ...f.project.config.env } } } + const childTeardown = await setupRuntimeBuildPreflight(nested, { command: f.command }) + expect(f.rows()).toHaveLength(5) + await childTeardown?.() + expect(fs.existsSync(binary)).toBe(true) + await teardown?.() + expect(fs.existsSync(binary)).toBe(false) + }) + + it('fails preparation before publishing a partial bundle when a prerequisite build fails', async () => { + const f = fixture() + Object.assign(f.project.config.env, { FAIL_BUILD: 'provider-qualification' }) + await expect(setupRuntimeBuildPreflight(f.project, { command: f.command })).rejects.toThrow(/17|build/) + expect(f.rows()).toHaveLength(3) + expect(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)).toBeUndefined() + expect(fs.existsSync(path.join(f.root, '.runtime-build')) ? fs.readdirSync(path.join(f.root, '.runtime-build')) : []).toEqual([]) + }) + + it('refuses missing prepared variants and artifacts from another checkout instead of silently building', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(() => preparedRuntimeBinary(f.root, 'server-managed-fresh-agent-fixtures', f.project.config.env)).toThrow(/prepared.*variant|not prepared/i) + const other = fixture() + expect(() => preparedRuntimeBinary(other.root, 'supervisor-test', f.project.config.env)).toThrow(/checkout|root/) + expect(other.rows()).toEqual([]) + await teardown?.() + }) + + it('uses the prepared executables in the actual runtime and browser rig build methods without invoking Cargo again', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV]) + const runtime = new RuntimeHarness(f.root, undefined, 2) + const rig = new ManagedRuntimeBrowserRig(f.root, 2, {}, {}, 'test', { enabledProviders: ['claude'] }) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + ;(runtime as unknown as { buildBinaries(): void }).buildBinaries() + const methods = rig as unknown as { buildManagedServer(): string; buildQualificationSupervisor(): string } + const server = methods.buildManagedServer() + const supervisor = methods.buildQualificationSupervisor() + expect(fs.readFileSync(runtime.testHostBinary, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') + expect(fs.readFileSync(runtime.releaseHostBinary, 'utf8')).toContain('release:default') + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') + expect(fs.readFileSync(supervisor, 'utf8')).toContain('release:provider-qualification') + expect(f.rows()).toHaveLength(4) + await teardown?.() + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') + }) + + it('runs preflight before a real Vitest startup hook, selects only requested files, and never starts after a build failure', () => { + const f = fixture() + const sourceRoot = path.resolve(import.meta.dirname, '../../../..') + const url = (file: string) => JSON.stringify(pathToFileURL(path.join(sourceRoot, file)).href) + const marker = path.join(f.root, 'started') + fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ type: 'module' })) + const setup = path.join(f.root, 'setup.ts') + fs.writeFileSync(setup, `import { setupRuntimeBuildPreflight } from ${url('test/setup/runtime-build-preflight.ts')}; +export default project => setupRuntimeBuildPreflight(project, ${JSON.stringify({ command: f.command })});`) + const config = path.join(f.root, 'vitest.config.ts') + fs.writeFileSync(config, `export default ${JSON.stringify({ root: f.root, test: { environment: 'node', globalSetup: [setup], maxWorkers: 1 } })}`) + const ordinary = 'test/unit/ordinary.test.ts' + const managed = 'test/integration/server/managed-provider-parity.test.ts' + for (const file of [ordinary, managed]) { + fs.mkdirSync(path.dirname(path.join(f.root, file)), { recursive: true }) + fs.writeFileSync(path.join(f.root, file), `import fs from 'node:fs'; +import { it, beforeAll, expect } from ${url('node_modules/vitest/dist/index.js')}; +import { preparedRuntimeBinary } from ${url('scripts/testing/runtime-build-preflight.ts')}; +let binary; +beforeAll(() => { binary = preparedRuntimeBinary(${JSON.stringify(f.root)}, 'server-managed-provider-qualification'); fs.writeFileSync(${JSON.stringify(marker)}, binary || 'unit'); }, 250); +it('starts with the exact selected artifacts', () => expect(${JSON.stringify(file)}.includes('/server/')).toBe(Boolean(binary)));`) + } + const run = (file: string, extra: NodeJS.ProcessEnv = {}) => execFileSync(process.execPath, [ + path.join(sourceRoot, 'node_modules/vitest/vitest.mjs'), 'run', file, '--config', config, + ], { cwd: f.root, env: { ...f.project.config.env, BUILD_DELAY_MS: '100', ...extra }, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }) + run(ordinary) + expect(f.rows()).toEqual([]) + expect(fs.readFileSync(marker, 'utf8')).toBe('unit') + fs.rmSync(marker) + run(managed) + expect(f.rows()).toHaveLength(4) + expect(fs.readFileSync(marker, 'utf8')).toContain('server-managed-provider-qualification') + fs.rmSync(marker) + expect(() => run(managed, { FAIL_BUILD: 'provider-qualification' })).toThrow() + expect(fs.existsSync(marker)).toBe(false) + }, 30_000) + + it('consumes the latest prepared artifacts on real Vitest reruns and cleans every observed bundle', () => { + const f = fixture() + const sourceRoot = path.resolve(import.meta.dirname, '../../../..') + const url = (file: string) => JSON.stringify(pathToFileURL(path.join(sourceRoot, file)).href) + const stamp = path.join(f.root, 'stamp') + const observations = path.join(f.root, 'observations.jsonl') + fs.writeFileSync(stamp, 'initial') + fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ type: 'module' })) + const setup = path.join(f.root, 'setup.ts') + fs.writeFileSync(setup, `import { setupRuntimeBuildPreflight } from ${url('test/setup/runtime-build-preflight.ts')}; +export default project => setupRuntimeBuildPreflight(project, ${JSON.stringify({ command: f.command })});`) + const managed = 'test/integration/server/managed-provider-parity.test.ts' + fs.mkdirSync(path.dirname(path.join(f.root, managed)), { recursive: true }) + fs.writeFileSync(path.join(f.root, managed), `import fs from 'node:fs'; +import { it, expect } from ${url('node_modules/vitest/dist/index.js')}; +import { preparedRuntimeBinary, RUNTIME_BUILD_MANIFEST_ENV } from ${url('scripts/testing/runtime-build-preflight.ts')}; +it('observes the exact rebuilt binary', () => { + const binary = preparedRuntimeBinary(${JSON.stringify(f.root)}, 'server-managed-provider-qualification'); + const bytes = fs.readFileSync(binary, 'utf8'); + expect(bytes).toContain(fs.readFileSync(${JSON.stringify(stamp)}, 'utf8')); + fs.appendFileSync(${JSON.stringify(observations)}, JSON.stringify({ manifest: process.env[RUNTIME_BUILD_MANIFEST_ENV], binary, bytes }) + '\\n'); +});`) + const config = path.join(f.root, 'vitest.config.ts') + fs.writeFileSync(config, `export default ${JSON.stringify({ root: f.root, test: { environment: 'node', globalSetup: [setup], maxWorkers: 1 } })}`) + const driver = path.join(f.root, 'rerun.mjs') + fs.writeFileSync(driver, `import fs from 'node:fs'; +import { startVitest } from ${url('node_modules/vitest/dist/node.js')}; +const ctx = await startVitest('test', [${JSON.stringify(managed)}], { root: ${JSON.stringify(f.root)}, config: ${JSON.stringify(config)}, watch: true }); +if (!ctx) throw new Error('Vitest did not start'); +try { + const specs = await ctx.globTestSpecifications([${JSON.stringify(managed)}]); + for (const next of ['rerun-one', 'rerun-two']) { + fs.writeFileSync(${JSON.stringify(stamp)}, next); + await ctx.rerunTestSpecifications(specs); + } +} finally { await ctx.close(); } +`) + execFileSync(process.execPath, [driver], { cwd: f.root, env: { ...f.project.config.env, BUILD_STAMP: stamp }, timeout: 30_000, stdio: 'pipe' }) + const rows = fs.readFileSync(observations, 'utf8').trim().split('\n').map(row => JSON.parse(row)) + expect(rows).toHaveLength(3) + expect(rows.map(row => row.bytes)).toEqual([ + expect.stringContaining(':initial'), expect.stringContaining(':rerun-one'), expect.stringContaining(':rerun-two'), + ]) + for (const row of rows) { + expect(fs.existsSync(row.binary)).toBe(false) + expect(fs.existsSync(row.manifest)).toBe(false) + } + }, 30_000) +}) diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index a1c399ae1..0b07d94d4 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -8,7 +8,7 @@ import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' -import { renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' +import { freshAgentResumeProvider, renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' type Flags = Record @@ -459,7 +459,7 @@ async function main() { const model = getFlag(flags, 'model') as string | undefined const effort = getFlag(flags, 'effort') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined - const resumeProvider = mode ?? agent + const resumeProvider = mode ?? freshAgentResumeProvider(agent) const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) const prompt = getFlag(flags, 'prompt') as string | undefined if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return @@ -576,11 +576,15 @@ async function main() { const mode = getFlag(flags, 'mode') as string | undefined const shell = getFlag(flags, 'shell') as string | undefined const cwd = getFlag(flags, 'cwd') as string | undefined + const agent = getFlag(flags, 'agent') as string | undefined + const model = getFlag(flags, 'model') as string | undefined + const effort = getFlag(flags, 'effort') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined - const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) - if (rejectRawCodexResume(mode, resumeSessionId)) return + const resumeProvider = mode ?? freshAgentResumeProvider(agent) + const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) + if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return if (sessionRefResult.rejected) return - const promoted = promoteResumeFlag(mode, resumeSessionId, sessionRefResult.sessionRef) + const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return const resolved = await resolvePaneTarget(client, target) @@ -597,6 +601,9 @@ async function main() { mode, shell, cwd, + agent, + model, + effort, ...(isTruthy(getFlag(flags, 'hostStats')) ? { hostStats: true } : {}), ...(promoted.sessionRef ? { sessionRef: promoted.sessionRef } : {}), }) diff --git a/tools/freshell-mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts index 203bf01e5..5f2ce2f05 100644 --- a/tools/freshell-mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -11,6 +11,7 @@ import { translateKeys } from '../node-client-runtime/keys.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { ACTION_ALIASES, + freshAgentResumeProvider, resolveCanonicalAction, supportedActionCapabilities, unsupportedInvocationResult, @@ -57,7 +58,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Fresh agents (in-app) -- Use new-tab with agent="opencode", optional model=, effort=, cwd=. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Then drive the pane with send-keys and read it with capture-pane. Example: +- Use new-tab with agent="claude", "kilroy", or "opencode", optional model=, effort=, cwd=. Use split-pane with agent="claude" or "kilroy" for a fresh pane beside an existing one. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Drive fresh panes with send-keys and read them with capture-pane. Example: new-tab { agent: "opencode", model: "umans-ai-coding-plan/umans-kimi-k2.7", prompt: "Summarize README.md" } ## Choosing the right action @@ -356,19 +357,6 @@ function rejectRawCodexResume( return undefined } -// Resume sugar (`resume`/`resumeSessionId`) on the fresh-agent shorthand path -// (agent param, no mode): only opencode maps to a synthesized sessionRef -- it -// is the only provider the REST resume endpoint honors. codex maps to 'codex' -// so the raw-resume refusal above fires with parity to mode=codex. Every other -// value (claude/kilroy/unknown/non-string) returns undefined: no synthesis, -// resume fields keep their dropped behavior, and explicit sessionRef (already -// forwarded for any provider) remains the documented path. -// Accepts unknown because routeAction args are Record. -function agentResumeProvider(agent: unknown): 'codex' | 'opencode' | undefined { - if (agent === 'opencode' || agent === 'codex') return agent - return undefined -} - // --------------------------------------------------------------------------- // Action router // --------------------------------------------------------------------------- @@ -473,9 +461,9 @@ async function routeAction( // never forwarded over the wire. const legacyResume = typeof resume === 'string' ? resume : resumeSessionId // Provider the resume sugar keys on: an explicit mode wins; otherwise the - // fresh-agent shorthand contributes one only for the REST-honorable set - // (opencode synthesizes, codex rejects raw ids; see agentResumeProvider). - const resumeProvider = mode ?? agentResumeProvider(rest.agent) + // fresh-agent shorthand maps Claude/Kilroy to Claude identity and keeps + // the existing OpenCode promotion and raw-Codex refusal. + const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) const codexResumeError = rejectRawCodexResume(resumeProvider, legacyResume, explicitSessionRef) if (codexResumeError) return codexResumeError const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof legacyResume === 'string' @@ -546,10 +534,11 @@ async function routeAction( if (!resolved.pane) return { error: resolved.message || 'No pane found', hint: "Run action 'list-panes' to see available panes." } const paneId = resolved.pane.id const { direction, browser, editor, mode, shell, cwd, target: _t, resume, sessionRef, ...rest } = params || {} - const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) + const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) + const codexResumeError = rejectRawCodexResume(resumeProvider, resume, sessionRef) if (codexResumeError) return codexResumeError - const effectiveSessionRef = sessionRef ?? (typeof mode === 'string' && mode !== 'codex' && typeof resume === 'string' - ? { provider: mode, sessionId: resume } + const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof resume === 'string' + ? { provider: resumeProvider, sessionId: resume } : undefined) return c.post(`/api/panes/${encodeURIComponent(paneId)}/split`, { direction, browser, editor, mode, shell, cwd, ...(effectiveSessionRef ? { sessionRef: effectiveSessionRef } : {}), ...rest, diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index 6064e4cd0..9b74b78fc 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -24,7 +24,7 @@ export const ACTION_CAPABILITIES: readonly ActionCapability[] = [ { action: 'rename-tab', aliases: ['rename-window'], supported: true, params: params(['name'], ['target', 'nameIntent']) }, { action: 'next-tab', aliases: ['next-window'], supported: true, params: noParams }, { action: 'prev-tab', aliases: ['previous-window', 'prev-window'], supported: true, params: noParams }, - { action: 'split-pane', aliases: ['split-window'], supported: true, params: params([], ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'sessionRef']) }, + { action: 'split-pane', aliases: ['split-window'], supported: true, params: params([], ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'sessionRef', 'agent', 'model', 'effort']) }, { action: 'display', aliases: ['display-message'], supported: true, params: params([], ['target', 'format']) }, { action: 'screenshot', aliases: ['screenshot-pane', 'screenshot-tab', 'screenshot-view'], supported: true, params: params(['scope'], ['target', 'name']) }, { action: 'has-tab', supported: true, params: params(['target']) }, @@ -132,11 +132,12 @@ export function unsupportedInvocationResult( const unsupported = unsupportedActionResult(action) if (unsupported) return unsupported const canonical = resolveCanonicalAction(action) - if (canonical === 'new-tab' && invocation?.agent !== undefined && invocation.agent !== 'opencode') { - return { error: "Only agent 'opencode' is supported with the Rust Freshell server.", hint: 'Use mode for direct Claude or Codex terminals.' } + if (canonical === 'new-tab' && invocation?.agent !== undefined && !['claude', 'kilroy', 'opencode'].includes(String(invocation.agent))) { + return { error: 'This fresh-agent provider is unavailable with the Rust Freshell server.', hint: 'Use agent claude, kilroy, or opencode; use mode codex for a Codex terminal.' } } - if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined)) { - return { error: 'Fresh-agent split parameters are unavailable with the Rust Freshell server.', hint: 'Use a supported mode pane instead.' } + if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined) + && !['claude', 'kilroy'].includes(String(invocation?.agent))) { + return { error: 'Fresh-agent splits require agent claude or kilroy.', hint: 'Choose a supported fresh agent or use mode for a terminal pane.' } } const waitPattern = canonical === 'wait-for' ? invocation?.pattern : undefined if (canonical === 'wait-for' && ( @@ -146,3 +147,10 @@ export function unsupportedInvocationResult( } return undefined } + +/** Fresh-agent names can share a runtime; resume identity always names the runtime. */ +export function freshAgentResumeProvider(agent: unknown): 'claude' | 'codex' | 'opencode' | undefined { + if (agent === 'claude' || agent === 'kilroy') return 'claude' + if (agent === 'codex' || agent === 'opencode') return agent + return undefined +}