From e4657fcd956aea5600a80866c85a21ee350d94d7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:11:52 -0700 Subject: [PATCH 01/92] docs: add complete Freshcodex implementation plan --- AGENTS.md | 2 + docs/plans/2026-10-25-freshcodex-complete.md | 944 +++++++++++++++++++ 2 files changed, 946 insertions(+) create mode 100644 docs/plans/2026-10-25-freshcodex-complete.md diff --git a/AGENTS.md b/AGENTS.md index c563d59b7..5ae4c6110 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,4 +332,6 @@ All components **must** be accessible for browser-use automation and WCAG compli Managed provider environment and recovery repair: [implementation plan](docs/plans/2026-10-05-managed-provider-environment.md). +Freshcodex completion: [implementation plan](docs/plans/2026-10-25-freshcodex-complete.md). + When idle-waiting on subagents, always call `wait_agent` with `timeout_ms=3600000` (the 1-hour maximum). `wait_agent` wakes immediately on any subagent message or completion, so long timeouts add no latency — but every wait return (including a no-op timeout) costs a full model turn that re-sends the entire context. Never poll on shorter timeouts. Have workers `send_message` to `/root` at milestones so the parent wakes exactly when there is news. diff --git a/docs/plans/2026-10-25-freshcodex-complete.md b/docs/plans/2026-10-25-freshcodex-complete.md new file mode 100644 index 000000000..2a5756515 --- /dev/null +++ b/docs/plans/2026-10-25-freshcodex-complete.md @@ -0,0 +1,944 @@ +# Complete Freshcodex Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. + +## User Request + +### Requested result +Make Freshcodex a complete, reliable implementation of the Freshagent experience, as good as or better than Freshclaude and Freshopencode, with additional support where essential Codex features require it. + +### Explicit constraints +- Inspect the existing implementation patterns and choose the strongest applicable ones. +- Use the-usual workflow. +- Prefer GLM 5.3 through OpenCode as the independent reviewer if available; otherwise use GPT 6.1 Sol at xhigh. +- Work in a dedicated Git worktree, preserve other agents' work, and use the pinned pnpm version. +- Use red/green/refactor development and meaningful unit, integration, and browser verification; do not weaken or skip tests to obtain a pass. +- Do not deploy or restart production as part of this implementation. + +### Accepted tradeoffs and residuals +- None. + +**Goal:** A Freshcodex conversation can be created, controlled, observed live, restored, and automated through the same Freshagent surfaces as the other providers, while faithfully exposing essential native Codex planning, models, permissions, questions, and child activity. + +**Architecture:** Extend the existing `FreshCodexState` and app-server adapter below the local/managed-host split. Keep native thread identity, ownership, handoff, history reads, fork, compaction, rollback, naming, shared transcript, settings, and typed REST lane; add a focused incremental transcript projection, identity-owned turn accounting, retained attention, native catalog probing, and faithful control normalization. Use the shared renderer and live-state isolation for Codex output rather than creating another provider runtime or view. + +**Tech Stack:** Rust/Axum/Tokio, existing Codex WebSocket JSON-RPC client, React 18/Redux Toolkit/Zod/TypeScript, Vitest, Cargo, Playwright, deterministic fake app-server, and existing Docker managed-runtime/sandbox fixtures. + +## Global Constraints + +1. Execute in `/home/dan/code/freshell/.worktrees/freshcodex-complete` on `the-usual/freshcodex-complete`, created from verified base `b5f86817ce3a9a1907e90eda8924927a55e669d6`. The clean-origin full baseline and frozen pnpm 10.34.5 install passed; the coordinator recorded them in `/home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/reports/workspace-receipt.md`. This committed source plan is canonical; the external draft is historical planning evidence. Implementation begins only after formal load-bearing validation and the requested independent plan review. Revalidate affected references if the approved base changes. +2. Node 22, pnpm **10.34.5**, Rust/Cargo 1.96.0; use locked Cargo commands. Relative TypeScript imports use `.js`. Supported essential native target is Codex **0.164.0**. Task 1 first proves that binary and then aligns the managed Codex pin in `docker/runtime/provider-versions.json` and `docker/runtime/Dockerfile` from 0.147.0 to 0.164.0 on this branch; other provider pins stay unchanged. This is build preparation, not deployment. Test artifact staging leaves root dependencies/lock/workspaces, root `.bin`, host production PATH and global installed CLI unchanged. Preserve unrelated branches/worktrees, especially the foreign Codex lifecycle worktree. +3. Configured Vitest and browser backends are both `cloud`. Use the supported entrypoints, `GCLOUD_ROBOT_REQUIRE=1`, and the configured robot identity; no fallback to a local backend to bypass infrastructure failures. `FRESHELL_RUST_TEST_SANDBOX=1` is mandatory on broad `check`/Rust/source-runtime runs. All focused Cargo checks below use the run-owned `run-focused-rust.py` helper, which invokes `scripts/sandbox-test.sh --runtime-suite` with owner guard, owned executable/runtime temp directories, the worktree-specific target path and bounded workers. `--build-server` rebuilds the correct test server before WS/server integration. Never kill a foreign coordinator holder. Follow `docs/development/test-sandbox.md` and `docs/development/pre-push-gate.md`. +4. No deployment, client build into main's `dist/client`, production restart, live-host mutation, global configuration edit, or real-home corpus mount. Feature tests use controlled provider fixtures; the requested independent reviews use the selected reviewer model. Scratch servers belong to the test fixtures and their owned temporary homes/ports. Keep the live port 3001 process untouched. +5. Every task follows red/green/refactor, runs the impacted behavior set, and makes a focused commit. Tests must execute the protected behavior. Do not skip/weaken tests or add source/prose/hash assertions. If an old static-copy assertion blocks an intentional behavior change, remove that assertion and replace its useful intent with behavior coverage. +6. Keep the native `thread/read { includeTurns:true }` full-history compatibility path for this change. Installed 0.164.0 still hydrates full turns; there is no demonstrated truncation requiring pagination. Preserve paginated **thread history mode** for native undo; that is a separate concern from fetching history in pages. Codex redo remains explicitly unsupported, and conversation undo does not edit files. +7. Preserve every existing ownership lease, epoch/generation fence, accepted-before-completion emission barrier, fork cleanup token, durable checkpoint boundary, crash-resume/supersession path, native saved-name authority, and side-effect-free untracked snapshot read. New snapshot/projection work must never spawn/resume a provider for GET. +8. Use structured tracing with provider/session/native-turn/native-item/request IDs and severity for protocol/recovery failures. Never log secret answer values, credentials, full control payloads containing secrets, or uploaded image data. Unsupported native subtypes get one explicit provider response plus a useful typed notice, never an indefinitely pending request. +9. New behavior stays on the feature branch. Commit and push are allowed; PR creation, merge, and worktree cleanup await this change's explicit user approval. Follow the requested independent-review provider selection; configured first choice is `lunaroute/glm-5.3` through the trusted OpenCode transport, with actual generation availability established by the review. The fallback is exactly `openai/gpt-6.1-sol` with reviewer `variant:xhigh`, not the workflow's older GPT default. + +--- + +## Verified starting points and scope decisions + +The original explorer reports plus `load-bearing-finder-preliminary.md`, `load-bearing-strategist-preliminary.md`, the parent preliminary ledger and `reports/native-artifact-metadata.md` are preliminary planning evidence, not executed native proof or formal-stage completion. The final explorer reports under `.worktrees/.the-usual-logs/freshcodex-complete/reports/` and bounded source reads are the evidence for this plan, including the separate existing-branch pattern check and official native-artifact metadata report. The installed experimental-inclusive native protocol is `/tmp/freshcodex-schema-simy2pdx`, Codex 0.164.0. The checked-in 0.129.0 inventory is stale evidence, not the runtime authority. Exact wire shapes should be represented by behavior fixtures and parsed in the adapter; do not generate a new all-RPC SDK for this task. + +| Shared Freshagent surface | Freshcodex disposition and proof | +| --- | --- | +| Pane picker, create, saved-history resume, reload, recovery | Preserve current create dedup/native identity/ownership and prove two panes plus reload in Tasks 2, 4, 10. | +| Live answer, reasoning, tools, retry/error, usage, plan and diff | Repair native notification projection and snapshot convergence in Task 2. | +| `/new`, `/compact`, `/fork`, `/model`, `/undo` | Keep existing implementations; native compact takes only `threadId`, native fork/checkpoints remain intact; exercise in Tasks 7–10. | +| `/redo` and provider session-command menus | Keep honest Codex undo-only refusal and existing capability-filtered menu. Do not advertise arbitrary native RPCs as slash commands. | +| Model/thinking, approval policy, workspace/cwd | Native catalog and faithful values in Tasks 7–8. | +| Style/font and shared pane preferences | Existing provider-independent preferences remain; run the shared settings tests. | +| File access and planning | Add separate Codex File access and Work mode controls in Task 8. Plan/default is native `collaborationMode`, separate from approval and sandbox. | +| Approvals, questions, Stop, waiting/stuck, reconnect attention | Repair and extend the existing controls in Tasks 4–6, including genuinely blocking versus nonblocking questions. | +| Child activity and open-child action | Map current items and observe the exact child through its fenced parent provider context in Task 3; no child writer. | +| Images, shell escape, file checkpoints, names, handoff | Existing implementation retained and included in Task 10 regressions; image sending already exists. No new media-serving service. | +| CLI/MCP new/split/send/capture/wait/names | Implement Codex on existing `FreshAgentRestLane` in Task 9; preserve terminal `mode=codex` and its raw-resume validation. | +| Local and managed hosted provider | Same provider state remains below the split. All production paths receive the same behavior; hosted Docker proof is in Task 10. | +| Configured MCP elicitation | Ordinary flat primitive/enum form, `openai/form`/`openaiForm` equivalent flat schema, and URL confirmation are supported in Task 5. Complex forms and device-proof verification receive bounded explanatory refusal; no auth-proof impersonation or arbitrary form framework. | +| Audio/realtime, cloud-agent administration, remote environments, dynamic client tools, marketplace/account/auth administration | No existing shared Freshagent affordance requires these native APIs; not part of Freshagent parity. Native CLI owns its configured authentication. | + +The Plan choice is included deliberately: Codex's native collaboration-mode contract is absent from today's start-turn adapter, and the shared Claude pane already exposes a planning workflow. A normal text instruction is insufficient to claim Codex Plan mode. Selecting Plan sends `{ mode:'plan', settings:{ model, reasoning_effort, developer_instructions:null } }`; selecting Build sends the corresponding `default` mode. Both use the actual selected model/effort, because native collaboration settings override the parallel fields. No claim that Plan itself enforces file permissions: the separate sandbox choice remains authoritative for that. + +## File responsibilities and task interfaces + +Line numbers below locate the inspected base; use symbol names after earlier tasks change offsets. New files are scoped helpers, not replacements for the existing runtime. + +| Owner | Production files and one responsibility | +| --- | --- | +| Task 1 | New `scripts/testing/prepare-codex-contract.mjs`, `test/fixtures/coding-cli/codex-native-artifact.json`, `test/integration/providers/codex-local-provider.mjs`, `codex-cli-contract.mjs`, `codex-cli-contract.test.ts`: staged real-native evidence and early decision gates. `docker/cloud-run/Dockerfile`: test-only native artifact layer. `docker/runtime/provider-versions.json`, `docker/runtime/Dockerfile`, `scripts/testing/runtime-sandbox.ts`: managed Codex version alignment and truthful runtime receipts. | +| Task 2 | `crates/freshell-freshagent/src/codex/projection.rs` (new): current native turn/item projection and incremental patch production. `codex.rs:7532,7849,9214,9520`: consume/overlay through existing session/snapshot/mapper. `shared/fresh-agent-contract.ts`: additive normalized patch and measured usage contracts. `src/store/freshAgentLiveTypes.ts`, `src/store/freshAgentLiveSlice.ts`: isolated normalized live rows/patches. `src/lib/fresh-agent-ws.ts`, new `src/lib/fresh-agent-transcript-projection.ts`, `FreshAgentView.tsx`: validate/apply/reconcile/render shared rows. `FreshAgentSidebar.tsx` displays the latest native plan, and `FreshAgentDiffPanel.tsx` can render supplied native unified diffs. Existing context-usage helpers render observed usage. | +| Task 3 | New `codex/children.rs` and `FreshAgentChildObserver.tsx`: parent-scoped read-only child access. `codex.rs:9214`, `codex/metadata.rs`: current native item/child mapping. `shared/fresh-agent-contract.ts`: narrow item metadata additions if existing tool/delegation variants cannot represent a native fact. `FreshAgentItemCard.tsx`, `FreshAgentSidebar.tsx`, `FreshAgentView.tsx`: honest outcomes and provider-correct child action. | +| Task 4 | New `codex/turn_state.rs` plus `codex.rs:3348,3500,4039,10442`: identity-owned dispatch/turn/Stop transitions; keep existing reducer and emission fences. `FreshAgentView.tsx` send-failure restoration: correlate refused Codex sends and preserve composer text. | +| Task 5 | `codex/controls.rs`, its `tests.rs`, new `codex/elicitation.rs`: preserve native control meaning and normalize bounded MCP forms. `shared/fresh-agent-contract.ts`, `shared/ws-protocol.ts`, `crates/freshell-protocol/src/client_messages.rs`, `src/store/freshAgentTypes.ts`, `freshAgentSlice.ts`, `fresh-agent-ws.ts`: additive control metadata/answer values only where these existing contracts carry controls. `FreshAgentQuestionBanner.tsx`, `FreshAgentApprovalBanner.tsx`, `FreshAgentApprovalCard.tsx`, new `FreshAgentElicitationBanner.tsx`, `FreshAgentView.tsx`: accessible native decisions/questions/forms/URL confirmation. | +| Task 6 | `codex.rs`, `codex/controls.rs`, `crates/freshell-ws/src/lib.rs:659`, `providers/fresh_agent.rs`, `host_actor.rs`, gateway proxy and client reset handler: retain provider state before lossy bridge, independently drain after loss and combine replay marks. Reuse `attention_ledger.rs` unchanged unless a provider-neutral visibility adjustment is required. | +| Task 7 | `crates/freshell-codex/src/app_server.rs`, `model.rs`, new `catalog.rs`, its `lib.rs` exports; `crates/freshell-freshagent/src/model_capabilities.rs`; server/session-host catalog wiring; `shared/fresh-agent-models.ts`, `shared/fresh-agent-model-capabilities.ts`, `src/lib/fresh-agent-model-capabilities.ts`, registry, model dialog: installed catalog and faithful model/effort choices. | +| Task 8 | `StartTurnParams` and serializer, Rust fresh-agent create/send/configure types, `codex.rs` and new `codex/settings.rs`; `shared/fresh-agent-contract.ts`, `shared/settings.ts`, `shared/ws-protocol.ts`, `src/store/paneTypes.ts`, persistence normalizers, create-content helper, shared registry/settings button/view: apply/persist full settings and native Plan/default. | +| Task 9 | New `codex_rest_lane.rs`; `codex.rs` typed create/send outcomes and per-turn settlement subscription; `rest_lane.rs`, `lib.rs:2579`, `pane_ops.rs`, `crates/freshell-server/src/main.rs`; CLI/MCP capabilities and request parsers: adapter from existing automation into the same runtime. | +| Task 10 | Existing native fake fixture; Task 1 native harness for final regression; cloud-legal browser specs; managed-provider parity integration; `README.md` and `docs/index.html`: production proof and user-facing behavior documentation. No deployment runbook changes. | + +The current provider event stream is intentionally provider-native below the shared event envelope; the patch protocol is an additive normalized rendering path, not a new provider runtime. Existing client state files are `src/store/freshAgentTypes.ts`, `freshAgentSlice.ts`, `freshAgentLiveTypes.ts`, and `freshAgentLiveSlice.ts`; approval files are `FreshAgentApprovalBanner.tsx` and `FreshAgentApprovalCard.tsx`. All new helper names/signatures below are defined by this plan. + +## Source authority and early decision gates + +Task 1 is a real native/runtime version deliverable, not scaffolding to postpone evidence. It must execute the decisive native captures before Tasks 2–9 depend on them. The preliminary reports ran no native turns, so their shape/configuration observations are not passes. If a required capture fails, inspect exact-version native source/configuration and sanitized native errors, fix the owned fixture or narrowly supported configuration, and rerun. Do not proceed with an assumption merely because application tests can inject the expected RPC frames. F1/F2/F3/F4/F6/F7 retain separately attributable receipts even when one capture supplies several results. + +The following conservative projection design removes any dependency on a native-history/application-event shared watermark: + +1. `CodexProjection` retains event-owned active/unlanded native items, latest usage, plan and turn diff inside the existing owned session incarnation. Native full history remains the source of historical bodies. Normalize both using `build_codex_turn_json`/`map_codex_item`; keep existing row IDs, role segmentation, unique user nativeTurnId and checkpoint rules. +2. `freshAgent.transcript.patch` remains the one additive shared event. `{sessionId,projectionId,historyGeneration,ops}` carries safe-integer **consumed-event revisions only**. Ops are `resetTurn {nativeTurnId,turns}`, `upsertItem {nativeTurnId,row,item}`, `appendItem {nativeTurnId,rowId,itemId,field:'text'|'output'|'summary'|'content',index?,text}`, `settleTurn {nativeTurnId}`. Each op has a revision. Appends are admitted only for an event-owned item opened under the current native turn/operation and source generation; a late item/turn event cannot open an arbitrary historical turn. `resetTurn` with no rows removes a live group after accepted history mutation. +3. Append operations contain O(delta) bytes and mutate retained strings in place. Coalesce adjacent same-item/field/index appends for at most 33 ms; flush before item replacement/lifecycle/control/notice/teardown. Only structural row changes and item starts/completions carry full normalized state. Compare emitted byte growth for 1,000/2,000 equal deltas and assert exact content plus approximately linear growth. +4. Projection incarnation authority remains the existing runtime-owner epoch/generation and client snapshot-request binding generation. Introduce projectionId through authoritative create/attach/init; do not adopt a new incarnation from an arbitrary patch or UUID ordering. Rebind/materialization uses current aliases. Capture source and historyGeneration when starting a read; recheck after return. A read begun before accepted undo/compaction or source replacement is rejected and retried through the existing bounded scheduler, even if its native response arrives later. +5. A tracked snapshot combines explicitly distinct sources. Historical/final native bodies replace complete rows **atomically**. The retained event-owned active body is included at its captured eventRevision. An independently read incomplete native body is never an append baseline: when no safe event baseline exists after recovery, show it as a nonappendable native preview until a demonstrated native full replacement or new item-start baseline arrives. Task 1 must prove this eventually converges. Full `item/completed` event bodies replace their items, never concatenate them with native reads. A native terminal body supersedes event-owned state only when it contains the final items/error, or retain the completed event body until native history agrees; terminal status alone cannot erase content. +6. Snapshot metadata is `extensions.codex.projection = {id,eventRevision,historyGeneration,eventOwnedTurnIds,nativePreviewTurnIds,nativeFinalTurnIds}` with completed-item identities scoped by native turn. eventRevision describes **only the event-owned portion** captured in that snapshot. Client replays newer event ops only against that portion; a complete native item/turn replacement fences appends for that exact identity even when those queued ops have larger revisions. No native body is stamped as if it consumed a known notification prefix. Final/history membership derives from retained exact history and the current operation/source generation, never an evictable eight-entry convenience set. The existing eight-ended-turn cache may still deduplicate attention; it does not establish transcript finality or mutation membership. +7. Undo uses existing serialized native revert/rollback-record membership; removed identities cannot be recreated by late output. Compaction fences reads and replaces exactly the history native actually supplies; do not assume it deletes historical transcript rows. Mutation invalidation lasts for that history/source generation and existing durable rollback membership, not until an unrelated larger eventRevision. Fork keeps the existing identity/ownership transition and distinct child incarnation. +8. `mergeFreshAgentTranscriptProjection(snapshot,live)` preserves chronological positions and other turns while replacing event-owned groups and applying only permitted newer operations. Final live content retires when its exact native final content lands. Retain an honest diagnostic final/preview state if settlement needs recovery; never silently drop unlanded visible output. Hidden panes keep isolated live state without focus changes/per-token polling; reveal uses the existing dirty scheduler. A patch gap, `freshAgent.host.backpressure`, or `freshAgent.host.journal.reset` marks the source dirty and coalesces a resnapshot of authoritative provider-retained state. Reconnect/reload use native history plus retained event state and silent attention seeding. + +### Task 1: Prove the native 0.164 contract and align the managed Codex runtime + +**Files:** +- Create: `scripts/testing/prepare-codex-contract.mjs`; `test/fixtures/coding-cli/codex-native-artifact.json`; `test/integration/providers/codex-local-provider.mjs`; `test/integration/providers/codex-cli-contract.mjs`; `test/integration/providers/codex-cli-contract.test.ts`. +- Modify: `docker/cloud-run/Dockerfile`; `docker/runtime/provider-versions.json`; `docker/runtime/Dockerfile`; `scripts/testing/runtime-sandbox.ts` (Codex version receipt reads the pin instead of stale literal). +- Test: new native contract test and standalone offline contract runner; actual managed image native receipt using the existing owned `RuntimeSandbox` image preparation in `scripts/testing/runtime-sandbox.ts`. + +**Interfaces:** +- Produces: `prepare-codex-contract.mjs --output ABS` for connected verified artifact preparation and `--sandbox` to stage the completed artifact in the existing executable volume before any offline invocation. `--stage-prepared` copies only a verified prepared entry; it does not download. The helper writes `.runtime-build/codex-contract/staged.json` with the owned sandbox artifact path and integrity for the standalone runner. Cloud tests receive `FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64` from their test image. Neither runtime path fetches packages or falls back to PATH. +- Produces: `captureNativeScenario(name,options) -> Promise` in the native harness, with `{binary:{path,version,platform,integrity},threadId,requests,responses,notifications,reads,fixtureTraffic,observations}`. Scenario names are `text-history`, `active-input-stop`, `parent-child-read`, `plan-question-controls`, `mcp-form-url`, `model-effort-usage`; each has bounded deadlines and sanitized raw evidence. CLI `--scenario all --receipt-dir ABS` runs all; `--managed-runtime --receipt-dir ABS` prepares the existing run-owned managed image then runs the same real-native scenarios inside it with the image's intended `/usr/local/bin/codex`. +- Produces: claim-specific native receipts for F1/F2/F3/F4/F6/F7, selected actual feature/configuration flags/tool schemas and observed behavior used by later fixture expectations. No new universal protocol SDK, packaging framework, native lifecycle controller or production installation. + +- [ ] **Step 1: Add the failing behavioral contract and minimal executable harness** + +Use the existing Claude contract style as a fixture pattern, but implement Codex Responses/MCP exchanges from exact native evidence. Preserve raw request/response/native-ID ordering. A representative actual native contract assertion is: + +```ts +it('retains the actual final answer through exact history and resume', async () => { + const capture = await captureNativeScenario('text-history', { timeoutMs: 120_000 }) + expect(capture.binary.version).toBe('0.164.0') + const text = capture.observations.finalAssistantText + expect(text).toBe('First fragment and final fragment') + expect(capture.observations.resumedAssistantText).toBe(text) + expect(capture.notifications.some(frame => frame.method === 'item/agentMessage/delta')).toBe(true) + expect(capture.fixtureTraffic.every(request => request.loopback === true)).toBe(true) +}) +``` + +`finalAssistantText`/`resumedAssistantText` are derived from captured native thread/read bodies by native item ID, not injected normalized snapshots. Minimal fixture code and staging helper belong in this step so missing imports/prerequisites are fixed before the intended red run. Before changing the managed pin, run its actual binary receipt: the current runtime supplies 0.147.0, so the required 0.164.0 native-version assertion fails for the intended reason. No static version-string test substitutes for executing that runtime binary. + +- [ ] **Step 2: Stage before offline entry and establish intended red evidence** + +Known Linux x64 artifact metadata is exact: URL `https://registry.npmjs.org/@openai/codex/-/codex-0.164.0-linux-x64.tgz`, integrity `sha512-XLAoaWsGOT2khzl+P4FYDPTkzAr9W0VFXFaVkqlFdkM0soa1gH/4S0wSD57Ek3HHwkirAEGz2/Mwehseh+l5Mg==`, installed executable `vendor/x86_64-unknown-linux-musl/bin/codex`, unpacked package 444,349,626 bytes. Pin these in the test artifact JSON, verify downloaded bytes/manifest/actual layout and publish an atomic completed directory. Use a connected owned `.runtime-build/codex-contract` stage; the archive plus unpacked artifact must never occupy the 512 MiB `/tmp` or 256 MiB `.cache` runtime tmpfs together. `--sandbox` copies the verified artifact into a unique run/worktree subdirectory of the existing `freshell-sandbox-cargo-target` volume at `/freshell-test-artifacts`, using an owned container and the existing sandbox wrapper. Staging occurs before `--network none`; the offline harness only reads staged bytes and writes its owned home/receipts. + +Run: `node scripts/testing/prepare-codex-contract.mjs --sandbox`. + +Run: `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir .runtime-build/codex-contract/managed-red`. + +Expected: staging succeeds, with actual byte/integrity/layout receipt; managed required-version assertion FAILS on current 0.147.0. If staging/harness setup fails first, repair it and rerun so the red result is behavioral. These are future implementation actions after formal validation and independent plan review; no native execution occurred during plan publication. + +- [ ] **Step 3: Prove the isolated native flows, then align the managed pin** + +In `docker/cloud-run/Dockerfile`, after the connected node-builder copies source, run the preparer with `--output /opt/freshell-test-providers/codex/0.164.0-linux-x64`; copy that completed directory into the final test image and set the contract-root env above. In ordinary contract execution, missing/corrupt artifact is a loud prerequisite failure; never attempt a network package bootstrap inside offline runtime. Compare ambient `codex` resolution as a present-or-absent value before/after artifact preparation, not an assumption it exists in the cloud image. Root deps/lock/workspaces/.bin and host production CLI stay unchanged. Linux x64 is the required executing platform for these lanes; metadata for six published platforms is not execution proof. Add another platform receipt only when that actual compatible host executes the contract. + +Run the real 0.164 binary with owned HOME/CODEX_HOME/XDG/workspace, no inherited credentials/proxies, an owned loopback Responses endpoint and a local configured MCP server. Inspect exact-version native source/configuration and advertised tools first; determine necessary initialization capabilities/feature flags from actual evidence. Never infer working Plan/MCP/collaboration setup from schema spelling alone. The fixture selects tool calls from the names/schema actually advertised by that binary. No external model endpoint is called. + +**Hard early native gate, before dependent production implementation:** + +1. F1: capture native active read while streaming, final item/turn and exact full-history read, plus same-thread resume of interrupted/active history. Establish eventual complete final bodies and a recovery policy that converges from a nonappendable native preview without guessing a delta offset. Record native read-ahead/behind observations; application revisions make no stronger native ordering claim. +2. F2: idle acceptance, active accepted input or explicit refusal, actual same native turn IDs for steering, response versus turn-terminal ordering, Stop while initial acceptance is pending, interrupt acknowledgement versus actual terminal/error/death evidence. If explicit `turn/steer expectedTurnId` is the demonstrated safer mechanism, select that narrow implementation and record it; do not invent an extra turn or replay ambiguity. +3. F3: create one actual native child and observe it through the parent's running context during activity and after completion. Record zero Freshell child-start/resume calls. Prove either exact parent-client read or exact provider-owned saved history with honest pending/saved status and final convergence. No child release is inferred from completed activity. If no supported observer source supplies that view, stop Task 3 implementation until the read mechanism is demonstrated. +4. F7: actual Plan setup/tool exposure, blocking/nonblocking questions and resolution/cancellation lifetime, command/network/stdin approval alternatives and request/response IDs, configured MCP flat form and URL elicitation, selected model/effort, and usage semantics. Capture final history and native responses for each. If last.totalTokens is not demonstrated current context occupancy, display it as Latest usage from the native last sample and leave context utilization unknown. +5. F6: execute the staged binary from both assembled cloud test image and offline sandbox, record its actual path/version/integrity and fixture-only traffic. Metadata or a local global CLI pass is insufficient. + +Run: `scripts/sandbox-test.sh --runtime-suite "node test/integration/providers/codex-cli-contract.mjs --scenario all --receipt-dir /freshell-test-artifacts/codex-contract-early-receipts"`. + +Run: `GCLOUD_ROBOT_REQUIRE=1 pnpm run test:vitest run test/integration/providers/codex-cli-contract.test.ts --config config/vitest/vitest.config.ts`. + +Expected: actual pinned-native captures PASS in both required environments. Shared executions may supply several receipts but retain separate F1/F2/F3/F6/F7 conclusions. If one fails, diagnose/fix the fixture using native source/errors or select a demonstrated supported configuration and rerun; a fake app-server frame, skipped scenario, silent backend change or unexecuted platform declaration cannot replace it. Unavailable necessary execution remains a failed early gate; dependent features do not proceed on an invented guarantee. + +Only after that native gate, set Codex version to `0.164.0` in `docker/runtime/provider-versions.json` and `ARG CODEX_VERSION=0.164.0` in `docker/runtime/Dockerfile`. Preserve the existing install/version verification and every other provider pin. Make `scripts/testing/runtime-sandbox.ts` derive its Codex receipt version from the manifest instead of stale 0.147.0. Build the existing run-owned managed test image, execute its version and the essential native scenarios there, and retain separate F4 image/native receipts. No image is deployed and no live service is restarted. + +- [ ] **Step 4: Confirm green native/runtime evidence** + +Run: `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir .runtime-build/codex-contract/managed-green`. + +Run the exact sandbox and configured cloud native commands from Step 3. Expected: PASS; test artifact and managed runtime both actually execute Codex 0.164.0 with the essential captured contract. Reuse content-identical capture receipts instead of repeating expensive scenarios when nothing changed; changed pin/image must have its own actual binary receipt. + +- [ ] **Step 5: Refactor while green** + +Consolidate only the Codex fixture's owned process/deadline/cleanup and sanitized trace helpers. Keep artifact preparation outside offline execution and use one pinned metadata source. Do not move the wrapper into root dependencies or create a cross-platform package manager. Use real captured shapes to refine the existing fake fixture in each later feature task. + +- [ ] **Step 6: Verify impacts** + +Run: `pnpm run typecheck`. + +Run: `GCLOUD_ROBOT_REQUIRE=1 pnpm run test:vitest run test/integration/providers/codex-cli-contract.test.ts --config config/vitest/vitest.config.ts`. + +Run: `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir .runtime-build/codex-contract/managed-final`. + +Expected: PASS with nonzero native scenarios and owned runtime-image receipts. Record image digest, executing architecture and exact provider versions. Final full-suite/hosted/browser evidence remains in Task 10; fixture parity alone does not prove the upgraded managed binary. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/testing/prepare-codex-contract.mjs test/fixtures/coding-cli/codex-native-artifact.json test/integration/providers/codex-local-provider.mjs test/integration/providers/codex-cli-contract.mjs test/integration/providers/codex-cli-contract.test.ts docker/cloud-run/Dockerfile docker/runtime/provider-versions.json docker/runtime/Dockerfile scripts/testing/runtime-sandbox.ts +git commit -m "feat(codex): establish verified 0.164 native runtime contract" +``` + +### Task 2: Show native streaming, tool progress, usage, plan and diff without transcript duplication + +**Files:** +- Create: `crates/freshell-freshagent/src/codex/projection.rs`. +- Create: `src/lib/fresh-agent-transcript-projection.ts`. +- Modify: `crates/freshell-freshagent/src/codex.rs:7532,7849,9520`. +- Modify: `shared/fresh-agent-contract.ts`; `src/store/freshAgentLiveTypes.ts`; `src/store/freshAgentLiveSlice.ts`; `src/lib/fresh-agent-ws.ts`; `src/components/fresh-agent/FreshAgentView.tsx`; `src/components/fresh-agent/FreshAgentSidebar.tsx`; `src/components/fresh-agent/FreshAgentDiffPanel.tsx`; `src/lib/fresh-agent-context-usage.ts`. +- Test: new `test/unit/client/lib/fresh-agent-transcript-projection.test.ts`; existing `test/unit/client/lib/fresh-agent-context-usage.test.ts`; existing Freshagent WS/live-slice/view tests; new `test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx`; existing `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx`; embedded `codex::projection::tests` and consumer tests. + +**Interfaces:** +- Consumes: Task 1 actual native captures and source-authority decision; `CodexNotification::Other { method, params }`, existing mapper/builder and `FreshAgentEvent { provider,session_type,session_id,event }`. The Task 1 F1/F7 gate must pass before this production implementation. +- Produces: `CodexProjection::new(projection_id:String, thread_id:String)`, `apply(&mut self, method:&str, params:&Value) -> Result, ProjectionError>`, `begin_history_read(&self) -> SnapshotReadFence`, `accept_native_history(&mut self, raw:&Value, read_fence:&SnapshotReadFence) -> Result`; normalized incremental patch contract above. `SnapshotReadFence` carries projectionId, existing owner epoch/generation and historyGeneration. `ProjectionSnapshot` carries the chosen raw bodies, eventRevision and explicit event-owned/native-preview/native-final memberships described above. `SnapshotStale` requests one bounded reread after source/mutation change. `ProjectionError` includes native method and IDs, never full secret-bearing payload. +- Produces: `FreshAgentTranscriptPatchSchema`; `applyFreshAgentTranscriptPatch(state,patch)` and `mergeFreshAgentTranscriptProjection(snapshot,live)` using existing `FreshAgentTurn`/item types. Live payload remains outside the general session slice. + +- [ ] **Step 1: Write failing behavioral tests** + +In the new Rust module's tests, use native frames, not already normalized output. This representative test defines the projection seam; `snapshot_rows()` is the helper returning `build_codex_turn_json` over the projection's raw turn, implemented in that module so it exercises the real mapper: + +```rust +#[test] +fn native_text_delta_then_full_item_is_one_exact_item() { + let mut p = CodexProjection::new("runtime-1".into(), "thread-1".into()); + p.apply("item/started", &json!({ + "threadId":"thread-1","turnId":"turn-1", + "item":{"type":"agentMessage","id":"answer-1","text":""} + })).unwrap(); + let ops = p.apply("item/agentMessage/delta", &json!({ + "threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"Hel" + })).unwrap(); + assert!(ops.iter().any(|op| op["text"] == "Hel")); + p.apply("item/agentMessage/delta", &json!({ + "threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"lo" + })).unwrap(); + p.apply("item/completed", &json!({ + "threadId":"thread-1","turnId":"turn-1", + "item":{"type":"agentMessage","id":"answer-1","text":"Hello"} + })).unwrap(); + let rows = p.snapshot_rows("turn-1").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["items"], json!([{"id":"answer-1","kind":"text","text":"Hello"}])); + assert!(p.apply("item/agentMessage/delta", &json!({ + "threadId":"other","turnId":"turn-1","itemId":"answer-1","delta":"leak" + })).unwrap().is_empty()); +} +``` + +Also drive native frames through `insert_fake_session_with_real_consumer`, not only the pure helper. Before completion, assert the broadcast contains usable text and command-output appends. Send two interleaved threads, multiple items, reasoning summary/content indices, fragmented Unicode strings, patchUpdated file changes, MCP progress, a retry/error, and turn end. Assert final snapshot equals complete native history once, failures remain failures, and no old-turn event retires or overwrites the newer turn. Verify native usage `{ last,total,modelContextWindow }`: cumulative total is not last, cached/reasoning subsets are not counted twice, no fabricated money, and unavailable differs from measured zero. Plan/diff updates replace previous aggregate values. + +Drive the real patch reducer and merge helper through both independent-reader schedules. Event start plus `Hel` is event-owned; a native active read ahead at `Hello` must not become its append baseline, and the later `lo` yields `Hello` once. A native active read behind at empty must not erase `Hel`. When native full final `Hello` arrives before a queued larger-revision `lo`, final replacement fences that append. After more than eight later turns, a delayed old delta still cannot reopen the final turn. Recovery partial `prefix` is a nonappendable native preview; full item replacement/new safe start converges using the mechanism Task 1 actually proved. Hold a read across accepted undo/compaction and source replacement: old response is rejected by its source/history generation, not accepted because a larger event counter exists. Repeat event-owned snapshot revision 1 versus later append revision 2, full-item completion before/after native read, row split, hidden/reveal, disconnect/refetch and host reset/backpressure. Render through `FreshAgentTranscript`, not object comparisons alone. + +- [ ] **Step 2: Run and confirm intended failures** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent codex::projection`. + +Run: `pnpm run test:vitest run test/unit/client/lib/fresh-agent-transcript-projection.test.ts --config config/vitest/vitest.config.ts`. + +Expected: missing projection/event behavior, followed by explicit assertion failures when the minimal types exist; do not count an unresolved import as the final red receipt. + +- [ ] **Step 3: Implement the projection and existing transport integration** + +Add the module and session-owned state at all create/resume/recovery construction sites. `apply` validates thread/turn/item identity, updates only its owned raw records, and emits the incremental operations above. The core append operation is: + +```rust +fn append_string(target: &mut Value, field: &str, delta: &str) { + // The caller has validated the native item/field type. Mutate the owned + // String so token arrival does not repeatedly copy the full answer. + match target.get_mut(field) { + Some(Value::String(text)) => text.push_str(delta), + _ => target[field] = Value::String(delta.to_owned()), + } +} +``` + +Use native-indexed arrays for reasoning, whole replacements for `turn/plan/updated`, `turn/diff/updated`, and file patches, and item replacement for `item/completed`. Do not append aggregate diffs or full final text. The emitted delta carries only `delta`, even though the retained raw item accumulates content; reserve item upsert/reset for boundaries. No unsupported native method should crash the consumer; log unknown method once per method/incarnation at debug severity, with no payload dumping. Project provider retry/error notices using existing `freshAgent.retry`/`freshAgent.notice`; terminal failed-turn status/error remains owned by existing lifecycle handling. + +For usage, preserve raw native `last/total` and context window in `extensions.codex` and populate shared tokenUsage from native `total`. Add an optional shared `observed` boolean (absent remains backward compatible), set it only after an actual notification, and display unknown until then. Preserve native `last.totalTokens` as the measured native last sample without adding cached/reasoning subsets. Use it as current context occupancy only if Task 1 captured semantics establish that meaning; otherwise label it Latest usage and leave utilization unknown. Add optional shared `modelContextWindow`; enable a Codex model-window fallback in the existing context helper/view only for the demonstrated numerator meaning; label that denominator as model context limit, never as an invented compaction threshold. Preserve existing indexed compaction-threshold usage where actually measured, and leave unknown until both native numerator/denominator exist. Do not report cumulative lifetime input as current context. Latest plan steps and explanation live in `extensions.codex.plan` and render a small Plan section in the existing `FreshAgentSidebar` with pending/in-progress/completed statuses; there is no pre-existing generic plan panel to assume. Unified native turn diff is retained in projection and exposed as `FreshAgentDiffSummary.diff?:string` on a stable turn diff entry with title `Turn changes`. Extend the existing `FreshAgentDiffPanel` to display a supplied diff directly and use the current cwd/path fetch only when no provider diff was supplied; later native replacements update expanded content without appending or fetching an unrelated current git diff. Preserve native per-file change diff for approval previews. Tests expand actual supplied/updated diff content and verify no cwd/path fetch was needed. + +The consumer applies projection before broadcasting, within the existing per-session ordering barrier, and flushes pending patch ops before lifecycle/control events. On tracked snapshot, capture source/history read fence before native RPC, recheck it after return, choose event-owned versus whole native bodies by the source-authority contract, and stamp only the captured event-owned revision/memberships; reuse current builders. Never initialize an appendable body from an independent active read. Client parses the additive event, applies it in `freshAgentLive`, and merges normalized rows into the existing transcript props. Reuse current snapshot scheduler/request-authority checks and hidden-pane dirty handling. Implement the source-authority eight-point contract above in these helpers; do not scatter revision logic across render branches. + +- [ ] **Step 4: Run the focused tests** + +Run the same two commands from Step 2. Expected: PASS with growing pre-completion content, exact final replacement, and linear emitted bytes. + +- [ ] **Step 5: Refactor while green** + +Centralize native ID checks, indexed reasoning append, and final-item replacement inside `projection.rs`. Keep existing mapper/role segmentation as the single normalized representation. Coalesce patches in one session-owned batcher; remove temporary duplicate mapper/usage branches introduced during green development. Re-run the focused tests. + +- [ ] **Step 6: Run impacted verification** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-codex -p freshell-freshagent`. + +Run: `pnpm run test:vitest run test/unit/shared/fresh-agent-contract.test.ts test/unit/client/lib/ test/unit/client/store/ test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Expected: PASS. This shared live-state boundary affects all provider live rendering, so include the whole existing Freshagent client lib/store/component set, especially Claude stream landing, OpenCode projection and hidden-rebind/view tests. Retain the real browser pre-completion journey for Task 10. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/projection.rs shared/fresh-agent-contract.ts src/store/freshAgentLiveTypes.ts src/store/freshAgentLiveSlice.ts src/lib/fresh-agent-ws.ts src/lib/fresh-agent-transcript-projection.ts src/lib/fresh-agent-context-usage.ts src/components/fresh-agent/FreshAgentView.tsx src/components/fresh-agent/FreshAgentSidebar.tsx src/components/fresh-agent/FreshAgentDiffPanel.tsx test/unit/shared/fresh-agent-contract.test.ts test/unit/client/lib/fresh-agent-transcript-projection.test.ts test/unit/client/lib/fresh-agent-context-usage.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/freshAgentLiveSlice.test.ts test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.stream-render.test.tsx +git commit -m "feat(codex): project native live transcript and usage" +``` + +### Task 3: Preserve native items and open child conversations as parent-scoped observers + +**Files:** +- Create: `crates/freshell-freshagent/src/codex/children.rs`; `src/components/fresh-agent/FreshAgentChildObserver.tsx`; `test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx`. +- Modify: `crates/freshell-freshagent/src/snapshot.rs`; `crates/freshell-runtime-protocol/src/lib.rs`; `crates/freshell-runtime-client/src/lib.rs`; `crates/freshell-supervisor/src/service.rs`; `crates/freshell-session-host/src/main.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`; `crates/freshell-agent-runtime/src/host_actor.rs`; `crates/freshell-server/src/fresh_agent_proxy_rest.rs`; `src/store/paneTypes.ts`; `src/store/persistedState.ts` for observer selection, not a child runtime. +- Modify: `crates/freshell-freshagent/src/codex.rs:9214,9365`; `crates/freshell-freshagent/src/codex/metadata.rs`; `shared/fresh-agent-contract.ts`; `src/components/fresh-agent/FreshAgentItemCard.tsx`; `FreshAgentSidebar.tsx`; `FreshAgentView.tsx:4401`; `src/components/fresh-agent/FreshAgentComposer.tsx` only for an observed native-image attachment display/serialization gap. +- Test: mapper tests in `codex.rs`; `test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx`; `FreshAgentTranscript.test.tsx`; shared contract tests; child callback/view tests. + +**Interfaces:** +- Consumes: Task 1 F3 real-native parent/child read proof, Task 2 projection, existing `map_codex_item(item_id,&Value,item_type)`, metadata collector and shared transcript renderer. No independent child-resume/release guarantee is assumed. +- Produces: faithful normalized rows for `subAgentActivity`, `functionCallOutput`, `sleep`; parent-scoped child observation action. Existing `collab_agent` remains for multi-receiver native tool calls. New activity can reuse `task_delegation` with `childSessionId`, status/model fields added only if necessary; sleep can use a named tool row with duration. Function output becomes a visible tool-result with stable `toolUseId` based on its native ID, without requiring an absent preceding tool-use row. + +- Produces: `FreshAgentChildRead { parentSessionId:string, childThreadId:string, observedEpoch?:number, observedGeneration?:number }` and `FreshAgentChildObservation { parentThreadId:string, childThreadId:string, status:'live'|'saved'|'pending', snapshot:FreshAgentSnapshot }`. HTTP `GET /api/fresh-agent/threads/freshcodex/codex/{parentSessionId}/children/{childThreadId}` carries the existing observed owner pair as query parameters. `FreshCodexState::read_child(parent_session_id:&str,child_thread_id:&str,observed_epoch:Option,observed_generation:Option) -> Result` resolves the parent, verifies the native child relationship, and uses only the parent's proved read context. +- Produces: minimal managed `FreshAgentReadChildRequest {soul_id,child_thread_id,expected_control_epoch}` / `HostCommand::FreshAgentReadChild {incarnation_id,child_thread_id}` and corresponding `FreshAgentChildSnapshot(Value)` result through current typed client/supervisor/session-host dispatch. Gateway resolves the parent alias to its root soul, never resolves the child as a registered root. Recheck the existing same_snapshot_source tuple after read. +- Produces: `FreshAgentChildObserver({parent,childThreadId,onClose})` using `FreshAgentTranscript` in a read-only dialog/section. Parent includes its current provider/session/owner fence. Persist only `observedChildThreadId?:string` in parent pane content so reload can restore observation; no independent child pane/session lifecycle. + +- [ ] **Step 1: Write failing behavioral tests** + +Add to the existing item card suite: + +```tsx +it('renders a declined command as declined rather than completed', () => { + render() + expect(screen.getByLabelText('declined')).toBeInTheDocument() + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + expect(screen.getByText(/declined/i)).toBeInTheDocument() +}) +``` + +Pair command/file-change running/completed/failed/declined cases through the real transcript; expand a failed command and assert actual exit code appears. Native mapper tests feed exact 0.164 shapes: subAgentActivity started/interacted/interrupted/completed with agentThreadId, functionCallOutput string and typed content array, sleep duration; assert visible normalized content and stable IDs. Multi-child collab tests retain distinct receiver result/error/status/model and clicking one child selects that exact Codex identity. Upload an actual small image/text file through the composer and verify attachment name/path remains in the user transcript after reload; native image/localImage history retains a meaningful image label/path rather than a bare `[type]` placeholder. Assert image output references remain inspectable after reload using existing authenticated file/link affordances; no new media server or thumbnail promise is necessary. Include missing/unknown native variant: structured diagnostic and graceful history, no empty assistant/user row. + +- [ ] **Step 2: Confirm red** + +Run: `pnpm run test:vitest run test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx --config config/vitest/vitest.config.ts`. + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent codex::`. + +Expected: declined currently appears complete, new variants are dropped, child callback cannot select Codex. + +- [ ] **Step 3: Implement faithful mappings and rendering** + +Extend current mapper and role classifier; map native subagent activity to a task/delegation activity with child native identity and direct status mapping. Preserve function-call output plain text and content-item text/image references; unknown content items keep a readable type/result representation, not blank output. Map sleep to a visible tool activity with actual duration. Preserve optional agent-message phase/async choice metadata as optional item extensions used for a readable choice list; never infer completion merely from absent phase. + +Extend `FreshAgentToolDisplay.status` with `'declined'` and preserve exitCode. In command and file-change adapters use: + +```ts +const status = item.status === 'running' ? 'running' + : item.status === 'declined' ? 'declined' : 'complete' +``` + +`FreshAgentToolBlock` renders declined with a neutral icon plus accessible label `declined`, completed with the existing success semantics, failed with existing error semantics, and exit status in expanded command details. Keep the tool renderer shared. + +Keep the OpenCode resume action unchanged. Add a distinct Codex `observe-child` action carrying the parent reference/fence and selected child native ID. `FreshAgentChildObserver` fetches the new parent-scoped route and renders existing shared transcript components; it never mounts the ordinary create/attach FreshAgentView path or offers create/resume/send/stop/configure. No native child status, including completed, authorizes a writer claim. Deliberate user opening/closing the observer follows existing accessible focus return; background provider events do not change focus or replace the parent. + +For local reads resolve the parent's current registered client, verify child membership in retained/native parent activity, and call exact child `thread/read(includeTurns:true)` through that same client using the Task 1 demonstrated mechanism. If Task 1 selected exact provider-owned saved history, use that source with honest saved/pending status and final convergence. Never consult an unrelated HOME or start a child sidecar. Capture/recheck parent source and history-generation fence across the read. + +Managed reads add the minimal parent-root typed request described above, run in its provider volume, and return the child under an explicit observer response. The existing root snapshot route is not an arbitrary-child reader and must retain its root identity checks. The observer response stamps child capabilities nonmutating. Route tests use deliberately different gateway/provider child histories, verify the right result, reject an unrelated child and a replaced owner during a paused read, and count zero child-start/resume RPCs. Component/browser tests restore the observer after reload while parent identity/transcript/actions remain intact. + +- [ ] **Step 4: Confirm green** + +Run both Step 2 commands. Expected: PASS; all new variants render, declined is never success, parent-scoped child observation returns exact content without a child writer. + +- [ ] **Step 5: Refactor while green** + +Consolidate child-status/result formatting in one focused helper shared by collab/activity/sidebar. Reuse existing generic tool/delegation blocks and remove dead duplicated branches only if the task actively changed/read them. Preserve OpenCode callback behavior. + +- [ ] **Step 6: Verify impacts** + +Run: `pnpm run test:vitest run test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent codex::`. + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-freshagent -p freshell-server -p freshell-session-host -p freshell-runtime-client -p freshell-runtime-protocol -p freshell-supervisor child`. + +Expected: PASS with nonzero parent/child route, serializer and source-fence tests, including existing OpenCode delegation and Claude tool rows. Task 10 supplies denied-tool/child/reload browser receipts. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/metadata.rs shared/fresh-agent-contract.ts src/components/fresh-agent/FreshAgentItemCard.tsx src/components/fresh-agent/FreshAgentSidebar.tsx src/components/fresh-agent/FreshAgentView.tsx test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +git add crates/freshell-freshagent/src/codex/children.rs crates/freshell-freshagent/src/snapshot.rs crates/freshell-runtime-protocol/src/lib.rs crates/freshell-runtime-client/src/lib.rs crates/freshell-supervisor/src/service.rs crates/freshell-session-host/src/main.rs crates/freshell-session-host/src/providers/fresh_agent.rs crates/freshell-agent-runtime/src/host_actor.rs crates/freshell-server/src/fresh_agent_proxy_rest.rs src/store/paneTypes.ts src/store/persistedState.ts src/components/fresh-agent/FreshAgentChildObserver.tsx test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx +git commit -m "feat(codex): preserve native tool and child activity" +``` + +### Task 4: Keep owed turns and Stop correct across steering, rejection and response races + +**Files:** +- Create: `crates/freshell-freshagent/src/codex/turn_state.rs`. +- Modify: `crates/freshell-freshagent/src/codex.rs:3348,3500,4039,10442`; `crates/freshell-codex/src/app_server.rs` and its `lib.rs` exports only if the Task 1 evidence selects explicit native turn/steer; `src/components/fresh-agent/FreshAgentView.tsx` send-refusal branch. +- Test: embedded Codex ChannelTransport tests; new turn-state tests; FreshAgentView queued-send/refusal tests. + +**Interfaces:** +- Consumes: existing turn lock, event-emission gate, active/native turn IDs, dispatch request ID, user-interrupt marker, QuietDeadman, ownership fences. +- Produces: `CodexTurnState` with `begin_dispatch(request_id:&str)->DispatchToken`, `accept(token,turn_id:&str)->StopDisposition`, `reject(token)->bool`, `mark_ambiguous(token:DispatchToken)`, `request_stop()->StopDisposition`, `interrupt_ack(token:DispatchToken,turn_id:&str)`, `settle_native(turn_id:&str,evidence:NativeSettlement)->bool`, and `has_owed_work()->bool`. `NativeSettlement` is completed/interrupted/failed native terminal evidence or confirmed owned runtime exit, never interrupt acknowledgement/quiet-deadman attention. `DispatchToken` is monotonically session-local and equality-owned; `StopDisposition` is `None | Latched | Interrupt { dispatch:DispatchToken, turn_id:String }`. Keep compatibility latches mirrored from this one transition source until consumers can use it directly. +- Produces: identity-owned turn facts usable by Task 9 typed outcomes without inspecting the broadcast bus. Ordinary browser busy sends retain the existing client queue policy; server/API follow-up sends use the actual native start/expected-turn steering mechanism selected by Task 1 F2 proof. Do not replace supported native steering with a new server queue or paid retry. + +- [ ] **Step 1: Add failing race tests** + +The pure ownership test defines the intended transition behavior: + +```rust +#[test] +fn rejected_steer_and_old_stop_cannot_retire_a_new_turn() { + let mut turns = CodexTurnState::default(); + let first = turns.begin_dispatch("first"); + turns.accept(first, "old"); + let steer = turns.begin_dispatch("steer"); + turns.reject(steer); + assert!(turns.has_owed_work()); + assert!(matches!(turns.request_stop(), StopDisposition::Interrupt { turn_id, .. } if turn_id == "old")); + let next = turns.begin_dispatch("next"); + turns.accept(next, "new"); + turns.interrupt_ack(first, "old"); + assert!(turns.has_owed_work()); + assert!(!turns.settle_native("old", NativeSettlement::Interrupted)); + assert!(turns.has_owed_work()); + assert!(turns.settle_native("new", NativeSettlement::Completed)); + assert!(!turns.has_owed_work()); +} +``` + +Also test Stop before native acceptance latches and dispatches once when the turn ID arrives; same-ID accepted steer is the same owed native turn; rejected initial dispatch leaves idle; rejected steer preserves the earlier owed turn; exit-induced dispatch error leaves death classification to the exit owner. ChannelTransport tests hold the old interrupt response, accept a new turn, release old response, and assert new busy/interrupt target survives. Hold successful interrupt acknowledgement without any terminal event and assert busy/REST waiter remain outstanding; then deliver matching terminal evidence and settle once. Same-ID steering creates two accepted dispatch facts but one native owed turn and one terminal settlement. Preserve accepted-before-immediate-completion and lifecycle-loss dedup tests. Inject ownership refusal during recovery and assert **both** pane-scoped SESSION_RESERVED and request-correlated send error are emitted; browser restores that refused prompt without overwriting a later typed draft. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent codex::`. + +Run the existing FreshAgentView send/queue/refusal test files through `pnpm run test:vitest run test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Expected: old interruption clears new turn; rejected steer clears previous latch; pre-accept Stop fails; interrupt acknowledgement incorrectly retires work; refused Codex prompt is not restored. + +- [ ] **Step 3: Implement identity-owned transitions** + +Replace unconditional shared-latch retirement with matching dispatch/native-turn transitions. An interrupt captures its owned native turn/token before awaiting; response success records Stop acknowledgement/stopping only. It never retires owed work, settles a REST waiter or claims a terminal turn. Actual matching native terminal/confirmed-exit evidence retires that identity, while an old acknowledgement cannot alter a newer turn. Failed interrupt disarms only its own user-stop marker. Stop during an unresolved initial start sets a latch for that dispatch, then sends native interrupt once accepted; `turn/started` cannot erase a stop already latched for that dispatch. A newer accepted native turn cannot inherit an older marker. Retain compact's separate guarded native turn identity. + +Task 1 F2 is a hard prerequisite. For the demonstrated native follow-up mechanism, keep acceptance distinct from settlement: acceptance may return the existing native turn; do not claim a new turn or duplicate completion. On explicit rejection remove only that request's pending dispatch; on transport timeout/uncertain acceptance use mark_ambiguous and retain the unresolved ownership fact until actual evidence resolves it, and derive busy/deadman state from all still-owed work. Do not automatically resend an ambiguous timeout/transport result: preserve existing recoverability/typed error semantics and native identity. If early native proof selected explicit `turn/steer`, add only its native client helper with required `expectedTurnId` and preserve its no-new-turn/no-overrides semantics; the browser keeps its current queued-send policy and needs no separate steering UI. + +At main's SESSION_RESERVED recovery refusal, call existing `send_error(&request_id,...)` before pane error, retaining request and holder diagnostics. Expand the existing prompt-restoration branch to Codex request-correlated refusals and restore only when it will not destroy newer user input; keep the refused text recoverable through the existing notice/composer mechanism. No blanket retry. + +- [ ] **Step 4: Confirm green** + +Run Step 2 commands. Expected: PASS with deterministic barriers, no sleeps as race proof. + +- [ ] **Step 5: Refactor while green** + +Make all send/started/completed/interrupt/death/close paths call the same transition methods. Remove temporary duplicate owned-turn booleans only after all existing tests are green. Keep lock order rollback registry → turn lock → short state guard; never await while holding a standard mutex or take the turn lock from the consumer behind the existing emission gate. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-codex -p freshell-freshagent -p freshell-ws`. + +Run: `pnpm run test:vitest run test/unit/client/components/fresh-agent/ test/unit/client/lib/ --config config/vitest/vitest.config.ts`. + +Expected: PASS, including crash, compact, fork, rollback and stale-send fences. Process-fault tests remain in Docker. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/turn_state.rs src/components/fresh-agent/FreshAgentView.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +git add crates/freshell-codex/src/app_server.rs crates/freshell-codex/src/lib.rs crates/freshell-codex/tests/interrupt_rpc.rs +git commit -m "fix(codex): preserve turn ownership across send and stop races" +``` + +### Task 5: Make native approvals, questions and configured MCP prompts usable, and pause only for blocking input + +**Files:** +- Modify: `crates/freshell-freshagent/src/codex/controls.rs`; `codex/controls/tests.rs`; `codex.rs` deadman arming/firing; shared question/pending-control schemas; existing client WS/store/banner/View files from the responsibility map; `crates/freshell-ws/src/terminal.rs`; `crates/freshell-ws/src/hosted_fresh_agent.rs`; `crates/freshell-server/src/fresh_agent_proxy.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`. +- Create: `crates/freshell-freshagent/src/codex/elicitation.rs`; `src/components/fresh-agent/FreshAgentElicitationBanner.tsx`; `test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx`. +- Test: controls/elicitation/deadman tests; question/approval/elicitation component tests; shared response-contract tests. + +**Interfaces:** +- Consumes: Task 1 actual native Plan/approval/question/MCP setup and lifetime proof; existing `normalize_request`, pending request registry/native JSON-RPC RequestId, approval/question respond messages, Task 4 owed-work state. +- Produces: per-question `isOther?:boolean`, `isSecret?:boolean`; pending-question `isBlocking?:boolean` (Codex always stamps installed value; absent legacy requests preserve existing blocking behavior). Approval public metadata carries kind, network host/protocol, offered decisions, and requested permission details without dropping the original method/ID. +- Produces: existing string-valued question answers preserved verbatim, wrapped as one native answer per native single-choice/free-entry question (no comma parsing or new cross-provider answer union), plus a separate narrow elicitation response `{requestId,action:'accept'|'decline'|'cancel',content:Record|null}`. Add `FreshAgentElicitationRespond` / `freshAgent.elicitation.respond` through `ClientMessage`, the WS schema/export unions, `terminal.rs` dispatch, `HostedFreshAgentCommand::Elicitation`, gateway proxy and session-host provider dispatch. Hosted forwarding reuses the existing durable `resolve_permission(decision_id,Value)` operation with a tagged elicitation action/content payload; the Codex provider routes it to `handle_elicitation_respond`, while other providers return explicit unsupported. No new supervisor control opcode is needed; do not send it as model text. The request event `freshAgent.elicitation.request` must also join the existing `PermissionRequested`/actor `record_permission` durable pending-decision path; adding a response variant alone is insufficient. Extend the existing name-based conversion in `providers/fresh_agent.rs`, retain the public native request before broadcast, and expose pending controls through authoritative retained state for Task 6 loss reconciliation. +- Produces: session-owned atomic `blocking_control_count` synchronized by pending mutations, used in both deadman arm and fire validity. All approvals and ordinary supported MCP elicitations are blocking; Codex question uses native `isBlocking`. + +- [ ] **Step 1: Write failing request→UI→response and deadman tests** + +Extend existing controls `runtime()/event()/snapshot()` fixture. This representative assertion exercises a real native request: + +```rust +#[tokio::test] +async fn codex_question_keeps_secret_other_and_blocking_flags_on_reload() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(51), "item/tool/requestUserInput", json!({ + "threadId":"thread-1","turnId":"turn-1","itemId":"ask-1","isBlocking":true, + "questions":[{"id":"token","header":"Token","question":"Enter token", + "options":null,"isOther":false,"isSecret":true}] + })); + let request = event(&mut rx).await; + assert_eq!(request["questions"][0]["isSecret"], true); + assert_eq!(request["isBlocking"], true); + let pending = snapshot(&state, &peer).await; + assert_eq!(pending["pendingQuestions"][0]["questions"][0]["isOther"], false); + assert_eq!(pending["pendingQuestions"][0]["questions"][0]["isSecret"], true); + peer.disconnect(); + state.shutdown().await; +} +``` + +Component tests type and submit a secret free-form answer containing commas and punctuation, assert input type=password, and assert exact question ID/value—not comma-split values—in native response. For native options plus `isOther:false`, Other is absent; options absent still provides the required free-form input. Legacy non-Codex questions retain their current Other behavior. Test multiple questions/controls, response failure retry, wrong/stale ID, native `serverRequest/resolved`, Stop/turn completion cancellation and reload exactly once. + +Approval cases: command absent with network host/protocol; `writeStdin`; command/file decline; availableDecisions with cancel versus decline; session accept only when offered; permission `fileSystem.entries` and network grants. Display exactly what is requested. Test full native response—not a shared allow flag only—and ensure unsupported structured rule amendments are not falsely offered. Existing pane-scoped Always allow must not auto-grant a native decision outside the offered choices. + +Hosted tests must observe an actual durable pending decision before clicking the elicitation answer, then reload/reconnect and prove the same request remains answerable. Retained native pending state is the recovery source if upstream broadcast loses the request before conversion; Task 6 must drain that state without waiting for another frame. + +MCP cases: flat form with required string, integer/number, boolean and enum/string-array enum; `openai/form` and `openaiForm` aliases for the same representable flat shape; URL confirmation preserving url/elicitationId and action; decline and cancel; invalid required/type/range/enum answer; missing/wrong optional turn identity; secret-like/writeOnly field mask where schema advertises it; unsupported nested schema and `openai/userVerification` produce one explanatory native error/decline and no pending banner. URL opening alone never reports acceptance. + +Use the existing short quiet-window seam: unanswered approval, blocking question and MCP prompt survive multiple windows without stuck/completion; nonblocking question permits ordinary deadman detection while the turn is genuinely silent; answering one of two blocking controls keeps it paused; draining the final blocking control rearms a fresh full quiet window; provider activity clears stuck; close/death cancels waiters. Recheck membership at deadline firing to cover an arrival racing an already armed timer. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent codex::`. + +Run: `pnpm run test:vitest run test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/ test/unit/client/lib/fresh-agent-ws.test.ts --config config/vitest/vitest.config.ts`. + +Expected: native flags lost, wrong visible choices/plain text secret input, MCP rejected, and legitimate blocking input trips deadman. + +- [ ] **Step 3: Extend the existing control path and bounded elicitation UI** + +Normalize native fields into the existing public control records and live events identically. Keep RequestId string/integer identity through all serialization; request keys remain stable on reload. Managed `decision_id` is an opaque routing key using the existing unambiguous JSON/native-ID mapping; keep original native ID type in retained request data. Test integer 41 versus string "41" concurrently so hosted conversion cannot collapse them, and respond with the original native RequestId. Store original native params privately for exact validation/response and turn cancellation. Never restore secret entered values after reload, store them in pane persistence, or log them. + +`FreshAgentQuestionBanner` passes flags through its current question props. Show Other only when native allows it; use free entry for questions with no options; mask secrets. Keep native single-choice/free-entry values verbatim. Respond native `{answers:{[questionId]:{answers:[...]}}}` without splitting human answer text. On response, check request still pending and belongs to the same turn/incarnation immediately before send; cancelled old request cannot consume a later question's answer. + +Approval cards show a bounded plain-language description for command, stdin, network, file change and additional filesystem entries, then only supported offered choices. Continue existing simple Allow/Deny where valid, add Stop/cancel distinctly when offered, and validate requested decision against native `availableDecisions`. `decline` continues, `cancel` interrupts; do not translate both into the same decision. Permissions responses grant explicit requested subset or none with honest turn/session scope; no persistent policy editor is required. + +After the early native gate has established actual elicitation setup/lifetimes, `elicitation.rs` accepts only representable flat object properties using primitive/enum rules, required membership, numeric/string bounds, and supported format hints. Normalize it into the new small banner, preserving title/message, field ID/type/default/allowed choices and secret hint. Use native `{action,content}`. For URL, show server name/message/link and explicit Continue/Decline/Cancel; open through the existing browser/link affordance and wait for user confirmation. A verified unrepresentable subtype receives the explicit unsupported result and a notice naming the subtype; do not leave the provider blocked or fabricate a successful form. Ensure turnless elicitation remains scoped to the owning native thread and is cleared on thread death/close. + +Publish `blocking_control_count` before arming/draining transitions. The required deadman condition is: + +```rust +let should_watch = turn_state.has_owed_work() + && blocking_control_count.load(Ordering::SeqCst) == 0; +``` + +Apply it both when feeding/arming and when the timer validates its generation at firing. Draining the final blocker feeds current activity and rearms a full window; nonblocking questions do not fake idle/completion. Keep waiting attention and turn completion distinct. + +- [ ] **Step 4: Confirm green** + +Run Step 2 commands. Expected: PASS; exact native responses and retained metadata through snapshot/live path, no false deadman for blocking controls. + +- [ ] **Step 5: Refactor while green** + +Centralize pending insert/remove/drain transitions so every route updates count, cancellation event and waiting state once. Keep typed bounded form validation in `elicitation.rs`; do not embed JSON-schema interpretation in the main view. Keep provider-specific decision translation in Codex controls. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-codex -p freshell-freshagent -p freshell-ws -p freshell-session-host`. + +Run: `pnpm run test:vitest run test/unit/shared/ test/unit/client/components/fresh-agent/ test/unit/client/lib/ test/unit/client/store/ --config config/vitest/vitest.config.ts`. + +Expected: PASS. The new typed response goes through ordinary and managed WS ingress, and old Claude/OpenCode approval/question flows remain intact. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/controls.rs crates/freshell-freshagent/src/codex/controls/tests.rs crates/freshell-freshagent/src/codex/elicitation.rs crates/freshell-protocol/src/client_messages.rs crates/freshell-ws/src/terminal.rs crates/freshell-ws/src/hosted_fresh_agent.rs crates/freshell-server/src/fresh_agent_proxy.rs crates/freshell-session-host/src/providers/fresh_agent.rs shared/fresh-agent-contract.ts shared/ws-protocol.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/lib/fresh-agent-ws.ts src/components/fresh-agent/FreshAgentQuestionBanner.tsx src/components/fresh-agent/FreshAgentApprovalBanner.tsx src/components/fresh-agent/FreshAgentApprovalCard.tsx src/components/fresh-agent/FreshAgentElicitationBanner.tsx src/components/fresh-agent/FreshAgentView.tsx test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentApprovalBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx test/unit/client/lib/fresh-agent-ws.test.ts +git commit -m "feat(codex): honor native controls and MCP elicitation" +``` + +### Task 6: Recover authoritative Codex state and attention through hosted event loss + +**Files:** +- Modify: `crates/freshell-freshagent/src/codex.rs`; `crates/freshell-freshagent/src/codex/projection.rs`; `codex/controls.rs`; `crates/freshell-ws/src/lib.rs:659`; `crates/freshell-session-host/src/providers/fresh_agent.rs`; `crates/freshell-agent-runtime/src/host_actor.rs`; `crates/freshell-server/src/fresh_agent_proxy.rs`; `src/lib/fresh-agent-ws.ts`; `src/components/fresh-agent/FreshAgentView.tsx` reset/backpressure snapshot triggers. +- Create: `crates/freshell-ws/tests/freshagent_codex_attention_replay.rs`. +- Test: new replay target; existing `freshagent_claude_attention_replay.rs`; Codex edge/death/controls tests; client attention replay tests. + +**Interfaces:** +- Consumes: existing `AttentionLedger`, Codex monotonic completion/waiting clocks, current negotiated `freshAgentAttentionReplayV1`. +- Produces: `FreshCodexState::attention_marks() -> Vec` and retain-before-broadcast helpers; combined existing ready payload. The provider state, not what happened to reach the gateway, is authoritative. +- Produces: `FreshCodexState::retained_state(session_id:&str) -> Result` with existing source identity, event-owned projection/memberships, attention marks and pending public controls; `subscribe_retained_state(session_id:&str) -> watch::Receiver` is a coalesced wakeup for changes, not a new history clock/journal. Native facts update retained state before best-effort broadcast. Expose this through the existing provider transport/snapshot route; an optional default-unsupported trait method for non-Codex providers preserves their behavior. +- Produces: one owned bridge recovery worker that reacts to the coalesced wakeup/loss, awaits downstream channel capacity without holding provider locks, and drains the latest retained source. It sends current marks/projection resync in a properly routed `FreshAgentEvent` envelope using the existing `freshAgent.host.backpressure` recovery event with retained/source data, plus every retained pending control through its durable `PermissionRequested` conversion. The retained snapshot includes last exact native history plus event-owned active state and does not wait for another native RPC merely to recover a dropped frame. Gateway/client apply this fenced retained state immediately and request native history refresh separately through the bounded scheduler. It runs even when the dropped frame was the last frame; no later provider token is required. Gateway ready/reset reconciliation reads this retained source under the existing host/incarnation fence, rather than treating its lossy journal as complete history. + +- [ ] **Step 1: Write red replay tests** + +Mirror the real WS harness in the existing Claude replay target and drive Codex native completion/control facts through its actual state/consumer. After disconnect: complete/fail turn, reconnect negotiated client, compare exact retained timestamp and session type; request blocking question/approval, reconnect, see waiting once; answer last pending control, reconnect, waiting absent but completion retained. Unrequested death retains completion and clears dead controls, explicit close forgets all aliases, materialization maps both old and canonical identity, unnegotiated ready contains no replay. Also prove first load/reload silent seeding, same-page reconnect alerts once, no double ring after late duplicate completion. Saturate both the upstream broadcast and downstream provider-event channels, drop the final completion/waiting/control frame, then produce **no more provider frames**. Drain capacity and assert visible transcript/retained attention recover and the exact pending question/elicitation becomes a durable answerable decision. Repeat gateway journal reset and source replacement while recovery is paused; old-incarnation retained data is rejected. No attention/focus event from the other pane may leak. + +The ledger acceptance used by provider tests is: + +```rust +state.attention.record_turn_complete("thread-1", "freshcodex", 100); +state.attention.record_waiting("thread-1", "freshcodex", 101); +state.attention.clear_waiting("thread-1"); +let marks = state.attention_marks(); +assert_eq!(marks.len(), 1); +assert_eq!(marks[0].last_turn_complete_at, Some(100)); +assert_eq!(marks[0].last_waiting_at, None); +``` + +This pure assertion supplements, rather than replaces, the consumer→WS ready tests. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-ws --test freshagent_codex_attention_replay`. + +Expected: real Codex marks are absent from ready replay and the current bridge loses a final edge/control before journal without an independent drain. Add the compiling test harness first so the red receipt is behavioral. + +- [ ] **Step 3: Integrate the existing ledger** + +Add clone-shared `Arc` to Codex state; observe sessions at admission. Completion and synthesized death/deadman edges record timestamp **before** broadcasting. Waiting transitions retain before broadcast; final pending drain clears waiting; explicit close forgets; materialization aliases; unrequested death keeps completion. Reuse existing clocks/dedup gates, never mint a second clock solely for replay. Combine `fresh_claude.attention_marks()` and `fresh_codex.attention_marks()` at the current capability-gated ready construction. For managed providers the existing arbitrary payload/extension forwarding already works; retain it. Repair the source-proven loss before journaling: `spawn_broadcast_bridge` currently ignores lagged broadcast records and `try_send` drops generic provider events, while its backpressure notice waits for a later frame. Retain Codex facts before that boundary and use the reliable coalesced worker above to publish latest state once capacity is available, even after a dropped last edge. Keep ordinary token patches best effort; on a gap/loss send one resync and read retained state rather than replaying an unbounded token journal. Pending elicitation/approval/question requests use the same existing durable actor conversion as Task 5, including requests missed by broadcast. + +At the gateway, retain authoritative host marks for ready replay and refetch the provider-retained source on journal reset/backpressure; recheck current source tuple before applying it. Client `fresh-agent-ws`/View marks a patch gap, host backpressure or host journal reset dirty and schedules one coalesced snapshot; visible rows stay until authoritative replacement. Do not wait for a later provider frame to trigger recovery. A gateway-only ledger cannot reconstruct an edge lost before the journal, so it is not the sole source. + +- [ ] **Step 4: Confirm green** + +Run Step 2 command. Expected: PASS with exact timestamps and capability gating. + +- [ ] **Step 5: Refactor while green** + +Route all Codex attention edges through focused retain-and-broadcast helpers; consolidate only the touched bridge recovery paths and reuse existing durable pending-decision recording, without a new generic event journal; keep the generic ledger and Claude observer contract intact. Remove any duplicate controls waiting clock only if the same monotonic timeline is fully preserved and tests prove it. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-freshagent -p freshell-ws -p freshell-session-host -p freshell-agent-runtime -p freshell-server`. + +Run existing client attention/ready tests through `pnpm run test:vitest run test/unit/client/lib/ test/unit/client/store/ --config config/vitest/vitest.config.ts`. + +Expected: PASS, including Claude/Kilroy silent seed and alias eviction behavior. Task 10 adds browser offline/hidden/two-pane proof. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/controls.rs crates/freshell-ws/src/lib.rs crates/freshell-ws/tests/freshagent_codex_attention_replay.rs crates/freshell-server/src/fresh_agent_proxy.rs +git add crates/freshell-freshagent/src/codex/projection.rs crates/freshell-session-host/src/providers/fresh_agent.rs crates/freshell-agent-runtime/src/host_actor.rs src/lib/fresh-agent-ws.ts src/components/fresh-agent/FreshAgentView.tsx +git commit -m "fix(codex): recover retained state after hosted event loss" +``` + +### Task 7: Use installed model capabilities without changing the selected model or effort + +**Files:** +- Create: `crates/freshell-codex/src/catalog.rs`. +- Modify: `crates/freshell-codex/src/app_server.rs`; `model.rs`; `lib.rs`; `crates/freshell-freshagent/src/model_capabilities.rs`; Codex create/send/configure/fork normalization; `crates/freshell-runtime-protocol/src/lib.rs`; `crates/freshell-runtime-client/src/lib.rs`; `crates/freshell-supervisor/src/service.rs`; `crates/freshell-session-host/src/main.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`; `crates/freshell-agent-runtime/src/host_actor.rs`; `crates/freshell-server/src/fresh_agent_proxy.rs` for scoped hosted probe wiring; `crates/freshell-opencode/src/catalog.rs`; `shared/fresh-agent-models.ts`; `shared/fresh-agent-contract.ts:54`; shared capability definitions/registry/model dialog helpers and components. +- Test: new Codex catalog tests; existing model tests; model-capability Rust tests; `test/unit/shared/fresh-agent-models.test.ts`; existing model dialog/registry/create-content tests. + +**Interfaces:** +- Consumes: Task 1 actual native catalog/model/effort evidence, current five-minute TTL/singleflight registry and the ordinary pane-create acknowledgement/current runtime owner. Reuse the actual initialized local/session-host app-server; no new conversation or gateway-global provider process is created for catalog discovery. +- Produces: `CodexAppServerClient::list_models(cursor:Option<&str>) -> Result` and a parser in `catalog.rs`. Follow every nextCursor. Use native runnable `model` ID and displayName, supported/default efforts and input modalities. Optional `defaultEffort:string`/`inputModalities:string[]` extend shared/Rust capability rows; absent values preserve other providers. Internal model limits stay internal. +- Produces: `FreshAgentCodexCatalogContext {sessionId:string,sessionRef?:SessionLocator,cwd?:string,observedEpoch?:number,observedGeneration?:number}` on the existing capability HTTP request. Before ordinary creation binds that context, the picker is pending and preserves exact saved/staged/custom selections; it performs no hidden create or global probe. On create acknowledgement, automatically request the catalog through that bound context. The endpoint returns existing unavailable envelope with `RUNTIME_PENDING` if the runtime is not yet available; defaults-only screens show exact input/labeled defaults without claiming a native catalog. +- Produces: `FreshAgentModelCatalogRequest {soul_id,cwd,expected_control_epoch}` and `HostCommand::FreshAgentModelCatalog {incarnation_id,cwd}` plus `FreshAgentModelCatalog(Value)` responses through existing typed client/supervisor/session-host dispatch. Gateway resolves the conversation reference to the current parent/root runtime server-side; never trusts a stale frontend soul as authority. Local state resolves the same current registered client. Read `model/list` in that actual provider context and recheck the existing source tuple after reply. +- Produces: catalog cache key `{provider,currentSource,cwd}` where currentSource derives from existing native identity/owner epoch+generation or managed same_snapshot_source fields (host boot/incarnation/execution generation/provider volume). Rebind/restart/materialization invalidates the old key and in-flight client request; stale replies cannot overwrite a newer context or staged selection. Use the existing TTL/singleflight registry. Native model-list absence never rejects an explicit model/effort; exact values remain eligible for native validation. +- Produces: capability-aware selection validation shared by create/send/configure/fork; nonempty native effort strings preserved across wire/TS validation. No new fixed effort enum. `max` legacy alias maps to `xhigh` only where the installed selected model supports xhigh and does not advertise distinct max. + +- [ ] **Step 1: Write red model/effort behavior tests** + +Use the existing app-server ChannelPeer handshake helper to test list pagination; native result page 1 contains `gpt-6.1-sol` with `xhigh`, page 2 contains `provider-current` with effort `ultra` and no static menu entry. Assert exact request cursor and final mapped choice list. Probe failure/deadline returns typed unavailable and leaves previous same-source cache intact; it creates/closes no extra provider process. Distinct provider contexts and gateway HOME must yield distinct records and exact source routing. + +Add the existing actual CLI/app-server drive test variant with chosen model `provider-current` and effort `ultra`: create/send/configure/resume/fork must put that exact pair in the next native request. Add this small normalization test after changing the function semantics: + +```rust +#[test] +fn explicit_native_choices_are_never_replaced_by_static_defaults() { + assert_eq!(normalize_freshcodex_model(Some("provider-current")), "provider-current"); + assert_eq!(to_codex_reasoning_effort(Some("ultra")).unwrap(), Some("ultra".into())); + assert_eq!(to_codex_reasoning_effort(Some("none")).unwrap(), Some("none".into())); +} +``` + +The provider-drive test is necessary: a normalizer test alone cannot prove runtime acceptance. Current-model omitted from refresh stays visibly selected/unavailable, failed catalog does not switch to Astra, delayed refresh does not erase search or staged selection, committed xhigh survives configure→send→reload→send. Any native rejection of an explicit choice is a useful typed failure; catalog omission alone is never a rejection. Absent choice uses native/default chosen by existing defaults. Preserve a nonempty explicit model/effort for native validation whether catalog succeeds, is incomplete, or is unavailable. Open the picker before create acknowledgement, stage a new exact choice, acknowledge creation, and assert the auto-probe uses the newly bound context without erasing the choice or search. Fail creation and prove honest unavailable with no hidden runtime. Rebind during a held catalog response and prove the old source reply cannot commit. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-codex -p freshell-freshagent model`. + +Run: `pnpm run test:vitest run test/unit/shared/fresh-agent-models.test.ts test/unit/client/lib/fresh-agent-model-capabilities.test.ts test/unit/client/lib/fresh-agent-create-content.test.ts test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx --config config/vitest/vitest.config.ts`. + +Expected: static clamp substitutes Astra/max and Codex dialog never probes native. + +- [ ] **Step 3: Implement native catalog and faithful selection** + +`list_models` uses the existing bound client's initialized request path; request visible models, follow cursor to exhaustion and reject malformed/looping cursors with typed probe error. Preserve advertised fields and native defaults. The normal automatic pane create establishes the runtime with exact saved/default settings; a catalog request never creates a synthetic conversation or probes another HOME. While create is pending keep exact selection, show pending, and preserve any labeled defaults. After bind, fetch automatically using the current-context request above. + +Replace FreshCodex's static fast path only for a bound context. Server resolves that session reference under existing ownership/inventory safety, executes the query in the owning local/managed provider, and rejects source changes. Cache by the actual current source generation and cwd; invalidate on rebind/restart, not just TTL. Missing/restarting managed runtime returns pending/unavailable, never falls back to gateway CLI. Existing arbitrary payload extensions already cross managed hosting; typed model-list request/routing is the new work. + +Keep every nonempty explicit Codex model/effort through create/send/configure/resume/fork. The catalog populates menus and advertised choices; it is not proved exhaustive for custom providers, so successful list absence never rejects exact intent. Let native validate and return typed failure rather than silently substitute a model/effort. `FreshAgentSettingsSchema.effort` accepts nonempty native strings. Preserve xhigh exactly; migrate legacy max→xhigh only at the intended compatibility boundary where the selected native model advertises xhigh and no distinct max. Saved/custom values omitted by refresh stay visible as exact current selections. No new fixed effort enum or session-preparation/auth framework. + +- [ ] **Step 4: Confirm green** + +Run Step 2 commands plus `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-codex catalog`. Expected: PASS, exact current values reach native calls. + +- [ ] **Step 5: Refactor while green** + +Unify selection application across all Codex lifecycle paths, leaving shared provider-specific behavior intact. Delete obsolete Codex fixed-menu clamps and misleading 0.129 effort comments, retain deliberate fallback constants only for genuinely omitted choices. Keep native catalog mapping in `catalog.rs`, process lifecycle in the existing transport/launch seam, registry caching in the existing registry. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-codex -p freshell-freshagent -p freshell-session-host -p freshell-runtime-protocol -p freshell-runtime-client -p freshell-supervisor`. + +Run: `pnpm run test:vitest run test/unit/shared/ test/unit/client/lib/ test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Expected: PASS; all providers' model choices and saved Codex defaults remain usable. Task 10 runs the Codex live-convergence browser leg absent today. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-codex/src/app_server.rs crates/freshell-codex/src/model.rs crates/freshell-codex/src/catalog.rs crates/freshell-codex/src/lib.rs crates/freshell-codex/tests/app_server_drive.rs crates/freshell-opencode/src/catalog.rs crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/model_capabilities.rs crates/freshell-freshagent/src/model_capabilities_tests.rs crates/freshell-runtime-protocol/src/lib.rs crates/freshell-runtime-client/src/lib.rs crates/freshell-supervisor/src/service.rs crates/freshell-session-host/src/main.rs crates/freshell-session-host/src/providers/fresh_agent.rs crates/freshell-agent-runtime/src/host_actor.rs crates/freshell-server/src/fresh_agent_proxy.rs shared/fresh-agent-models.ts shared/fresh-agent-model-capabilities.ts shared/fresh-agent-contract.ts src/lib/fresh-agent-model-capabilities.ts src/lib/fresh-agent-registry.ts src/components/fresh-agent/FreshAgentModelDialog.tsx test/unit/shared/fresh-agent-models.test.ts test/unit/client/lib/fresh-agent-model-capabilities.test.ts test/unit/client/lib/fresh-agent-create-content.test.ts test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx +git commit -m "feat(codex): use native model and reasoning capabilities" +``` + +### Task 8: Apply, persist and expose Codex file access, cwd and native Plan mode + +**Files:** +- Create: `crates/freshell-freshagent/src/codex/settings.rs`. +- Modify: `crates/freshell-codex/src/app_server.rs:182,461`; `crates/freshell-protocol/src/client_messages.rs`; `crates/freshell-freshagent/src/codex.rs:1958,3516,3923,7950`; `crates/freshell-runtime-protocol/src/lib.rs:2483`; `crates/freshell-agent-runtime/src/host_actor.rs`; `crates/freshell-agent-runtime/src/snapshot_projection.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`; `crates/freshell-server/src/fresh_agent_proxy.rs`; `shared/fresh-agent-contract.ts`; `shared/settings.ts`; `shared/ws-protocol.ts`; `src/store/paneTypes.ts`; existing pane persistence normalizers; `src/lib/fresh-agent-create-content.ts`; registry; `FreshAgentSettingsButton.tsx`; `FreshAgentView.tsx`; managed create/send/configure serialization. +- Test: settings helper/native request tests; existing settings/create/resume/fork tests; `FreshAgentSettingsButton.test.tsx`; persistence/view tests. + +**Interfaces:** +- Consumes: Task 7 selected native model/effort, existing `FreshAgentSendSettings`, sandbox/permission mappings, per-session turn lock, durable fenced settings persistence. +- Produces: `CodexEffectiveSettings { model:String, effort:Option, cwd:Option, sandbox:Option, permission_mode:Option, collaboration_mode:Option }`, with `CodexWorkMode::{Default,Plan}`. A pure `resolve(base,patch)` validates and merges; apply under existing serialization boundary; emit/persist complete effective settings. Wire create/send/configure/pane/default types add optional `collaborationMode:'default'|'plan'`; shared scopes add cwd/collaborationMode optional keys. +- Produces: `StartTurnParams.collaboration_mode:Option` serialized as native `collaborationMode` with selected model, reasoning_effort and developer_instructions:null. All existing Rust struct-literal callers add `None` or use `..Default::default()` deliberately; update native client drive tests accordingly. + +- [ ] **Step 1: Write failing settings/order/Plan behavior tests** + +Test native wire encoding independently: + +```rust +#[test] +fn plan_mode_uses_selected_model_and_effort_without_changing_sandbox() { + let settings = CodexEffectiveSettings { + model: "provider-current".into(), effort: Some("xhigh".into()), + cwd: Some("/work".into()), sandbox: Some("read-only".into()), + permission_mode: Some("on-request".into()), + collaboration_mode: Some(CodexWorkMode::Plan), + }; + assert_eq!(settings.native_collaboration_mode(), Some(json!({ + "mode":"plan", "settings":{ + "model":"provider-current","reasoning_effort":"xhigh","developer_instructions":null + } + }))); + assert_eq!(settings.sandbox.as_deref(), Some("read-only")); +} +``` + +Drive configure cwd/sandbox/permission only, then send and snapshot: each value reaches native and returns unchanged. Hold an in-flight send response while a newer configure arrives; once both settle, the newer configure remains the default for the next send and reload. Make durable settings write fail: useful typed/logged refusal and no false durable success. Test configured model/effort and work mode survive create/resume/fork and native effective resume values override stale requested assumptions. Backward compatible panes lacking new fields stay Build/default. + +Component tests use real store/settings callback: select File access Read only/Workspace write/Full access and Work mode Plan/Build, observe pane patch and configure payload. Read-only and approval Never ask remain distinct; create-only/unsupported scope shows honest hint/disabled action. Plan→native question→answer→Build is later proven in browser, not just by static label assertions. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-codex -p freshell-freshagent settings`. + +Run: `pnpm run test:vitest run test/unit/client/lib/fresh-agent-create-content.test.ts test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Expected: configure ignores cwd; snapshot omits sandbox/cwd; send overwrites newer configure; no native mode or file-access control. + +- [ ] **Step 3: Implement one effective-settings path** + +Use the existing turn lock for configure as for send/rollback, so a later configure applies after the older send completes rather than being overwritten by its response. Resolve all fields once; validate directory using the same existing cwd rules as create/send; honor native per-turn semantics. Persist only applied/accepted fields with captured ownership fence and handle errors explicitly. Configuration changes stage next-message defaults; they must not falsely claim the current native turn was reconfigured. Broadcast metadata for any changed field, including sandbox/permission/cwd/work mode, and snapshot all of them. Carry work mode through `FreshAgentTurnSettings`, host actor durable profiles/recovery, configure/send forwarding and session-host native `StartTurnParams`; extend hosted `snapshot_projection::valid_scopes` to allow cwd/collaborationMode, otherwise it currently drops the whole scope map. Extend client metadata adoption so older absent fields do not clear valid values. + +Build native collaborationMode using resolved model and **wire-normalized** effort. Selecting Build explicitly switches native `mode:'default'`, not omission that might preserve Plan. Start/resume effective values, when native echoes them, are authoritative. Plan setting is capability/scoped and separate from approval policy. Extend the existing settings popover with plain-language File access and Work mode rows, persist defaults via current provider settings patch, and stage/commit through the same helper as model/permission. Snapshot capability `settingScopes` truthfully advertises per-send cwd/sandbox/model/effort/approval/work mode supported by installed Codex; no invented live-only reconfiguration. + +- [ ] **Step 4: Confirm green** + +Run Step 2 commands. Expected: PASS and exact Plan/default native fields with same model/effort and correct sandbox/cwd after reload. + +- [ ] **Step 5: Refactor while green** + +Replace four separate settings-normalization blocks with the focused helper and shared persistence/projection functions. Do not move identity/lease/fork machinery into settings. Keep mode/approval/sandbox distinct in names and UI copy. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-codex -p freshell-freshagent -p freshell-protocol -p freshell-session-host`. + +Run: `pnpm run test:vitest run test/unit/shared/ test/unit/client/lib/ test/unit/client/store/ test/unit/client/components/fresh-agent/ --config config/vitest/vitest.config.ts`. + +Expected: PASS including old saved panes, create defaults, hosted settings forwarding and cross-provider settings scope behavior. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-codex/src/app_server.rs crates/freshell-codex/tests/app_server_drive.rs crates/freshell-protocol/src/client_messages.rs crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/settings.rs crates/freshell-runtime-protocol/src/lib.rs crates/freshell-agent-runtime/src/host_actor.rs crates/freshell-agent-runtime/src/snapshot_projection.rs crates/freshell-session-host/src/providers/fresh_agent.rs crates/freshell-server/src/fresh_agent_proxy.rs shared/fresh-agent-contract.ts shared/settings.ts shared/ws-protocol.ts src/store/paneTypes.ts src/store/persistedState.ts src/lib/fresh-agent-create-content.ts src/lib/fresh-agent-registry.ts src/lib/fresh-agent-setting-scopes.ts src/components/fresh-agent/FreshAgentSettingsButton.tsx src/components/fresh-agent/FreshAgentView.tsx test/unit/client/lib/fresh-agent-create-content.test.ts test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +git commit -m "feat(codex): expose faithful settings and native plan mode" +``` + +### Task 9: Enable CLI/MCP and default REST automation through the existing Codex runtime + +**Files:** +- Create: `crates/freshell-freshagent/src/codex_rest_lane.rs`. +- Modify: `crates/freshell-freshagent/src/codex.rs` typed outcomes/turn subscriptions; `lib.rs:2092,2579`; `rest_lane.rs`; `pane_ops.rs`; `crates/freshell-server/src/main.rs`; `tools/node-client-runtime/action-capabilities.ts`; `tools/freshell-cli/index.ts:120,159,461,582` create/split/resume body parser; `tools/freshell-mcp/freshell-tool.ts`. +- Test: `rest_lane_http_tests.rs`; new `codex_rest_lane` behavior tests; `test/unit/cli/action-capabilities.test.ts`; `commands.test.ts`; `test/unit/mcp/freshell-tool.test.ts` and existing MCP HTTP dispatcher tests. + +**Interfaces:** +- Consumes: `FreshAgentRestLane::{create_agent,abandon_create,send_agent,capture,snapshot}`, exact `RestLaneCreate/Send/Capture` types, Task 4 identity-owned turn facts, current naming/ownership/fence/create cleanup contract. +- Produces: `CodexRestLane::new(state:FreshCodexState, enabled:Arcbool+Send+Sync>)`; `FreshAgentState::set_codex_rest_lane(SharedFreshAgentRestLane)`; local provider selection `codex` after hosted gateway precedence. `CodexCreateOutcome` is `Created { session_id:String, session_ref:Option, cleanup_ownership_id:Option } | Failed { code:String,message:String,retryable:Option,owner:Option }`. `CodexSendOutcome` is `Accepted { session_id:String, submitted_turn_id:String, incarnation_id:String } | Refused { code:String,message:String,retryable:Option,owner:Option } | Ambiguous { session_id:String,request_id:String,incarnation_id:String,code:String,message:String }`. Accepted means native acceptance only; it is not completed. Exact field responsibilities match the existing Claude typed outcomes. `CodexTurnEvent::Settled` carries native session/turn/incarnation, terminal outcome and actual evidence source; interrupt acknowledgement, quiet-deadman attention, arbitrary status snapshots and acceptance are not settlement events. Subscribe before dispatch and settle only matching native identity. Same-ID steering shares the existing native turn; ambiguous delivery remains incomplete/uncertain with no automatic replay. + +- [ ] **Step 1: Add failing real dispatcher and HTTP/runtime tests** + +Extend the existing actual CLI HTTP-recording parameterized test from Claude/Kilroy to Codex with provider-specific sessionRef. This complete representative MCP case exercises its real dispatcher: + +```ts +it('creates a fresh Codex pane with canonical resume identity', async () => { + mockClient.post.mockResolvedValue({ status: 'ok', data: { tabId: 't1', paneId: 'p1' } }) + await executeAction('new-tab', { + agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', + sessionRef: { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + }) + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', + sessionRef: { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) +}) +``` + +Add the split equivalent with target lookup. The CLI test invokes the real `tools/freshell-cli/index.ts` child against an owned HTTP recording server; `--resume` maps to provider=codex canonical ref and never raw fresh resumeSessionId. Keep terminal mode=codex raw-resume rejection separate. + +Rust HTTP tests construct default local server state with real Codex lane and ChannelTransport: headless new/split honors cwd/model/effort/approval/sandbox/defaults, initial prompt acceptance, canonical resume reuses writer/session, send-keys Enter-only no-op, send waits for actual native terminal evidence for its submitted turn (including same-ID steering and an acknowledged Stop held before terminal), capture matches normalized transcript with byte limit/truncated, wait-for sees actual output, rename reaches shared name authority. Failed create leaves no blank pane and only cleans newly owned runtime; adopting a live writer never kills it on layout rollback. Test ambiguous acceptance never auto-replays input and timeout reports incomplete truthfully. + +- [ ] **Step 2: Confirm red** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py -p freshell-freshagent rest_lane`. + +Run: `pnpm run test:vitest run test/unit/cli/action-capabilities.test.ts test/unit/cli/commands.test.ts test/unit/mcp/freshell-tool.test.ts --config config/vitest/vitest.config.ts`. + +Expected: default local Codex create/split is unavailable and standalone clients refuse before HTTP. + +- [ ] **Step 3: Adapt the existing runtime rather than duplicate it** + +Refactor `handle_create`/`handle_send` to return typed outcomes while retaining current WebSocket broadcasts; callers that need no result may ignore it explicitly. Publish a private typed turn-settlement subscription from the same reducer/edge path, as Claude does, and subscribe **before** dispatch so fast turns cannot be missed. Correlate native turn, session incarnation, ownership identity and request; if turn/start steers into an existing turn, wait for that native turn without inventing another completion. Cancellation/death/close uses confirmed owned termination or actual native terminal evidence; a requested Stop or its RPC acknowledgement alone leaves the waiter pending. Ambiguous dispatch returns a truthful incomplete/uncertain result and retains same-owner history reconciliation, never resends input. + +Implement CodexRestLane by translating existing RestLane request into `FreshAgentCreate/Send`, calling the same state and mapping typed failures. Capture uses side-effect-free exact snapshot and the shared existing transcript-to-text/byte-bound helper; do not scrape rendered DOM or spin up a second sidecar. Cleanup requires owned token identity and existing safe kill/confirm; adopted runtime has no cleanup token. Preserve observed epoch/generation fences. + +Register local Codex lane at server construction beside local Claude; `rest_lane_for` retains hosted-gateway precedence and selects Codex locally when unmanaged. Existing split/new layout transaction continues to own rollback. Only once runtime behavior is green, extend the client capability guard and fresh-agent resume parser to Codex and update MCP parameter description. Remove the specific obsolete negative Codex refusal cases; retain all unrelated refusal cases and action counts. Do not add a new fresh-send command. + +- [ ] **Step 4: Confirm green** + +Run Step 2 commands. Expected: PASS with actual HTTP body and real lane behavior, no change to terminal-mode constraints. + +- [ ] **Step 5: Refactor while green** + +Reuse existing text capture, typed REST error and wait-deadline helpers; keep provider-specific outcome extraction in Codex. Avoid refactoring all providers into a new runtime trait. Remove temporary broadcast-message parsing used during green implementation. + +- [ ] **Step 6: Verify impacts** + +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-freshagent -p freshell-server`. + +Run: `pnpm run test:vitest run test/unit/cli/ test/unit/mcp/ --config config/vitest/vitest.config.ts`. + +Expected: PASS, including Claude automation, naming, focus contracts, split rollback and terminal command validation. Task 10 proves the real Rust/fake-native browser journey and hosted arrangement. + +- [ ] **Step 7: Commit** + +```bash +git add crates/freshell-freshagent/src/codex_rest_lane.rs crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/lib.rs crates/freshell-freshagent/src/rest_lane.rs crates/freshell-freshagent/src/rest_lane_http_tests.rs crates/freshell-freshagent/src/pane_ops.rs crates/freshell-server/src/main.rs tools/node-client-runtime/action-capabilities.ts tools/freshell-cli/index.ts tools/freshell-mcp/freshell-tool.ts test/unit/cli/action-capabilities.test.ts test/unit/cli/commands.test.ts test/unit/mcp/freshell-tool.test.ts +git commit -m "feat(codex): support REST CLI and MCP pane automation" +``` + +### Task 10: Prove complete local and hosted journeys and document the finished experience + +**Files:** +- Modify: `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs`; `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts` and `schema-traceability.ts` only where needed for exercised 0.164 behaviors; `test/e2e-browser/specs/fresh-agent-control-rust.spec.ts`; `fresh-agent-attention-reconnect-rust.spec.ts`; `freshagent-live-model-convergence-rust.spec.ts`; `freshagent-settings-resume-rust.spec.ts`; `test/integration/server/fresh-agent-parity.test.ts`; `README.md`; `docs/index.html`. +- Create: `test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts`; `test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts`. +- Modify: Task 1 native harness only for final adapter/hosted regression scenarios; it must already have executed the early native gates. +- Test: those specs plus existing rollback, names, crash/ownership/handoff/attachment/checkpoint regressions through supported lanes. + +**Interfaces:** +- Consumes: all production interfaces from Tasks 2–9; existing `RustServer`, `TestHarness`, configured cloud Playwright runner and `ManagedRuntimeBrowserRig`. +- Produces: executed receipts for actual provider messages→Rust runtime→WS/REST→browser/CLI/MCP→native responses, with independent review and no production deployment. + +- [ ] **Step 1: Add failing browser assertions before adjusting production behavior** + +In early task work, add each applicable acceptance case before its production change. This final task owns their integrated fixture/journey completion; do not postpone all browser red evidence until after implementation. Extend the existing behavior-driven native fixture with opt-in current-schema frames, catalog pages, prompt-scripted control types and operation/response logs. Keep defaults unchanged for other specs. The fake is only the native executable boundary; run real Rust, request handlers, WebSocket transport, shared store and UI. + +Use the existing spec-owned `bootWall`/`seedWallConfig`/`createFreshAgentPane` helpers from control spec in that spec; new specs copy the minimal owned-server idiom per repository convention. The core visible assertion in the new streaming journey is: + +```ts +await composer.fill('STREAM_AND_WAIT') +await composer.press('Enter') +await expect(pane.getByText('First streamed fragment', { exact: true })).toBeVisible() +await expect(pane.getByRole('button', { name: /stop/i })).toBeEnabled() +expect((await readProviderLog()).some(entry => entry.method === 'turn/completed')).toBe(false) +await releaseNativeTurn() +await expect(pane.getByText('First streamed fragment and final fragment', { exact: true })).toHaveCount(1) +await page.reload() +await harness.waitForConnection() +await expect(page.getByText('First streamed fragment and final fragment', { exact: true })).toHaveCount(1) +``` + +`composer` and `pane` are actual locators from the created pane; `readProviderLog` reads the fixture's owned JSONL event/operation log; `releaseNativeTurn` writes the fixture's owned release file. Define these complete helpers in the new spec and add fixture behavior `waitForTurnReleasePath` to pause deterministically before native completion. File wait is test-only fake-provider behavior, never production logic. Use a generous bounded deadline with an error naming thread/turn; no unbounded polling or arbitrary sleep. + +The Task 1 staged native harness and actual 0.164 receipts are prerequisites for these final regression journeys. Extend/reuse that harness here; do not bootstrap packages at runtime, restate metadata as a pass, or postpone the architecture-controlling native scenarios until this final task. The final actual managed-native pass complements deterministic provider-transport tests. + +Required journeys and exact evidence: + +1. **Streaming/transcript:** text/reasoning/tool output visible before completion; plan replacement and aggregate diff visible; usage nonzero with unknown cost; completion snapshots converge once; final error and declined rows remain truthful after reload. Hide one of two distinct Codex panes while it streams/awaits input, reveal, assert no transcript/control/model leak and no focus theft. +2. **Native controls/planning:** choose Plan and catalog-only model/xhigh; provider log has native collaborationMode with that pair. Receive blocking constrained question, reload, no Other, exact question IDs; secret question masks input and provider response retains punctuation; nonblocking question does not freeze running work. Network-without-command and stdin approvals show correct action; Deny produces declined row; Stop cancellation sends cancel without duplicate response. Flat MCP form and URL prompt succeed/cancel; unsupported complex/device-proof subtype yields visible bounded explanation and releases native request. +3. **Catalog/settings:** open picker before ordinary create acknowledgement and preserve an exact staged choice; after binding, probe only its actual provider context, ignore stale-source replies, and keep custom models omitted by a successful list eligible for native validation; install fixture model not in static menu and native effort not in old enum; configure/send/reload/send preserve exact pair. Change cwd/file access/approval/work mode and verify next native turn, snapshot and resumed defaults. Restore existing Codex leg in live-model-convergence spec; refresh failure preserves selection. Two browsers observing same session converge settings/name without changing native identity. +4. **Attention:** saturate hosted channels and drop the final control/completion frame with no later provider token; recover from retained state without focus theft, then disconnect only the browser WebSocket; complete/fail or raise input through independent native/WS driver; reconnect produces one correct completion/waiting indication. Reload stays silent, pending response drains waiting, hidden-pane reveal retains the card, no cross-pane edge. The spec's Codex cases must actually execute on cloud. +5. **Automation:** REST creates a headless background Codex pane while active pane/tab/focused element remain unchanged; initial prompt accepted; send/capture/wait see real native output; rename uses shared authority; reload sees the same durable ref; split and resume work. CLI/MCP dispatchers already have HTTP proof; invoke at least one actual standalone CLI and MCP dispatcher against the owned real server in supported integration coverage to prove end-to-end routing rather than recording-only proof. +6. **Preserved lifecycle:** parent-scoped child observer shows exact child transcript, stays read-only and restores its selected child after reload while issuing zero child create/resume/send/stop/configure RPCs; /new, native compact (no turn/start text), fork from a specific row/native boundary, conversation undo with no file change, honest redo refusal, image input reaches native and an uploaded image/text attachment remains meaningfully visible in its user transcript before/after reload, native image output path/result remains inspectable, file-checkpoint/rewind and `!command` cwd, saved-name precedence, same-thread reload/recovery, cross-mode handoff without duplicate writer, Stop in start-response window and rejected steer/late interrupt. Fault/kill/restart variants run only in disposable Docker; never bounce production or run destructive browser specs directly on host. +7. **Managed hosted:** extend `fresh-agent-parity.test.ts` existing rig for Codex same catalog/model/effort/settings/Plan, live transcript+usage, controls response, REST send/capture and reconnect attention. Assert provider operation log inside owned provider volume and gateway-visible state. Include one session-host restart/crash recovery in the existing isolated Docker rig if required by changed projection/replay forwarding; same native ref/settings/name survive, stale host patches cannot overwrite replacement incarnation. Do not replace this with a gateway-local fake state. Add a second actual-native Codex leg using the now-aligned managed image: omit freshcodex from fixtureFreshAgentModes, use the Task 1 loopback Responses/MCP fixture inside the owning provider container/namespace with synthetic config, and run the real Rust/gateway→session-host→actual Codex journey. Extend `codex-local-provider.mjs` with `startInManagedContext(rig,source) -> Promise<{baseUrl,stop,traffic}>` using the existing owned RuntimeSandbox/broker test mechanism, never a live host. The actual-native leg proves native version, streamed/landed text, Plan/control response, faithful settings and parent-scoped child observation through that production adapter; deterministic fake-native parity remains for forced channel/race cases. The Docker-backed local companion must discover and execute that leg, not silently select only the fake transport. + +- [ ] **Step 2: Run affected cloud specs and confirm intended red evidence** + +During each earlier implementation slice, select its new affected spec with `GCLOUD_ROBOT_REQUIRE=1 pnpm run test:e2e --project=chromium test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts` before/after production work; record nonzero discovered test count and failure reason. The concrete final selectors are in Step 4. Expected initial failures correspond to the missing user behavior, not fixture boot/setup failures. If fixture setup fails, fix setup and rerun before claiming red. + +- [ ] **Step 3: Complete minimal integrated implementation and documentation** + +Repair only production gaps revealed by the accepted journeys, within the earlier task intents. For schema fixture inventory update, represent 0.164 supported/unsupported classifications honestly and preserve unknown-type tolerance; do not add test assertions that merely require version/comment text. Keep native fake configured via existing behavior JSON, exact ID replacement, durable turn recording, and response/operation logs. Add opt-in prompt scripts and release barriers there, not production test seams. + +Update README usage for installed Codex model selection, Work mode Plan/Build, file-access versus approval, native questions/MCP prompts, CLI/MCP create/split, native undo/no-redo. Keep it concise and user-facing. Update the nonfunctional `docs/index.html` default-experience mock for major new settings/control affordances using existing design patterns. Do not create another end-user Markdown guide. + +- [ ] **Step 4: Run exact focused browser/hosted verification** + +Run: + +```bash +GCLOUD_ROBOT_REQUIRE=1 pnpm run test:vitest run test/integration/providers/codex-cli-contract.test.ts --config config/vitest/vitest.config.ts +node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir .runtime-build/codex-contract/final-managed-native +GCLOUD_ROBOT_REQUIRE=1 pnpm run test:e2e --project=chromium test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts test/e2e-browser/specs/fresh-agent-control-rust.spec.ts test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts test/e2e-browser/specs/unified-agent-names-freshcodex.spec.ts +``` + +Expected: actual pinned-native contract and discovered/executed Codex browser cases PASS on configured cloud, including native version/loopback traffic receipts and unchanged production CLI resolution. Verify new specs are absent from cloud skip lists and no required case was filtered/skipped. Existing `codex-terminal-bounce-rust`, `codex-terminal-restore-rust`, `agent-checkpoint-rewind`, centralization mocks and local-only runtime qualification specs cannot count as this cloud receipt. For handoff/checkpoint preservation, use meaningful Cargo/standard-source-runtime isolated coverage plus a cloud-legal non-destructive journey in the new Codex spec when the old browser spec is excluded. + +Managed parity remains in the real Docker-backed `test/integration/server` local companion. Use the broad supported run below, which automatically includes that excluded-from-cloud tree. Do not run a focused cloud selector and claim it executed the hosted rig. If a focused supported local companion invocation is needed, use its repository-owned selector documented by the harness, not an ad hoc local backend override; otherwise wait for the full check. + +- [ ] **Step 5: Refactor while green** + +Remove duplicate temporary fake scripts/helpers introduced by this work; keep per-spec ownership conventions. Consolidate repeated production projection/settings/control transitions only where the earlier tasks already own them. Check logging has diagnosable IDs/severity and no secret answers; verify instructions/copy describe actual behavior. Re-run only affected cases when this cleanup changes behavior. + +- [ ] **Step 6: Run final broad gate and independent review** + +Run `pnpm run typecheck` after all typed contract changes if it has not already run for the final tree. Run the required broad gate against the exact final tree; the ordinary focused task commit is Step 7. If the parent has already made a content-identical checkpoint for cloud image reuse, retain that checkpoint and record its SHA: + +```bash +GCLOUD_ROBOT_REQUIRE=1 FRESHELL_RUST_TEST_SANDBOX=1 FRESHELL_TEST_SUMMARY='Complete Freshcodex verification' pnpm run check +``` + +Expected: PASS for typecheck, client/cloud with local-only hosted companion, Rust sandbox and Electron; retain full phase results and no skipped required Codex behavior. `check` plus focused cloud browser receipts is the required final evidence; do not duplicate an entire green gate without a later change/failure concern. Run Rust fmt/clippy as required by existing filtered pre-push workflow and fix actual failures without bypassing hooks. A shared protocol/store surface changed, so the impacted set is the full supported suite. + +The parent runs the specified fresh independent spec-plus-quality review against committed delta and the complete unchanged User Request. Use configured `lunaroute/glm-5.3` first through `/home/dan/.config/opencode/skills/darkforge/scripts/fresheyes/opencode-reviewer-transport.py` and the usual review supervisor. Only actual provider/auth/billing unavailability permits the explicit `openai/gpt-6.1-sol` reviewer `variant:xhigh` fallback. Resolve substantiated findings by the supplied usual scope dispositions, rerun affected tests after repairs and rerun the broad gate when a repaired shared boundary requires it. Do not silently substitute GLM 5.2 or bundled older GPT. No PR/deploy action occurs in this step. + +- [ ] **Step 7: Commit and hand off the verified branch** + +```bash +git add test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs test/fixtures/coding-cli/codex-app-server/schema-inventory.ts test/fixtures/coding-cli/codex-app-server/schema-traceability.ts test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts test/e2e-browser/specs/fresh-agent-control-rust.spec.ts test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts test/integration/providers/codex-local-provider.mjs test/integration/providers/codex-cli-contract.mjs test/integration/providers/codex-cli-contract.test.ts test/integration/server/fresh-agent-parity.test.ts README.md docs/index.html +git commit -m "test(codex): prove complete Freshagent workflows" +``` + +Commit any later review fixes separately. The file lists above are the exact intended task ownership; leave unchanged tracked entries unstaged, and if implementing a listed behavior necessarily modifies another already-read caller/constructor, record that exact path in the task receipt and stage it explicitly as well. Never stage unrelated work. Verify clean feature worktree, focused history and no unrelated files; push the feature branch using the existing hooks. Report what changed, native/local/hosted/browser evidence, commit/branch, and any real remaining finding disposition. Ask the user for the repository-required explicit approval to open/land a PR only after the result is concrete and reviewable. Do not create a PR in advance, merge, clean up the worktree, deploy, or restart production. + +## Mandatory self-review of this implementation plan + +1. **Current intent:** The complete dispatcher-authored User Request is unchanged exactly once. No obligation or accepted residual was rewritten. The ten tasks retain the complete original user-visible coverage. +2. **Early proof and version:** Task 1 is an independently testable actual-native/managed-runtime upgrade, with staged cloud/offline execution and hard native F1/F2/F3/F6/F7 gates before dependent implementation. Managed Codex 0.147→0.164 alignment follows that early proof, preserves other provider pins, and deploys nothing. Artifact metadata is not execution evidence; only actually executed platforms are claimed. +3. **Source authority:** Event revisions cover consumed application events only. Active bodies remain event-owned, independent partial reads are nonappendable previews, complete native/event bodies replace atomically, and generation/retained-history membership prevents late reads/deltas from recreating removed/final history. No eight-entry-cache finality dependency, shared native watermark or new generic clock/journal is assumed. +4. **Ownership and receipts:** Acceptance, same-ID steering, Stop acknowledgement, ambiguity and actual terminal settlement are separate. Child observation is explicitly parent-scoped and read-only locally and managed; no create/resume/release predicate is inferred from completed activity. Existing identity/handoff/fork/undo/names/cleanup fences remain authoritative. +5. **Host and catalogs:** Provider-retained projection/attention/pending state precedes lossy broadcast and a reliable coalesced worker recovers the last dropped frame without requiring another token. Elicitation joins durable pending-decision conversion. Existing payload extensions already forward; cwd/work-mode valid_scopes is a known Task 8 update. Pre-create catalog is pending, ordinary create binds context, only that current provider is probed, and source changes invalidate stale cache/replies. List omission never rejects exact custom intent. +6. **Outcome map:** Task 1 supplies actual native/runtime evidence and version alignment; 2 covers streaming/usage/plan/diff; 3 native items/declined outcomes/parent child view; 4 turn/Stop races; 5 native controls/MCP/deadman; 6 retained attention and hosted loss; 7 catalog/model/effort; 8 complete settings/Plan; 9 REST/CLI/MCP; 10 final actual-native/real-Rust/fake-native/browser/hosted/regression/documentation proof. +7. **Tests and interfaces:** Every task retains seven red/green/refactor/impact/commit steps. Deterministic barriers test both native-read/consumer schedules, old mutation reads, final bodies beyond cache eviction, real response IDs, same-ID steering and last-frame channel loss. Complete representative snippets exercise actual behavior. New symbols/paths are declared, exact focused helper/cloud commands remain, and required cloud cases must be discovered/executed. No missing-artifact/native scenario skip or cloud→local substitute is allowed. +8. **Operations and status:** Root dependencies/lock/workspaces/.bin, global configuration and host production CLI remain unchanged; managed pin change is branch-only. Secrets stay out of persistence/logs. Existing executable volume/test-image staging avoids offline downloads and undersized tmpfs. README/mock cover major changes. Plan publication is documentation-only; formal load-bearing validation, independent plan review and implementation remain separate required steps. + +**Self-review result:** PASS for source-plan publication: ten tasks, seventy checkbox steps, the complete unchanged User Request exactly once, and explicit files/interfaces/commands. Native execution, image staging/pin behavior and resulting native claim verdicts remain mandatory early implementation gates; they are not claimed as verified by this documentation commit. The coordinator owns subsequent formal workflow transitions. From cad139b43beafc53f578637b257f62ee02c321af Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:30:57 -0700 Subject: [PATCH 02/92] docs: correct Freshcodex native semantics and release gates --- docs/plans/2026-10-25-freshcodex-complete.md | 197 +++++++++---------- 1 file changed, 96 insertions(+), 101 deletions(-) diff --git a/docs/plans/2026-10-25-freshcodex-complete.md b/docs/plans/2026-10-25-freshcodex-complete.md index 2a5756515..77b412e4e 100644 --- a/docs/plans/2026-10-25-freshcodex-complete.md +++ b/docs/plans/2026-10-25-freshcodex-complete.md @@ -20,9 +20,9 @@ Make Freshcodex a complete, reliable implementation of the Freshagent experience ### Accepted tradeoffs and residuals - None. -**Goal:** A Freshcodex conversation can be created, controlled, observed live, restored, and automated through the same Freshagent surfaces as the other providers, while faithfully exposing essential native Codex planning, models, permissions, questions, and child activity. +**Goal:** A Freshcodex conversation can be created, controlled, observed live, restored, and automated through the same Freshagent surfaces as the other providers, with correct native saved history and richer output still retained, truthful interrupted/unsaved/provider-truncated states, and faithful native planning, models, permissions, questions, and durable child observation. -**Architecture:** Extend the existing `FreshCodexState` and app-server adapter below the local/managed-host split. Keep native thread identity, ownership, handoff, history reads, fork, compaction, rollback, naming, shared transcript, settings, and typed REST lane; add a focused incremental transcript projection, identity-owned turn accounting, retained attention, native catalog probing, and faithful control normalization. Use the shared renderer and live-state isolation for Codex output rather than creating another provider runtime or view. +**Architecture:** Extend the existing `FreshCodexState` and app-server adapter below the local/managed-host split, preserving native identity, ownership, handoff, history, fork, compaction, rollback, naming and the typed REST lane. Reconcile complete native turns with the existing bounded live projection/retention, distinguish read availability from activity, and serialize idle starts, expected-ID steering and Stop without blocking event consumption. Use the shared renderer, authoritative attention, separate awaited-control/async-question paths and owned prepared configuration; prove the release binary and restricted execution under existing isolation before dependent work. **Tech Stack:** Rust/Axum/Tokio, existing Codex WebSocket JSON-RPC client, React 18/Redux Toolkit/Zod/TypeScript, Vitest, Cargo, Playwright, deterministic fake app-server, and existing Docker managed-runtime/sandbox fixtures. @@ -33,10 +33,11 @@ Make Freshcodex a complete, reliable implementation of the Freshagent experience 3. Configured Vitest and browser backends are both `cloud`. Use the supported entrypoints, `GCLOUD_ROBOT_REQUIRE=1`, and the configured robot identity; no fallback to a local backend to bypass infrastructure failures. `FRESHELL_RUST_TEST_SANDBOX=1` is mandatory on broad `check`/Rust/source-runtime runs. All focused Cargo checks below use the run-owned `run-focused-rust.py` helper, which invokes `scripts/sandbox-test.sh --runtime-suite` with owner guard, owned executable/runtime temp directories, the worktree-specific target path and bounded workers. `--build-server` rebuilds the correct test server before WS/server integration. Never kill a foreign coordinator holder. Follow `docs/development/test-sandbox.md` and `docs/development/pre-push-gate.md`. 4. No deployment, client build into main's `dist/client`, production restart, live-host mutation, global configuration edit, or real-home corpus mount. Feature tests use controlled provider fixtures; the requested independent reviews use the selected reviewer model. Scratch servers belong to the test fixtures and their owned temporary homes/ports. Keep the live port 3001 process untouched. 5. Every task follows red/green/refactor, runs the impacted behavior set, and makes a focused commit. Tests must execute the protected behavior. Do not skip/weaken tests or add source/prose/hash assertions. If an old static-copy assertion blocks an intentional behavior change, remove that assertion and replace its useful intent with behavior coverage. -6. Keep the native `thread/read { includeTurns:true }` full-history compatibility path for this change. Installed 0.164.0 still hydrates full turns; there is no demonstrated truncation requiring pagination. Preserve paginated **thread history mode** for native undo; that is a separate concern from fetching history in pages. Codex redo remains explicitly unsupported, and conversation undo does not edit files. +6. Keep the native `thread/read { includeTurns:true }` full-history compatibility path for this change. Native full-turn hydration is separate from native payload persistence: command/MCP saving can be bounded at 64 KiB, and unfinished deltas need not be saved. Preserve actual truncation markers and finite interrupted/unsaved states; do not infer complete bodies from terminal summaries, require impossible byte equality, or add a universal token journal. Preserve paginated **thread history mode** for native undo; it is separate from fetching history in pages. Codex redo remains explicitly unsupported, and conversation undo does not edit files. 7. Preserve every existing ownership lease, epoch/generation fence, accepted-before-completion emission barrier, fork cleanup token, durable checkpoint boundary, crash-resume/supersession path, native saved-name authority, and side-effect-free untracked snapshot read. New snapshot/projection work must never spawn/resume a provider for GET. 8. Use structured tracing with provider/session/native-turn/native-item/request IDs and severity for protocol/recovery failures. Never log secret answer values, credentials, full control payloads containing secrets, or uploaded image data. Unsupported native subtypes get one explicit provider response plus a useful typed notice, never an indefinitely pending request. 9. New behavior stays on the feature branch. Commit and push are allowed; PR creation, merge, and worktree cleanup await this change's explicit user approval. Follow the requested independent-review provider selection; configured first choice is `lunaroute/glm-5.3` through the trusted OpenCode transport, with actual generation availability established by the review. The fallback is exactly `openai/gpt-6.1-sol` with reviewer `variant:xhigh`, not the workflow's older GPT default. +10. Preserve approval intent and isolation. Public `approval_policy='untrusted'` is rejected before request overrides in 0.164: only a faithful owned prepared-config projection plus typed native policy can preserve it; otherwise refuse before work with a typed setting/source diagnostic. Never rewrite real HOME, silently loosen policy, or use public `-c` as the workaround. Restricted read-only/workspace-write commands must actually execute as the intended provider user inside the existing boundary. Legacy Landlock rejects restricted filesystem modes in this version; no guessed flag, speculative apt bubblewrap, SYS_ADMIN, privileged/unconfined container, host-daemon change or full-access substitute is permitted. --- @@ -44,6 +45,8 @@ Make Freshcodex a complete, reliable implementation of the Freshagent experience The original explorer reports plus `load-bearing-finder-preliminary.md`, `load-bearing-strategist-preliminary.md`, the parent preliminary ledger and `reports/native-artifact-metadata.md` are preliminary planning evidence, not executed native proof or formal-stage completion. The final explorer reports under `.worktrees/.the-usual-logs/freshcodex-complete/reports/` and bounded source reads are the evidence for this plan, including the separate existing-branch pattern check and official native-artifact metadata report. The installed experimental-inclusive native protocol is `/tmp/freshcodex-schema-simy2pdx`, Codex 0.164.0. The checked-in 0.129.0 inventory is stale evidence, not the runtime authority. Exact wire shapes should be represented by behavior fixtures and parsed in the adapter; do not generate a new all-RPC SDK for this task. +The finite semantics and release prerequisites below adopt the bounded decisions in `/home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/reports/load-bearing-resolution-options.md`, supported by the final reports at that same directory: `load-bearing-validator-f1.md` (completed/interrupted/truncated history and Legacy identities), `load-bearing-validator-f2-f7.md` (expected-ID input, Stop, awaited versus async questions and release fixture), `load-bearing-validator-f3.md` (durable versus ephemeral child reads), `load-bearing-validator-f4.md` (managed policy/isolation) and `load-bearing-validator-f6.md` (staging boundaries). These reports contain source evidence, not executed native receipts or an independent plan-review pass. All Task 1 and final execution gates remain mandatory. + | Shared Freshagent surface | Freshcodex disposition and proof | | --- | --- | | Pane picker, create, saved-history resume, reload, recovery | Preserve current create dedup/native identity/ownership and prove two panes plus reload in Tasks 2, 4, 10. | @@ -53,12 +56,12 @@ The original explorer reports plus `load-bearing-finder-preliminary.md`, `load-b | Model/thinking, approval policy, workspace/cwd | Native catalog and faithful values in Tasks 7–8. | | Style/font and shared pane preferences | Existing provider-independent preferences remain; run the shared settings tests. | | File access and planning | Add separate Codex File access and Work mode controls in Task 8. Plan/default is native `collaborationMode`, separate from approval and sandbox. | -| Approvals, questions, Stop, waiting/stuck, reconnect attention | Repair and extend the existing controls in Tasks 4–6, including genuinely blocking versus nonblocking questions. | -| Child activity and open-child action | Map current items and observe the exact child through its fenced parent provider context in Task 3; no child writer. | +| Approvals, questions, Stop, waiting/stuck, reconnect attention | Repair Tasks 4–6: awaited reply-bearing requests require answers regardless of `isBlocking`; true async transcript questions use ordinary input while work continues. | +| Child activity and open-child action | Task 3 observes ordinary durable children through the fenced parent, bounds unmaterialized pending reads, and reports ephemeral history explicitly unavailable; no child writer or ephemerality change. | | Images, shell escape, file checkpoints, names, handoff | Existing implementation retained and included in Task 10 regressions; image sending already exists. No new media-serving service. | | CLI/MCP new/split/send/capture/wait/names | Implement Codex on existing `FreshAgentRestLane` in Task 9; preserve terminal `mode=codex` and its raw-resume validation. | | Local and managed hosted provider | Same provider state remains below the split. All production paths receive the same behavior; hosted Docker proof is in Task 10. | -| Configured MCP elicitation | Ordinary flat primitive/enum form, `openai/form`/`openaiForm` equivalent flat schema, and URL confirmation are supported in Task 5. Complex forms and device-proof verification receive bounded explanatory refusal; no auth-proof impersonation or arbitrary form framework. | +| Configured MCP elicitation | Task 5 supports ordinary flat primitive/enum forms, each distinct `openai/form` and `openaiForm` capability/transport route, and explicit URL confirmation. Flat rendering can be shared; method identities cannot. Complex/device-proof requests receive bounded explanatory refusal. | | Audio/realtime, cloud-agent administration, remote environments, dynamic client tools, marketplace/account/auth administration | No existing shared Freshagent affordance requires these native APIs; not part of Freshagent parity. Native CLI owns its configured authentication. | The Plan choice is included deliberately: Codex's native collaboration-mode contract is absent from today's start-turn adapter, and the shared Claude pane already exposes a planning workflow. A normal text instruction is insufficient to claim Codex Plan mode. Selecting Plan sends `{ mode:'plan', settings:{ model, reasoning_effort, developer_instructions:null } }`; selecting Build sends the corresponding `default` mode. Both use the actual selected model/effort, because native collaboration settings override the parallel fields. No claim that Plan itself enforces file permissions: the separate sandbox choice remains authoritative for that. @@ -69,11 +72,11 @@ Line numbers below locate the inspected base; use symbol names after earlier tas | Owner | Production files and one responsibility | | --- | --- | -| Task 1 | New `scripts/testing/prepare-codex-contract.mjs`, `test/fixtures/coding-cli/codex-native-artifact.json`, `test/integration/providers/codex-local-provider.mjs`, `codex-cli-contract.mjs`, `codex-cli-contract.test.ts`: staged real-native evidence and early decision gates. `docker/cloud-run/Dockerfile`: test-only native artifact layer. `docker/runtime/provider-versions.json`, `docker/runtime/Dockerfile`, `scripts/testing/runtime-sandbox.ts`: managed Codex version alignment and truthful runtime receipts. | +| Task 1 | New `scripts/testing/prepare-codex-contract.mjs`, `test/fixtures/coding-cli/codex-native-artifact.json`, `test/integration/providers/codex-local-provider.mjs`, `codex-cli-contract.mjs`, `codex-cli-contract.test.ts`: staged real-native evidence and early decision gates. `docker/cloud-run/Dockerfile`: test-only native artifact layer. `docker/runtime/provider-versions.json`, `docker/runtime/Dockerfile`, `scripts/testing/runtime-sandbox.ts`: managed version alignment/receipts. Existing `crates/freshell-server/src/managed_provider_references/codex.rs`, `managed_mcp_capability.rs`, `managed_mcp_capability/ordinary_stage.rs`, runtime launch/profile contracts and Codex native request serializers listed in Task 1: narrowly preserve untrusted approval intent at the owned prepared-context boundary and refuse incompatible input before execution. | | Task 2 | `crates/freshell-freshagent/src/codex/projection.rs` (new): current native turn/item projection and incremental patch production. `codex.rs:7532,7849,9214,9520`: consume/overlay through existing session/snapshot/mapper. `shared/fresh-agent-contract.ts`: additive normalized patch and measured usage contracts. `src/store/freshAgentLiveTypes.ts`, `src/store/freshAgentLiveSlice.ts`: isolated normalized live rows/patches. `src/lib/fresh-agent-ws.ts`, new `src/lib/fresh-agent-transcript-projection.ts`, `FreshAgentView.tsx`: validate/apply/reconcile/render shared rows. `FreshAgentSidebar.tsx` displays the latest native plan, and `FreshAgentDiffPanel.tsx` can render supplied native unified diffs. Existing context-usage helpers render observed usage. | | Task 3 | New `codex/children.rs` and `FreshAgentChildObserver.tsx`: parent-scoped read-only child access. `codex.rs:9214`, `codex/metadata.rs`: current native item/child mapping. `shared/fresh-agent-contract.ts`: narrow item metadata additions if existing tool/delegation variants cannot represent a native fact. `FreshAgentItemCard.tsx`, `FreshAgentSidebar.tsx`, `FreshAgentView.tsx`: honest outcomes and provider-correct child action. | | Task 4 | New `codex/turn_state.rs` plus `codex.rs:3348,3500,4039,10442`: identity-owned dispatch/turn/Stop transitions; keep existing reducer and emission fences. `FreshAgentView.tsx` send-failure restoration: correlate refused Codex sends and preserve composer text. | -| Task 5 | `codex/controls.rs`, its `tests.rs`, new `codex/elicitation.rs`: preserve native control meaning and normalize bounded MCP forms. `shared/fresh-agent-contract.ts`, `shared/ws-protocol.ts`, `crates/freshell-protocol/src/client_messages.rs`, `src/store/freshAgentTypes.ts`, `freshAgentSlice.ts`, `fresh-agent-ws.ts`: additive control metadata/answer values only where these existing contracts carry controls. `FreshAgentQuestionBanner.tsx`, `FreshAgentApprovalBanner.tsx`, `FreshAgentApprovalCard.tsx`, new `FreshAgentElicitationBanner.tsx`, `FreshAgentView.tsx`: accessible native decisions/questions/forms/URL confirmation. | +| Task 5 | `codex/controls.rs`, its `tests.rs`, new `codex/elicitation.rs`: awaited controls/count and bounded MCP forms. Shared contracts/WS/store paths listed below retain exact native metadata/IDs. Existing question/approval banners/cards, new `FreshAgentElicitationBanner.tsx` and View render/answer real pending requests. Existing `FreshAgentItemCard.tsx`, `FreshAgentTranscript.tsx`, `FreshAgentComposer.tsx` and projection preserve true async transcript questions/context and route accepted/refused/ambiguous ordinary answers without fake controls. | | Task 6 | `codex.rs`, `codex/controls.rs`, `crates/freshell-ws/src/lib.rs:659`, `providers/fresh_agent.rs`, `host_actor.rs`, gateway proxy and client reset handler: retain provider state before lossy bridge, independently drain after loss and combine replay marks. Reuse `attention_ledger.rs` unchanged unless a provider-neutral visibility adjustment is required. | | Task 7 | `crates/freshell-codex/src/app_server.rs`, `model.rs`, new `catalog.rs`, its `lib.rs` exports; `crates/freshell-freshagent/src/model_capabilities.rs`; server/session-host catalog wiring; `shared/fresh-agent-models.ts`, `shared/fresh-agent-model-capabilities.ts`, `src/lib/fresh-agent-model-capabilities.ts`, registry, model dialog: installed catalog and faithful model/effort choices. | | Task 8 | `StartTurnParams` and serializer, Rust fresh-agent create/send/configure types, `codex.rs` and new `codex/settings.rs`; `shared/fresh-agent-contract.ts`, `shared/settings.ts`, `shared/ws-protocol.ts`, `src/store/paneTypes.ts`, persistence normalizers, create-content helper, shared registry/settings button/view: apply/persist full settings and native Plan/default. | @@ -92,22 +95,26 @@ The following conservative projection design removes any dependency on a native- 2. `freshAgent.transcript.patch` remains the one additive shared event. `{sessionId,projectionId,historyGeneration,ops}` carries safe-integer **consumed-event revisions only**. Ops are `resetTurn {nativeTurnId,turns}`, `upsertItem {nativeTurnId,row,item}`, `appendItem {nativeTurnId,rowId,itemId,field:'text'|'output'|'summary'|'content',index?,text}`, `settleTurn {nativeTurnId}`. Each op has a revision. Appends are admitted only for an event-owned item opened under the current native turn/operation and source generation; a late item/turn event cannot open an arbitrary historical turn. `resetTurn` with no rows removes a live group after accepted history mutation. 3. Append operations contain O(delta) bytes and mutate retained strings in place. Coalesce adjacent same-item/field/index appends for at most 33 ms; flush before item replacement/lifecycle/control/notice/teardown. Only structural row changes and item starts/completions carry full normalized state. Compare emitted byte growth for 1,000/2,000 equal deltas and assert exact content plus approximately linear growth. 4. Projection incarnation authority remains the existing runtime-owner epoch/generation and client snapshot-request binding generation. Introduce projectionId through authoritative create/attach/init; do not adopt a new incarnation from an arbitrary patch or UUID ordering. Rebind/materialization uses current aliases. Capture source and historyGeneration when starting a read; recheck after return. A read begun before accepted undo/compaction or source replacement is rejected and retried through the existing bounded scheduler, even if its native response arrives later. -5. A tracked snapshot combines explicitly distinct sources. Historical/final native bodies replace complete rows **atomically**. The retained event-owned active body is included at its captured eventRevision. An independently read incomplete native body is never an append baseline: when no safe event baseline exists after recovery, show it as a nonappendable native preview until a demonstrated native full replacement or new item-start baseline arrives. Task 1 must prove this eventually converges. Full `item/completed` event bodies replace their items, never concatenate them with native reads. A native terminal body supersedes event-owned state only when it contains the final items/error, or retain the completed event body until native history agrees; terminal status alone cannot erase content. -6. Snapshot metadata is `extensions.codex.projection = {id,eventRevision,historyGeneration,eventOwnedTurnIds,nativePreviewTurnIds,nativeFinalTurnIds}` with completed-item identities scoped by native turn. eventRevision describes **only the event-owned portion** captured in that snapshot. Client replays newer event ops only against that portion; a complete native item/turn replacement fences appends for that exact identity even when those queued ops have larger revisions. No native body is stamped as if it consumed a known notification prefix. Final/history membership derives from retained exact history and the current operation/source generation, never an evictable eight-entry convenience set. The existing eight-ended-turn cache may still deduplicate attention; it does not establish transcript finality or mutation membership. +5. A tracked snapshot combines distinct sources and provenance. Complete native rows replace a native turn group **atomically**; a retained event-owned active body carries its captured eventRevision. An independently read incomplete body is a nonappendable native preview, never a guessed append baseline. A full `item/completed` body replaces its item. `turn/completed` with Summary/NotLoaded items or interruption establishes status, not a complete body. Freeze available interrupted fragments and fence all old-operation appends. If cold recovery has lost every fragment owner, show the native saved interrupted turn and explain that only saved output remains; do not wait for a completed item that native never saves. Completed event text survives delayed/failed persistence under existing bounded retention with explicit pending/unsaved/error provenance; a bounded refresh failure stops settling without claiming durability. +6. Snapshot metadata is `extensions.codex.projection = {id,eventRevision,historyGeneration,eventOwnedTurnIds,nativePreviewTurnIds,nativeFinalTurnIds,turnOutputStates}`. `turnOutputStates` is keyed by native turn and carries saved/retained provenance, an optional interrupted/unsaved/provider-truncated diagnostic and whether refresh is still pending. It does not infer native byte counts or truncation from length. eventRevision describes **only the event-owned portion** captured. Complete-item replacement and terminal operation fences reject later appends even with larger revisions. Native bodies never pretend to have consumed a notification prefix. Final/history membership uses current exact history and operation/source generation, never the evictable eight-ended-turn attention cache. Frozen extras use existing bounded projection/snapshot retention; release incremental op logs once no longer appendable, without a new durable raw-token store. 7. Undo uses existing serialized native revert/rollback-record membership; removed identities cannot be recreated by late output. Compaction fences reads and replaces exactly the history native actually supplies; do not assume it deletes historical transcript rows. Mutation invalidation lasts for that history/source generation and existing durable rollback membership, not until an unrelated larger eventRevision. Fork keeps the existing identity/ownership transition and distinct child incarnation. -8. `mergeFreshAgentTranscriptProjection(snapshot,live)` preserves chronological positions and other turns while replacing event-owned groups and applying only permitted newer operations. Final live content retires when its exact native final content lands. Retain an honest diagnostic final/preview state if settlement needs recovery; never silently drop unlanded visible output. Hidden panes keep isolated live state without focus changes/per-token polling; reveal uses the existing dirty scheduler. A patch gap, `freshAgent.host.backpressure`, or `freshAgent.host.journal.reset` marks the source dirty and coalesces a resnapshot of authoritative provider-retained state. Reconnect/reload use native history plus retained event state and silent attention seeding. +8. `mergeFreshAgentTranscriptProjection(snapshot,live)` keeps chronological native-turn groups and applies only permitted newer operations. In Legacy history, compare the whole ordered role/content/tool-outcome representation for the same native turn and current history/source generation, ignoring only demonstrated reconstructed presentation item-ID differences. Repeated equal bodies and multiple roles remain ordered/distinct; no substring/fuzzy matching or coincidental `item-N` pairing. Paginated canonical items retain exact identity matching where supported. Native saved command/MCP bodies can be deliberately bounded at 64 KiB; preserve their actual markers/representation, mark saving complete but bounded, and stop impossible byte-equality retries. Keep richer available output separately identified within the same group, never concatenated or duplicated as another assistant turn. An unexplained shorter assistant body stays a diagnosed saved/retained discrepancy, not invented truncation. Release active bookkeeping after supported semantic agreement or a finite terminal state. Frozen retained output may survive rebind only for the same conversation/turn still in accepted history; it never permits old-incarnation appends, overrides a newer complete native body, or revives undo/compaction-removed membership. Hidden/reconnect/reload use existing bounded dirty scheduling, retained evidence and silent attention seeding. Gaps/backpressure/journal reset coalesce a fenced resnapshot; unavailable native reads preserve the last usable view with a typed availability error. + +Convergence means correct native saved history plus richer output actually still retained, each truthfully labeled. It does not promise durable recovery of every transient byte. Normal completed assistant content must appear exactly once after successful persistence/resume; interrupted, unsaved and provider-truncated cases must reach the finite states above. Task 1 captures each native case; Tasks 2/3/6/10 prove rendering, observation and recovery without hiding essential flows. ### Task 1: Prove the native 0.164 contract and align the managed Codex runtime **Files:** - Create: `scripts/testing/prepare-codex-contract.mjs`; `test/fixtures/coding-cli/codex-native-artifact.json`; `test/integration/providers/codex-local-provider.mjs`; `test/integration/providers/codex-cli-contract.mjs`; `test/integration/providers/codex-cli-contract.test.ts`. - Modify: `docker/cloud-run/Dockerfile`; `docker/runtime/provider-versions.json`; `docker/runtime/Dockerfile`; `scripts/testing/runtime-sandbox.ts` (Codex version receipt reads the pin instead of stale literal). -- Test: new native contract test and standalone offline contract runner; actual managed image native receipt using the existing owned `RuntimeSandbox` image preparation in `scripts/testing/runtime-sandbox.ts`. +- Modify: `crates/freshell-server/src/managed_provider_references/codex.rs`; `crates/freshell-server/src/managed_mcp_capability.rs`; `crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs`; `crates/freshell-runtime-protocol/src/lib.rs`; `crates/freshell-agent-runtime/src/host_actor.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`; `crates/freshell-freshagent/src/codex.rs`; `crates/freshell-codex/src/app_server.rs`; `crates/freshell-codex/src/lib.rs`: only the source-supported prepared-config/typed-policy compatibility path needed to prove managed untrusted intent before broader settings work. +- Test: new native contract test/standalone offline runner and actual managed image receipt using existing `RuntimeSandbox`; embedded behavioral tests in the listed preparation/native-request/provider/profile files cover projection, typed propagation and early refusal. Cross-task settings regression remains in Task 8. **Interfaces:** -- Produces: `prepare-codex-contract.mjs --output ABS` for connected verified artifact preparation and `--sandbox` to stage the completed artifact in the existing executable volume before any offline invocation. `--stage-prepared` copies only a verified prepared entry; it does not download. The helper writes `.runtime-build/codex-contract/staged.json` with the owned sandbox artifact path and integrity for the standalone runner. Cloud tests receive `FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64` from their test image. Neither runtime path fetches packages or falls back to PATH. -- Produces: `captureNativeScenario(name,options) -> Promise` in the native harness, with `{binary:{path,version,platform,integrity},threadId,requests,responses,notifications,reads,fixtureTraffic,observations}`. Scenario names are `text-history`, `active-input-stop`, `parent-child-read`, `plan-question-controls`, `mcp-form-url`, `model-effort-usage`; each has bounded deadlines and sanitized raw evidence. CLI `--scenario all --receipt-dir ABS` runs all; `--managed-runtime --receipt-dir ABS` prepares the existing run-owned managed image then runs the same real-native scenarios inside it with the image's intended `/usr/local/bin/codex`. +- Produces: `prepare-codex-contract.mjs --output ABS` for connected verified artifact preparation and `--sandbox` for connected image/pinned-pnpm fingerprint warmup plus staging into the existing executable volume before offline entry. `--stage-prepared` copies only a verified completed entry and never downloads. In the ordinary connected wrapper it reads `/workspace/.runtime-build/codex-contract/...` and writes `/workspace/target/`; `.runtime-build/codex-contract/staged.json` publishes `/freshell-test-artifacts/` for the offline alias of that same volume. The manifest records integrity, owned writable state/receipt roots and matching prerequisites. `--verify-sandbox` checks already-prepared image/dependency fingerprint/artifact/writable-root prerequisites through an owned read-only verifier before invoking the offline wrapper's install-capable entrypoint; it never warms, purges or downloads. Missing/stale prerequisites fail there before runtime startup. Cloud tests use final-image `FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64`. Neither runtime downloads or falls back to PATH. +- Produces: `captureNativeScenario(name,options) -> Promise` in the native harness, with `{binary:{path,version,platform,integrity},threadId,requests,responses,notifications,reads,fixtureTraffic,observations}` plus final UID/GID, permissions, effective sanitized context, prerequisite and writable-root receipts. Scenario names are `text-history`, `active-input-stop`, `parent-child-read`, `plan-question-controls`, `mcp-form-url`, `model-effort-usage`, `approval-policy-restricted-exec`; each has bounded deadlines and sanitized raw evidence. CLI `--scenario all` runs all; offline receipts default to the manifest's owned translated receipt subdirectory. `--receipt-dir PATH` explicitly selects a run-owned root, resolved to the executing environment's absolute writable path. `--managed-runtime --receipt-dir PATH` uses the existing run-owned image seam and executes the same scenarios through intended `/usr/local/bin/codex` as the actual provider UID/GID, distinct from absolute-artifact proof. - Produces: claim-specific native receipts for F1/F2/F3/F4/F6/F7, selected actual feature/configuration flags/tool schemas and observed behavior used by later fixture expectations. No new universal protocol SDK, packaging framework, native lifecycle controller or production installation. +- Produces: preserved effective `untrusted` policy in existing managed launch/profile settings and typed native create/resume/fork/next-turn requests, with declaring-source identity. Only owned prepared public config/layers omit the rejected declaration. If that cannot preserve effective source precedence/requirements/unrelated settings, return an early typed configuration incompatibility naming the setting/source and preserve the prompt/settings. Task 8 consumes this compatibility path; no public `-c` substitute or real-home rewrite. - [ ] **Step 1: Add the failing behavioral contract and minimal executable harness** @@ -125,11 +132,13 @@ it('retains the actual final answer through exact history and resume', async () }) ``` -`finalAssistantText`/`resumedAssistantText` are derived from captured native thread/read bodies by native item ID, not injected normalized snapshots. Minimal fixture code and staging helper belong in this step so missing imports/prerequisites are fixed before the intended red run. Before changing the managed pin, run its actual binary receipt: the current runtime supplies 0.147.0, so the required 0.164.0 native-version assertion fails for the intended reason. No static version-string test substitutes for executing that runtime binary. +`finalAssistantText`/`resumedAssistantText` are derived from captured native thread/read bodies by native turn and ordered role/content; canonical item IDs are used only in history modes preserving them. Minimal fixture code and staging helper belong in this step so missing imports/prerequisites are fixed before the intended red run. Before changing the managed pin, run its actual binary receipt: the current runtime supplies 0.147.0, so the required 0.164.0 native-version assertion fails for the intended reason. No static version-string test substitutes for executing that runtime binary. - [ ] **Step 2: Stage before offline entry and establish intended red evidence** -Known Linux x64 artifact metadata is exact: URL `https://registry.npmjs.org/@openai/codex/-/codex-0.164.0-linux-x64.tgz`, integrity `sha512-XLAoaWsGOT2khzl+P4FYDPTkzAr9W0VFXFaVkqlFdkM0soa1gH/4S0wSD57Ek3HHwkirAEGz2/Mwehseh+l5Mg==`, installed executable `vendor/x86_64-unknown-linux-musl/bin/codex`, unpacked package 444,349,626 bytes. Pin these in the test artifact JSON, verify downloaded bytes/manifest/actual layout and publish an atomic completed directory. Use a connected owned `.runtime-build/codex-contract` stage; the archive plus unpacked artifact must never occupy the 512 MiB `/tmp` or 256 MiB `.cache` runtime tmpfs together. `--sandbox` copies the verified artifact into a unique run/worktree subdirectory of the existing `freshell-sandbox-cargo-target` volume at `/freshell-test-artifacts`, using an owned container and the existing sandbox wrapper. Staging occurs before `--network none`; the offline harness only reads staged bytes and writes its owned home/receipts. +Known Linux x64 artifact metadata is exact: URL `https://registry.npmjs.org/@openai/codex/-/codex-0.164.0-linux-x64.tgz`, integrity `sha512-XLAoaWsGOT2khzl+P4FYDPTkzAr9W0VFXFaVkqlFdkM0soa1gH/4S0wSD57Ek3HHwkirAEGz2/Mwehseh+l5Mg==`, installed executable `vendor/x86_64-unknown-linux-musl/bin/codex`, unpacked package 444,349,626 bytes. Pin these in the test artifact JSON, verify archive bytes/manifest/layout and publish an atomic completed directory containing the full verified vendor/resource tree and bundled helpers. The installed mode 0700 is not evidence of archive permissions. Normalize permissions only inside the owned completed subtree; verify readable files, executable helpers and search access through **every ancestor** as final cloud `node`, sandbox and provider identities. Unavailable ancestors fail the gate, without broad chmod/chown outside owned paths. Keep archive/unpacked bytes out of the 512 MiB `/tmp` and 256 MiB `.cache` tmpfs. + +`--sandbox` uses the connected owned `.runtime-build/codex-contract` stage and existing ordinary wrapper to warm the sandbox image and exact pinned-pnpm lock/member-manifest dependency fingerprint before `--network none`. Copy from container `/workspace/.runtime-build/...` to `/workspace/target/` on `freshell-sandbox-cargo-target`, and publish the translated offline `/freshell-test-artifacts/` in staged.json. The connected wrapper does not expose the offline alias. Offline startup must fail fast on missing/stale artifact, image or dependency prerequisites without triggering reinstall/download/backend fallback. The runtime harness writes HOME/CODEX_HOME/XDG/workspace/receipts only under explicitly owned writable roots, precreates/canonicalizes CODEX_HOME, and reads the artifact without mutating it or assuming `/home/sandbox` is writable. Run: `node scripts/testing/prepare-codex-contract.mjs --sandbox`. @@ -139,25 +148,30 @@ Expected: staging succeeds, with actual byte/integrity/layout receipt; managed r - [ ] **Step 3: Prove the isolated native flows, then align the managed pin** -In `docker/cloud-run/Dockerfile`, after the connected node-builder copies source, run the preparer with `--output /opt/freshell-test-providers/codex/0.164.0-linux-x64`; copy that completed directory into the final test image and set the contract-root env above. In ordinary contract execution, missing/corrupt artifact is a loud prerequisite failure; never attempt a network package bootstrap inside offline runtime. Compare ambient `codex` resolution as a present-or-absent value before/after artifact preparation, not an assumption it exists in the cloud image. Root deps/lock/workspaces/.bin and host production CLI stay unchanged. Linux x64 is the required executing platform for these lanes; metadata for six published platforms is not execution proof. Add another platform receipt only when that actual compatible host executes the contract. +In `docker/cloud-run/Dockerfile`, after connected node-builder source copy, run the preparer with `--output /opt/freshell-test-providers/codex/0.164.0-linux-x64`; explicitly copy the completed tree into the final image and set the contract-root ENV in that final stage. Keep `.runtime-build` source-upload exclusions unchanged. Verify final copied resources and ancestor traversal/read/execute as final `node`, including `/opt` paths outside `/app` ownership adjustment. Missing/corrupt artifacts or stale offline prerequisites fail loudly without package bootstrap. Compare ambient `codex` resolution as a present-or-absent value before/after preparation. Root deps/lock/workspaces/.bin and host production CLI stay unchanged. Required cloud/offline receipts separately record actual Linux x64 architecture, verified bytes/layout/path, final UID/GID and permissions, warmed prerequisites, writable roots, native version/scenario traffic and unchanged ambient resolution. Six-platform metadata is not execution proof; the managed npm-wrapper proof is separate. -Run the real 0.164 binary with owned HOME/CODEX_HOME/XDG/workspace, no inherited credentials/proxies, an owned loopback Responses endpoint and a local configured MCP server. Inspect exact-version native source/configuration and advertised tools first; determine necessary initialization capabilities/feature flags from actual evidence. Never infer working Plan/MCP/collaboration setup from schema spelling alone. The fixture selects tool calls from the names/schema actually advertised by that binary. No external model endpoint is called. +Run the published 0.164 release binary with owned writable HOME/CODEX_HOME/XDG/workspace/receipts, a distinct no-auth custom Responses provider at loopback `/v1` using Responses HTTP transport with WebSockets disabled, and ordinary configured local MCP. Send coherent SSE IDs/deltas/final bodies/usage and subsequent model responses after actual native tool results. Start fixtures in the executing container namespace at IPv4 `127.0.0.1`; no external model calls. Use public release configuration and real advertised/discovered tool schemas. Upstream `TestAppServer` debug-only plugin-startup flags/LoaderOverrides are not a release recipe. Control `/etc/codex/config.toml`, `/etc/codex/requirements.toml` and trusted project layers only in the owned container/workspace, and clear inherited credentials/proxies/remote-control environment. HOME isolation alone is insufficient. Capture actual initialize capabilities, sanitized effective config, advertised tools and traffic. + +For network approval prefer the supported configured-proxy path if sufficient; requirements are not universally necessary. If the chosen recipe needs requirements, stage them at the supported system location only inside the owned container. Stdin approval needs actual unified-exec/permission features and the real process/session ID returned by execution. Async question exposure additionally needs root-thread/model supported-tool metadata supplied through public owned model configuration, observed advertisement, and continuing work. Production rendering accepts such content without forcing unrelated models/users onto experimental flags. **Hard early native gate, before dependent production implementation:** -1. F1: capture native active read while streaming, final item/turn and exact full-history read, plus same-thread resume of interrupted/active history. Establish eventual complete final bodies and a recovery policy that converges from a nonappendable native preview without guessing a delta offset. Record native read-ahead/behind observations; application revisions make no stronger native ordering claim. -2. F2: idle acceptance, active accepted input or explicit refusal, actual same native turn IDs for steering, response versus turn-terminal ordering, Stop while initial acceptance is pending, interrupt acknowledgement versus actual terminal/error/death evidence. If explicit `turn/steer expectedTurnId` is the demonstrated safer mechanism, select that narrow implementation and record it; do not invent an extra turn or replay ambiguity. -3. F3: create one actual native child and observe it through the parent's running context during activity and after completion. Record zero Freshell child-start/resume calls. Prove either exact parent-client read or exact provider-owned saved history with honest pending/saved status and final convergence. No child release is inferred from completed activity. If no supported observer source supplies that view, stop Task 3 implementation until the read mechanism is demonstrated. -4. F7: actual Plan setup/tool exposure, blocking/nonblocking questions and resolution/cancellation lifetime, command/network/stdin approval alternatives and request/response IDs, configured MCP flat form and URL elicitation, selected model/effort, and usage semantics. Capture final history and native responses for each. If last.totalTokens is not demonstrated current context occupancy, display it as Latest usage from the native last sample and leave context utilization unknown. -5. F6: execute the staged binary from both assembled cloud test image and offline sandbox, record its actual path/version/integrity and fixture-only traffic. Metadata or a local global CLI pass is insufficient. +1. F1: normal completed assistant event/read/resume appears exactly once in both relevant history modes; capture Legacy synthetic IDs, duplicate equal bodies and multi-role ordering. Capture nonempty deltas interrupted before item completion, warm retained partials and cold saved recovery without invented fragments; terminal Summary/NotLoaded is not a full body. Capture delayed/missing persistence and bounded refresh failure, and command/MCP payloads over the native 64 KiB persistence bound with actual saved markers and richer retained output. Demonstrate finite interrupted/unsaved/provider-truncated outcomes without guessed offsets, fuzzy matching or impossible equality retries. Record read-ahead/behind without inventing native event watermarks. +2. F2: idle `turn/start` and active `turn/steer` with `expectedTurnId`, same-ID acceptance, no-active/mismatch refusal, events before acceptance reply, natural completion racing Stop, pending-start Stop, and matching terminal/owned-exit evidence distinct from ACK. Verify the adapter's new-start issuance waits for the previous Stop to resolve, with the consumer still progressing; ambiguous Stop cannot release it merely by timeout. Startup empty-ID cancellation requires actual startup result/confirmed owned exit, not an invented turn terminal. Preserve ambiguous prompt/receipt without replay. +3. F3: prove ordinary Freshell parent/child durability/defaults, actual exact child relationship, parent-client active/final/saved reads and bounded genuinely unmaterialized pending. An explicitly ephemeral child returns permanent `ephemeral_history_unavailable` with absent snapshot, not endless pending/empty success. Preserve selected ID/available content on temporary source failure. Record zero observer child create/resume/send/stop/configure calls, no inferred release, no ephemerality rewrite. Ordinary durable observation must actually work before Task 3. +4. F7: actual Plan/tool exposure; awaited Plan true and Default false `isBlocking` requests both need exact answers and cancellation; separate true async AgentMessage questions have no pending RPC and continue work, with ordinary contextual input accepted. Capture command/network/stdin approval alternatives, native typed request/approval/item/elicitation IDs, permitted effects and absence of denied effects. Configured MCP ordinary flat, `openai/form`→MCP `openai/form`, `openaiForm`→MCP `openai/elicitation/create`, and URL accept/decline/cancel each need their actual capability route/resolution; opening URL is not accept. Capture model/effort/usage/history; label `last.totalTokens` Latest usage unless actual semantics prove context occupancy. +5. F6: execute the complete staged artifact in assembled configured-cloud image and networkless unprivileged sandbox, with all lane-specific byte/path/version/UID/prerequisite/permission/writable-root/traffic receipts above. Metadata, global CLI, debug test hooks, fake native frames or a skipped upstream test cannot satisfy a case. +6. F4: before managed alignment, add behavioral red coverage for rejected public untrusted config and intended approval effects. Implement only a faithful existing owned prepared-config projection plus explicit typed policy. Remove the rejected declaration from every applicable owned prepared layer, preserve declaring-source precedence and unrelated settings, and never rewrite source HOME/public `-c` or substitute policy. Prove create/resume/fork/send/child effective policy, requirement-constrained refusal, actual approval and no denied command/stdin effects. Actual permitted read-only/workspace-write commands and denied out-of-scope effects under real provider UID/GID and unchanged isolation are hard managed gates. Legacy Landlock rejects these restricted modes; speculative packages/flags/security relaxation/full-access tests cannot clear a failure. An observed failure remains failed until a demonstrated supported correction or an explicit evidence-backed version decision. + +Run: `node scripts/testing/prepare-codex-contract.mjs --verify-sandbox`. Expected: PASS only for the already warmed matching image/dependency fingerprint, complete artifact and owned writable roots; otherwise fail before the offline entrypoint can reinstall. -Run: `scripts/sandbox-test.sh --runtime-suite "node test/integration/providers/codex-cli-contract.mjs --scenario all --receipt-dir /freshell-test-artifacts/codex-contract-early-receipts"`. +Run: `scripts/sandbox-test.sh --runtime-suite "node test/integration/providers/codex-cli-contract.mjs --scenario all"`. Receipts use staged.json's unique owned `/freshell-test-artifacts//receipts` path, not a shared volume-root directory. Run: `GCLOUD_ROBOT_REQUIRE=1 pnpm run test:vitest run test/integration/providers/codex-cli-contract.test.ts --config config/vitest/vitest.config.ts`. -Expected: actual pinned-native captures PASS in both required environments. Shared executions may supply several receipts but retain separate F1/F2/F3/F6/F7 conclusions. If one fails, diagnose/fix the fixture using native source/errors or select a demonstrated supported configuration and rerun; a fake app-server frame, skipped scenario, silent backend change or unexecuted platform declaration cannot replace it. Unavailable necessary execution remains a failed early gate; dependent features do not proceed on an invented guarantee. +Expected: actual pinned-native captures PASS in both required environments. Shared captures retain separately attributable F1/F2/F3/F4/F6/F7 conclusions; staged F4 policy/restricted-exec behavior does not replace the intended managed-provider receipt below. If a case fails, diagnose using native source/errors and prove a supported correction without weakening intent/isolation; fake frames, skipped cases, backend changes or unexecuted declarations cannot replace it. Unavailable necessary execution remains a failed early gate; dependent features do not proceed. -Only after that native gate, set Codex version to `0.164.0` in `docker/runtime/provider-versions.json` and `ARG CODEX_VERSION=0.164.0` in `docker/runtime/Dockerfile`. Preserve the existing install/version verification and every other provider pin. Make `scripts/testing/runtime-sandbox.ts` derive its Codex receipt version from the manifest instead of stale 0.147.0. Build the existing run-owned managed test image, execute its version and the essential native scenarios there, and retain separate F4 image/native receipts. No image is deployed and no live service is restarted. +Only after the staged native gate, set Codex version to `0.164.0` in `docker/runtime/provider-versions.json` and `ARG CODEX_VERSION=0.164.0` in `docker/runtime/Dockerfile`. Preserve install/version verification and all other provider pins; derive runtime-sandbox receipts from the manifest. Build the existing run-owned managed image and prove all essential scenarios, prepared approval projection and actual restricted commands through its intended npm wrapper/native resources as the real provider identity. Record digest, wrapper/native resolution, UID/GID, sanitized owning context, permissions/limits, allowed effects and denied effects. `--version`, text-only, staged-artifact or full-access success cannot satisfy this separate F4 gate. Do not proceed to dependent tasks if it fails; no image deployment, production qualification or service restart is authorized. - [ ] **Step 4: Confirm green native/runtime evidence** @@ -177,12 +191,15 @@ Run: `GCLOUD_ROBOT_REQUIRE=1 pnpm run test:vitest run test/integration/providers Run: `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir .runtime-build/codex-contract/managed-final`. +Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-complete/run-focused-rust.py --build-server -p freshell-codex -p freshell-freshagent -p freshell-server -p freshell-session-host -p freshell-runtime-protocol -p freshell-agent-runtime` for the Task 1 compatibility projection, typed propagation and refusal/requirement effects; no text-only config assertions count. + Expected: PASS with nonzero native scenarios and owned runtime-image receipts. Record image digest, executing architecture and exact provider versions. Final full-suite/hosted/browser evidence remains in Task 10; fixture parity alone does not prove the upgraded managed binary. - [ ] **Step 7: Commit** ```bash git add scripts/testing/prepare-codex-contract.mjs test/fixtures/coding-cli/codex-native-artifact.json test/integration/providers/codex-local-provider.mjs test/integration/providers/codex-cli-contract.mjs test/integration/providers/codex-cli-contract.test.ts docker/cloud-run/Dockerfile docker/runtime/provider-versions.json docker/runtime/Dockerfile scripts/testing/runtime-sandbox.ts +git add crates/freshell-server/src/managed_provider_references/codex.rs crates/freshell-server/src/managed_mcp_capability.rs crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs crates/freshell-runtime-protocol/src/lib.rs crates/freshell-agent-runtime/src/host_actor.rs crates/freshell-session-host/src/providers/fresh_agent.rs crates/freshell-freshagent/src/codex.rs crates/freshell-codex/src/app_server.rs crates/freshell-codex/src/lib.rs git commit -m "feat(codex): establish verified 0.164 native runtime contract" ``` @@ -197,7 +214,7 @@ git commit -m "feat(codex): establish verified 0.164 native runtime contract" **Interfaces:** - Consumes: Task 1 actual native captures and source-authority decision; `CodexNotification::Other { method, params }`, existing mapper/builder and `FreshAgentEvent { provider,session_type,session_id,event }`. The Task 1 F1/F7 gate must pass before this production implementation. -- Produces: `CodexProjection::new(projection_id:String, thread_id:String)`, `apply(&mut self, method:&str, params:&Value) -> Result, ProjectionError>`, `begin_history_read(&self) -> SnapshotReadFence`, `accept_native_history(&mut self, raw:&Value, read_fence:&SnapshotReadFence) -> Result`; normalized incremental patch contract above. `SnapshotReadFence` carries projectionId, existing owner epoch/generation and historyGeneration. `ProjectionSnapshot` carries the chosen raw bodies, eventRevision and explicit event-owned/native-preview/native-final memberships described above. `SnapshotStale` requests one bounded reread after source/mutation change. `ProjectionError` includes native method and IDs, never full secret-bearing payload. +- Produces: `CodexProjection::new(projection_id:String, thread_id:String)`, `apply(&mut self, method:&str, params:&Value) -> Result, ProjectionError>`, `begin_history_read(&self) -> SnapshotReadFence`, `accept_native_history(&mut self, raw:&Value, read_fence:&SnapshotReadFence) -> Result`; normalized patch contract above. `SnapshotReadFence` carries projectionId, existing owner epoch/generation and historyGeneration. `ProjectionSnapshot` carries chosen raw bodies, eventRevision, event-owned/native-preview/native-final memberships and `turnOutputStates` provenance/finite diagnostics. Native read errors preserve usable retained state and expose typed unavailable/retryable status rather than a blank successful snapshot. `SnapshotStale` requests bounded reread; `ProjectionError` includes method/IDs, never secret-bearing payload. - Produces: `FreshAgentTranscriptPatchSchema`; `applyFreshAgentTranscriptPatch(state,patch)` and `mergeFreshAgentTranscriptProjection(snapshot,live)` using existing `FreshAgentTurn`/item types. Live payload remains outside the general session slice. - [ ] **Step 1: Write failing behavioral tests** @@ -232,9 +249,9 @@ fn native_text_delta_then_full_item_is_one_exact_item() { } ``` -Also drive native frames through `insert_fake_session_with_real_consumer`, not only the pure helper. Before completion, assert the broadcast contains usable text and command-output appends. Send two interleaved threads, multiple items, reasoning summary/content indices, fragmented Unicode strings, patchUpdated file changes, MCP progress, a retry/error, and turn end. Assert final snapshot equals complete native history once, failures remain failures, and no old-turn event retires or overwrites the newer turn. Verify native usage `{ last,total,modelContextWindow }`: cumulative total is not last, cached/reasoning subsets are not counted twice, no fabricated money, and unavailable differs from measured zero. Plan/diff updates replace previous aggregate values. +Also drive native frames through `insert_fake_session_with_real_consumer`, not only the pure helper. Before completion, assert usable text and command-output appends. Send two interleaved threads, multiple items, indexed reasoning, Unicode fragments, patchUpdated files, MCP progress, retry/error and turn end. For normal persisted assistant output assert exact whole-turn replacement once; for interrupted/unsaved/truncated output assert the separately labeled finite states, retained richer output and truthful saved representation. Failures stay failures; old-turn events cannot overwrite newer work. Verify native usage `{ last,total,modelContextWindow }`: cumulative total is not last, cached/reasoning subsets are not counted twice, no fabricated money, unavailable differs from measured zero. Plan/diff updates replace prior aggregates. -Drive the real patch reducer and merge helper through both independent-reader schedules. Event start plus `Hel` is event-owned; a native active read ahead at `Hello` must not become its append baseline, and the later `lo` yields `Hello` once. A native active read behind at empty must not erase `Hel`. When native full final `Hello` arrives before a queued larger-revision `lo`, final replacement fences that append. After more than eight later turns, a delayed old delta still cannot reopen the final turn. Recovery partial `prefix` is a nonappendable native preview; full item replacement/new safe start converges using the mechanism Task 1 actually proved. Hold a read across accepted undo/compaction and source replacement: old response is rejected by its source/history generation, not accepted because a larger event counter exists. Repeat event-owned snapshot revision 1 versus later append revision 2, full-item completion before/after native read, row split, hidden/reveal, disconnect/refetch and host reset/backpressure. Render through `FreshAgentTranscript`, not object comparisons alone. +Drive reducer/merge through independent-reader schedules: event `Hel`, native read ahead `Hello`, later `lo` yields `Hello` once without using read text as an append baseline; a behind read cannot erase `Hel`. Complete-item replacement fences a queued higher-revision append, including after eight later turns. A nonappendable recovery preview receives a normal complete replacement or freezes visibly interrupted when no completed item exists. Warm interruption retains fragments; cold native-only recovery has a finite saved-only interrupted view. Summary/NotLoaded turn completion cannot erase content or pretend full replacement. Missing persistence stops bounded refresh with explicit unsaved output; captured command/MCP results over 64 KiB preserve actual native truncation plus richer retained output without endless equality retries. Legacy tests vary only evidenced synthetic IDs and keep exact ordered duplicate bodies/multi-role turns; unexplained assistant differences stay diagnosed. Hold reads across undo/compaction/source change and reject stale generations. Frozen extras cannot revive removed membership or accept old-incarnation deltas. Repeat item completion/read order, row splits, hidden/reveal, disconnect/refetch and host loss; render all provenance/finite states through `FreshAgentTranscript`. - [ ] **Step 2: Run and confirm intended failures** @@ -263,11 +280,11 @@ Use native-indexed arrays for reasoning, whole replacements for `turn/plan/updat For usage, preserve raw native `last/total` and context window in `extensions.codex` and populate shared tokenUsage from native `total`. Add an optional shared `observed` boolean (absent remains backward compatible), set it only after an actual notification, and display unknown until then. Preserve native `last.totalTokens` as the measured native last sample without adding cached/reasoning subsets. Use it as current context occupancy only if Task 1 captured semantics establish that meaning; otherwise label it Latest usage and leave utilization unknown. Add optional shared `modelContextWindow`; enable a Codex model-window fallback in the existing context helper/view only for the demonstrated numerator meaning; label that denominator as model context limit, never as an invented compaction threshold. Preserve existing indexed compaction-threshold usage where actually measured, and leave unknown until both native numerator/denominator exist. Do not report cumulative lifetime input as current context. Latest plan steps and explanation live in `extensions.codex.plan` and render a small Plan section in the existing `FreshAgentSidebar` with pending/in-progress/completed statuses; there is no pre-existing generic plan panel to assume. Unified native turn diff is retained in projection and exposed as `FreshAgentDiffSummary.diff?:string` on a stable turn diff entry with title `Turn changes`. Extend the existing `FreshAgentDiffPanel` to display a supplied diff directly and use the current cwd/path fetch only when no provider diff was supplied; later native replacements update expanded content without appending or fetching an unrelated current git diff. Preserve native per-file change diff for approval previews. Tests expand actual supplied/updated diff content and verify no cwd/path fetch was needed. -The consumer applies projection before broadcasting, within the existing per-session ordering barrier, and flushes pending patch ops before lifecycle/control events. On tracked snapshot, capture source/history read fence before native RPC, recheck it after return, choose event-owned versus whole native bodies by the source-authority contract, and stamp only the captured event-owned revision/memberships; reuse current builders. Never initialize an appendable body from an independent active read. Client parses the additive event, applies it in `freshAgentLive`, and merges normalized rows into the existing transcript props. Reuse current snapshot scheduler/request-authority checks and hidden-pane dirty handling. Implement the source-authority eight-point contract above in these helpers; do not scatter revision logic across render branches. +The consumer applies projection before broadcast within existing ordering, flushing patch ops before lifecycle/control. Capture/recheck source/history read fences around RPC, choose event-owned versus whole native bodies and stamp only captured event revision/memberships. Never initialize an append baseline from an independent read. Implement whole-native-turn Legacy comparison and finite saved/retained states in the projection/merge helpers: only demonstrated synthetic ID changes are normalized; roles, order, duplicates and outcomes stay exact. Render one group with concise saved/retained/interrupted/unsaved/truncated provenance and a retained-output disclosure only where useful. Preserve actual native truncation markers; a shorter assistant body is not automatically truncation. Freeze terminal retained bodies, release incremental logs, bound refresh using existing scheduling, and preserve content on unavailable reads. Reuse shared transcript/live state, request authority and hidden dirty handling; no universal journal or scattered revision rules. - [ ] **Step 4: Run the focused tests** -Run the same two commands from Step 2. Expected: PASS with growing pre-completion content, exact final replacement, and linear emitted bytes. +Run the same two commands from Step 2. Expected: PASS with pre-completion content, exact normal whole-turn replacement, finite interrupted/unsaved/truncated views and linear emitted bytes. - [ ] **Step 5: Refactor while green** @@ -300,7 +317,7 @@ git commit -m "feat(codex): project native live transcript and usage" - Consumes: Task 1 F3 real-native parent/child read proof, Task 2 projection, existing `map_codex_item(item_id,&Value,item_type)`, metadata collector and shared transcript renderer. No independent child-resume/release guarantee is assumed. - Produces: faithful normalized rows for `subAgentActivity`, `functionCallOutput`, `sleep`; parent-scoped child observation action. Existing `collab_agent` remains for multi-receiver native tool calls. New activity can reuse `task_delegation` with `childSessionId`, status/model fields added only if necessary; sleep can use a named tool row with duration. Function output becomes a visible tool-result with stable `toolUseId` based on its native ID, without requiring an absent preceding tool-use row. -- Produces: `FreshAgentChildRead { parentSessionId:string, childThreadId:string, observedEpoch?:number, observedGeneration?:number }` and `FreshAgentChildObservation { parentThreadId:string, childThreadId:string, status:'live'|'saved'|'pending', snapshot:FreshAgentSnapshot }`. HTTP `GET /api/fresh-agent/threads/freshcodex/codex/{parentSessionId}/children/{childThreadId}` carries the existing observed owner pair as query parameters. `FreshCodexState::read_child(parent_session_id:&str,child_thread_id:&str,observed_epoch:Option,observed_generation:Option) -> Result` resolves the parent, verifies the native child relationship, and uses only the parent's proved read context. +- Produces: `FreshAgentChildRead { parentSessionId:string, childThreadId:string, observedEpoch?:number, observedGeneration?:number }`. `FreshAgentChildObservation` is a discriminated union with common `{parentThreadId:string,childThreadId:string}`: readable `{status:'live'|'saved',snapshot:FreshAgentSnapshot}`; genuinely unmaterialized `{status:'pending',reason:'history_unmaterialized',retryable:true,snapshot?:never}`; unavailable `{status:'unavailable',reason:'ephemeral_history_unavailable'|'history_unavailable'|'source_unavailable',retryable:boolean,snapshot?:never}`. Activity/turn status and output completeness stay in native metadata/snapshot, separate from availability. HTTP `GET /api/fresh-agent/threads/freshcodex/codex/{parentSessionId}/children/{childThreadId}` carries existing observed owner query fields. `FreshCodexState::read_child(parent_session_id:&str,child_thread_id:&str,observed_epoch:Option,observed_generation:Option) -> Result` resolves/fences the parent and verifies the exact child relationship; no empty success snapshot for unavailable content. - Produces: minimal managed `FreshAgentReadChildRequest {soul_id,child_thread_id,expected_control_epoch}` / `HostCommand::FreshAgentReadChild {incarnation_id,child_thread_id}` and corresponding `FreshAgentChildSnapshot(Value)` result through current typed client/supervisor/session-host dispatch. Gateway resolves the parent alias to its root soul, never resolves the child as a registered root. Recheck the existing same_snapshot_source tuple after read. - Produces: `FreshAgentChildObserver({parent,childThreadId,onClose})` using `FreshAgentTranscript` in a read-only dialog/section. Parent includes its current provider/session/owner fence. Persist only `observedChildThreadId?:string` in parent pane content so reload can restore observation; no independent child pane/session lifecycle. @@ -322,6 +339,8 @@ it('renders a declined command as declined rather than completed', () => { Pair command/file-change running/completed/failed/declined cases through the real transcript; expand a failed command and assert actual exit code appears. Native mapper tests feed exact 0.164 shapes: subAgentActivity started/interacted/interrupted/completed with agentThreadId, functionCallOutput string and typed content array, sleep duration; assert visible normalized content and stable IDs. Multi-child collab tests retain distinct receiver result/error/status/model and clicking one child selects that exact Codex identity. Upload an actual small image/text file through the composer and verify attachment name/path remains in the user transcript after reload; native image/localImage history retains a meaningful image label/path rather than a bare `[type]` placeholder. Assert image output references remain inspectable after reload using existing authenticated file/link affordances; no new media server or thumbnail promise is necessary. Include missing/unknown native variant: structured diagnostic and graceful history, no empty assistant/user row. +Observer tests cover durable active/final/saved reads with exact parent/child identity, pending pre-materialization then materialization, bounded refresh exhaustion, and terminal ephemeral unavailable with absent snapshot and specific reason. Preserve a selected child's already available content when source temporarily fails/rebinds, clearly labeling stale availability; do not erase it or switch HOME. Ordinary saved starts preserve demonstrated durable defaults; explicitly ephemeral/resumed contexts stay ephemeral. Count zero observer create/resume/send/stop/configure operations across local/managed reads, reload and all failure states. Native truncation/interruption follows Task 2 semantics rather than promising every token from read-only history. + - [ ] **Step 2: Confirm red** Run: `pnpm run test:vitest run test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx --config config/vitest/vitest.config.ts`. @@ -334,24 +353,19 @@ Expected: declined currently appears complete, new variants are dropped, child c Extend current mapper and role classifier; map native subagent activity to a task/delegation activity with child native identity and direct status mapping. Preserve function-call output plain text and content-item text/image references; unknown content items keep a readable type/result representation, not blank output. Map sleep to a visible tool activity with actual duration. Preserve optional agent-message phase/async choice metadata as optional item extensions used for a readable choice list; never infer completion merely from absent phase. -Extend `FreshAgentToolDisplay.status` with `'declined'` and preserve exitCode. In command and file-change adapters use: - -```ts -const status = item.status === 'running' ? 'running' - : item.status === 'declined' ? 'declined' : 'complete' -``` +Extend `FreshAgentToolDisplay.status` with `'declined'` and preserve exitCode. Command/file-change adapters retain running, failed and declined distinctions, mapping only actual completed outcomes to complete; do not use a catch-all success branch. `FreshAgentToolBlock` renders declined with a neutral icon plus accessible label `declined`, completed with the existing success semantics, failed with existing error semantics, and exit status in expanded command details. Keep the tool renderer shared. Keep the OpenCode resume action unchanged. Add a distinct Codex `observe-child` action carrying the parent reference/fence and selected child native ID. `FreshAgentChildObserver` fetches the new parent-scoped route and renders existing shared transcript components; it never mounts the ordinary create/attach FreshAgentView path or offers create/resume/send/stop/configure. No native child status, including completed, authorizes a writer claim. Deliberate user opening/closing the observer follows existing accessible focus return; background provider events do not change focus or replace the parent. -For local reads resolve the parent's current registered client, verify child membership in retained/native parent activity, and call exact child `thread/read(includeTurns:true)` through that same client using the Task 1 demonstrated mechanism. If Task 1 selected exact provider-owned saved history, use that source with honest saved/pending status and final convergence. Never consult an unrelated HOME or start a child sidecar. Capture/recheck parent source and history-generation fence across the read. +For local reads resolve the parent's current registered client, verify exact child membership and call child `thread/read(includeTurns:true)` through that same client for loaded/saved durable history, as Task 1 proves. A genuinely unmaterialized durable child is pending only within the bounded refresh/progress window; terminal lack of materialization or exhausted reads becomes a diagnosed unavailable state. Native ephemeral history is permanently unavailable with absent snapshot, not a retry spinner or misleading Open conversation action. Preserve identity/activity and explain the provider capability. Do not alter ephemerality, consult unrelated HOME, parse a new store or start/resume a child. Capture/recheck parent source/history fence; temporary failure retains any prior observation with truthful provenance. Managed reads add the minimal parent-root typed request described above, run in its provider volume, and return the child under an explicit observer response. The existing root snapshot route is not an arbitrary-child reader and must retain its root identity checks. The observer response stamps child capabilities nonmutating. Route tests use deliberately different gateway/provider child histories, verify the right result, reject an unrelated child and a replaced owner during a paused read, and count zero child-start/resume RPCs. Component/browser tests restore the observer after reload while parent identity/transcript/actions remain intact. - [ ] **Step 4: Confirm green** -Run both Step 2 commands. Expected: PASS; all new variants render, declined is never success, parent-scoped child observation returns exact content without a child writer. +Run both Step 2 commands. Expected: PASS; variants render, declined/failed never become success, durable child observation returns exact available native history, and pending/unavailable cases are finite and honest without a child writer. - [ ] **Step 5: Refactor while green** @@ -379,40 +393,19 @@ git commit -m "feat(codex): preserve native tool and child activity" **Files:** - Create: `crates/freshell-freshagent/src/codex/turn_state.rs`. -- Modify: `crates/freshell-freshagent/src/codex.rs:3348,3500,4039,10442`; `crates/freshell-codex/src/app_server.rs` and its `lib.rs` exports only if the Task 1 evidence selects explicit native turn/steer; `src/components/fresh-agent/FreshAgentView.tsx` send-refusal branch. +- Modify: `crates/freshell-freshagent/src/codex.rs:3348,3500,4039,10442`; `crates/freshell-codex/src/app_server.rs`; `crates/freshell-codex/src/lib.rs` for explicit native expected-ID turn/steer; `src/components/fresh-agent/FreshAgentView.tsx` send-refusal/ambiguous-delivery branch. - Test: embedded Codex ChannelTransport tests; new turn-state tests; FreshAgentView queued-send/refusal tests. **Interfaces:** - Consumes: existing turn lock, event-emission gate, active/native turn IDs, dispatch request ID, user-interrupt marker, QuietDeadman, ownership fences. - Produces: `CodexTurnState` with `begin_dispatch(request_id:&str)->DispatchToken`, `accept(token,turn_id:&str)->StopDisposition`, `reject(token)->bool`, `mark_ambiguous(token:DispatchToken)`, `request_stop()->StopDisposition`, `interrupt_ack(token:DispatchToken,turn_id:&str)`, `settle_native(turn_id:&str,evidence:NativeSettlement)->bool`, and `has_owed_work()->bool`. `NativeSettlement` is completed/interrupted/failed native terminal evidence or confirmed owned runtime exit, never interrupt acknowledgement/quiet-deadman attention. `DispatchToken` is monotonically session-local and equality-owned; `StopDisposition` is `None | Latched | Interrupt { dispatch:DispatchToken, turn_id:String }`. Keep compatibility latches mirrored from this one transition source until consumers can use it directly. -- Produces: identity-owned turn facts usable by Task 9 typed outcomes without inspecting the broadcast bus. Ordinary browser busy sends retain the existing client queue policy; server/API follow-up sends use the actual native start/expected-turn steering mechanism selected by Task 1 F2 proof. Do not replace supported native steering with a new server queue or paid retry. +- Produces: identity-owned turn facts usable by Task 9 without the broadcast bus, plus a per-session issuance gate using existing dispatch/ownership serialization: a later native start waits until an earlier Stop operation is definitively resolved. Terminal work settlement and release of this issuance gate are separate facts; timeout does not retract an ambiguous native interrupt. Ordinary browser busy sends retain the client queue; deliberate active server/API input uses `turn/steer {expectedTurnId}`, idle input uses `turn/start`. Steer has no setting overrides, so staged settings apply on the next turn. No new provider-wide queue or paid retry. - [ ] **Step 1: Add failing race tests** -The pure ownership test defines the intended transition behavior: - -```rust -#[test] -fn rejected_steer_and_old_stop_cannot_retire_a_new_turn() { - let mut turns = CodexTurnState::default(); - let first = turns.begin_dispatch("first"); - turns.accept(first, "old"); - let steer = turns.begin_dispatch("steer"); - turns.reject(steer); - assert!(turns.has_owed_work()); - assert!(matches!(turns.request_stop(), StopDisposition::Interrupt { turn_id, .. } if turn_id == "old")); - let next = turns.begin_dispatch("next"); - turns.accept(next, "new"); - turns.interrupt_ack(first, "old"); - assert!(turns.has_owed_work()); - assert!(!turns.settle_native("old", NativeSettlement::Interrupted)); - assert!(turns.has_owed_work()); - assert!(turns.settle_native("new", NativeSettlement::Completed)); - assert!(!turns.has_owed_work()); -} -``` +Replace the invalid draft schedule that accepted a new start while old Stop remained unresolved with behavioral tests through actual turn-state and ChannelTransport dispatch. Accept old work, reject its expected-ID steer and preserve the owed turn; request Stop, hold its RPC, then submit later idle input and assert no native `turn/start` is issued. Deliver natural completion/terminal evidence while Stop is held: the consumer must progress and settle only old work, but later start still waits for definitive Stop resolution. Release Stop, admit the new start, then deliver old duplicate ACK/terminal frames and verify the new turn remains busy/targetable. Separately inject adversarial stale replies after a supported resolved rollover; do not call this native normal ACK ordering. -Also test Stop before native acceptance latches and dispatches once when the turn ID arrives; same-ID accepted steer is the same owed native turn; rejected initial dispatch leaves idle; rejected steer preserves the earlier owed turn; exit-induced dispatch error leaves death classification to the exit owner. ChannelTransport tests hold the old interrupt response, accept a new turn, release old response, and assert new busy/interrupt target survives. Hold successful interrupt acknowledgement without any terminal event and assert busy/REST waiter remain outstanding; then deliver matching terminal evidence and settle once. Same-ID steering creates two accepted dispatch facts but one native owed turn and one terminal settlement. Preserve accepted-before-immediate-completion and lifecycle-loss dedup tests. Inject ownership refusal during recovery and assert **both** pane-scoped SESSION_RESERVED and request-correlated send error are emitted; browser restores that refused prompt without overwriting a later typed draft. +Test latched Stop before start acceptance, one interrupt of the returned ID, events preceding acceptance, same-ID accepted steer with two dispatch facts but one terminal settlement, explicit no-active/mismatch refusal, rejected initial dispatch, and ambiguous start/steer/interrupt without replay. A held successful ACK without terminal evidence cannot settle REST/busy; matching terminal evidence does. Startup empty-ID Stop has no promised terminal notification: require actual startup result or confirmed owned exit, not fabricated completion or WebSocket-loss death. An ambiguous Stop timeout keeps the new-start gate closed until definitive native/owned recovery evidence. Exercise that event consumption never awaits a dispatch-held lock needed to release the gate. Inject ownership refusal during recovery and assert both pane-scoped SESSION_RESERVED and request-correlated send error; restore refused/ambiguous prompt and delivery receipt without overwriting newer typed text. Preserve accepted-before-immediate-completion and lifecycle-loss dedup tests. - [ ] **Step 2: Confirm red** @@ -424,9 +417,9 @@ Expected: old interruption clears new turn; rejected steer clears previous latch - [ ] **Step 3: Implement identity-owned transitions** -Replace unconditional shared-latch retirement with matching dispatch/native-turn transitions. An interrupt captures its owned native turn/token before awaiting; response success records Stop acknowledgement/stopping only. It never retires owed work, settles a REST waiter or claims a terminal turn. Actual matching native terminal/confirmed-exit evidence retires that identity, while an old acknowledgement cannot alter a newer turn. Failed interrupt disarms only its own user-stop marker. Stop during an unresolved initial start sets a latch for that dispatch, then sends native interrupt once accepted; `turn/started` cannot erase a stop already latched for that dispatch. A newer accepted native turn cannot inherit an older marker. Retain compact's separate guarded native turn identity. +Replace unconditional shared-latch retirement with matching dispatch/native-turn transitions. Capture interrupt ownership before awaiting; successful response records ACK only, never outcome/REST settlement. Matching native terminal/confirmed owned exit retires that work; an old response cannot alter newer work. Native checks the requested interrupt ID then submits an unqualified core interrupt, so serialize the adapter's Stop/new-start issuance per session until the old Stop is definitively resolved. Natural completion may win. An ambiguous failed/timed-out Stop keeps its unresolved operation/gate; only proven pre-submission refusal can disarm its marker immediately. Do not hold a state/emission/turn lock across RPC if the notification consumer needs it to progress. Latch Stop on pending initial dispatch, interrupt once its ID arrives, and keep startup empty-ID ACK distinct from actual startup outcome/owned exit. Retain compact's guarded identity and existing owned-process recovery; no production restart. -Task 1 F2 is a hard prerequisite. For the demonstrated native follow-up mechanism, keep acceptance distinct from settlement: acceptance may return the existing native turn; do not claim a new turn or duplicate completion. On explicit rejection remove only that request's pending dispatch; on transport timeout/uncertain acceptance use mark_ambiguous and retain the unresolved ownership fact until actual evidence resolves it, and derive busy/deadman state from all still-owed work. Do not automatically resend an ambiguous timeout/transport result: preserve existing recoverability/typed error semantics and native identity. If early native proof selected explicit `turn/steer`, add only its native client helper with required `expectedTurnId` and preserve its no-new-turn/no-overrides semantics; the browser keeps its current queued-send policy and needs no separate steering UI. +Task 1 F2 is a hard prerequisite. Use idle `turn/start` and explicit active `turn/steer` with required `expectedTurnId`; add only that native client helper. Same-ID steer adds no native turn/completion and cannot apply model/cwd/approval/sandbox/work-mode overrides. Preserve those settings as next-turn defaults through Task 8, with truthful staged status. Classify only demonstrated pre-admission/NotSubmitted errors as refusal; arbitrary internal error/timeout/lost response can follow accepted input. Use mark_ambiguous, retain unresolved ownership and original prompt/delivery receipt, derive owed work honestly, and never replay automatically. Browser queued input stays queued without a new steering UI. At main's SESSION_RESERVED recovery refusal, call existing `send_error(&request_id,...)` before pane error, retaining request and holder diagnostics. Expand the existing prompt-restoration branch to Codex request-correlated refusals and restore only when it will not destroy newer user input; keep the refused text recoverable through the existing notice/composer mechanism. No blanket retry. @@ -454,18 +447,20 @@ git add crates/freshell-codex/src/app_server.rs crates/freshell-codex/src/lib.rs git commit -m "fix(codex): preserve turn ownership across send and stop races" ``` -### Task 5: Make native approvals, questions and configured MCP prompts usable, and pause only for blocking input +### Task 5: Answer awaited native controls and true async questions through their distinct paths **Files:** - Modify: `crates/freshell-freshagent/src/codex/controls.rs`; `codex/controls/tests.rs`; `codex.rs` deadman arming/firing; shared question/pending-control schemas; existing client WS/store/banner/View files from the responsibility map; `crates/freshell-ws/src/terminal.rs`; `crates/freshell-ws/src/hosted_fresh_agent.rs`; `crates/freshell-server/src/fresh_agent_proxy.rs`; `crates/freshell-session-host/src/providers/fresh_agent.rs`. - Create: `crates/freshell-freshagent/src/codex/elicitation.rs`; `src/components/fresh-agent/FreshAgentElicitationBanner.tsx`; `test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx`. +- Modify: `src/components/fresh-agent/FreshAgentItemCard.tsx`; `src/components/fresh-agent/FreshAgentComposer.tsx`; `src/components/fresh-agent/FreshAgentTranscript.tsx`; `crates/freshell-freshagent/src/codex/projection.rs` and corresponding existing item/composer/transcript tests only for async question context/actions and accepted ordinary-answer state; reuse Task 3 native item metadata. - Test: controls/elicitation/deadman tests; question/approval/elicitation component tests; shared response-contract tests. **Interfaces:** - Consumes: Task 1 actual native Plan/approval/question/MCP setup and lifetime proof; existing `normalize_request`, pending request registry/native JSON-RPC RequestId, approval/question respond messages, Task 4 owed-work state. -- Produces: per-question `isOther?:boolean`, `isSecret?:boolean`; pending-question `isBlocking?:boolean` (Codex always stamps installed value; absent legacy requests preserve existing blocking behavior). Approval public metadata carries kind, network host/protocol, offered decisions, and requested permission details without dropping the original method/ID. +- Produces: per-question `isOther?:boolean`, `isSecret?:boolean`; pending-question `isBlocking?:boolean` preserves native metadata, not the awaited lifetime. Every real `item/tool/requestUserInput` request requires its exact response regardless of that flag; other providers retain existing semantics. Approval metadata carries kind, network host/protocol, offered decisions and requested permission details without dropping original method/ID. - Produces: existing string-valued question answers preserved verbatim, wrapped as one native answer per native single-choice/free-entry question (no comma parsing or new cross-provider answer union), plus a separate narrow elicitation response `{requestId,action:'accept'|'decline'|'cancel',content:Record|null}`. Add `FreshAgentElicitationRespond` / `freshAgent.elicitation.respond` through `ClientMessage`, the WS schema/export unions, `terminal.rs` dispatch, `HostedFreshAgentCommand::Elicitation`, gateway proxy and session-host provider dispatch. Hosted forwarding reuses the existing durable `resolve_permission(decision_id,Value)` operation with a tagged elicitation action/content payload; the Codex provider routes it to `handle_elicitation_respond`, while other providers return explicit unsupported. No new supervisor control opcode is needed; do not send it as model text. The request event `freshAgent.elicitation.request` must also join the existing `PermissionRequested`/actor `record_permission` durable pending-decision path; adding a response variant alone is insufficient. Extend the existing name-based conversion in `providers/fresh_agent.rs`, retain the public native request before broadcast, and expose pending controls through authoritative retained state for Task 6 loss reconciliation. -- Produces: session-owned atomic `blocking_control_count` synchronized by pending mutations, used in both deadman arm and fire validity. All approvals and ordinary supported MCP elicitations are blocking; Codex question uses native `isBlocking`. +- Produces: session-owned atomic `awaited_control_count`, synchronized from actual unresolved reply-bearing native approvals/questions/elicitations and used for deadman arm/fire validity. Native question `isBlocking:false` still counts when native awaits the response. True async AgentMessage question content has no pending server RPC and never increments this count. +- Produces: Task 3 preserved async delivery/question metadata rendered as transcript questions/choices. A contextual answer enters the existing composer/send path: preserve native item/question identity as context and exact chosen/free-entry text, use normal idle start or existing active queue/expected-ID steer, and retain refusal/ambiguity/draft state. An answer-accepted indication means only ordinary input acceptance, never native control resolution or turn completion. No fabricated RequestId, `UserInputAnswer` RPC, durable permission record or automatic resend on reload. - [ ] **Step 1: Write failing request→UI→response and deadman tests** @@ -497,9 +492,11 @@ Approval cases: command absent with network host/protocol; `writeStdin`; command Hosted tests must observe an actual durable pending decision before clicking the elicitation answer, then reload/reconnect and prove the same request remains answerable. Retained native pending state is the recovery source if upstream broadcast loses the request before conversion; Task 6 must drain that state without waiting for another frame. -MCP cases: flat form with required string, integer/number, boolean and enum/string-array enum; `openai/form` and `openaiForm` aliases for the same representable flat shape; URL confirmation preserving url/elicitationId and action; decline and cancel; invalid required/type/range/enum answer; missing/wrong optional turn identity; secret-like/writeOnly field mask where schema advertises it; unsupported nested schema and `openai/userVerification` produce one explanatory native error/decline and no pending banner. URL opening alone never reports acceptance. +MCP cases: flat required string, integer/number, boolean and enum/string-array enum; separate actual `openai/form`→downstream MCP `openai/form` and `openaiForm`→downstream `openai/elicitation/create` form routes with their capability gates and IDs, sharing only representable flat rendering. Cover URL url/elicitationId with explicit accept/decline/cancel, invalid required/type/range/enum answers, missing/wrong optional turn, secret/writeOnly mask, and unsupported nested/`openai/userVerification` explanatory native refusal/release. URL opening alone never accepts. Preserve JSON-RPC string/int IDs, item/approval/public elicitation IDs and original MCP server/request IDs independently. + +Use the short quiet-window seam: unanswered approvals, MCP and both true/false-flag synchronous questions survive multiple windows without false stuck/completion because actual native reply-bearing operations remain pending. Answering one of two awaited controls stays paused; draining the last rearms a fresh full quiet window if work remains owed. True async AgentMessage questions have no RPC waiter: demonstrate subsequent native work before answering, ordinary quiet-stall detection if that work later goes silent, and no fake waiting/completion caused by visibility. Test distinct async questions, contextual chosen/free-entry reply via ordinary acceptance/refusal/ambiguity, active queue/steer and deliberate historical follow-up; no auto-send on reconnect. Close/death/Stop cancels real pending controls once, never a fictitious async response. Recheck actual pending membership at timer firing and preserve provider activity. -Use the existing short quiet-window seam: unanswered approval, blocking question and MCP prompt survive multiple windows without stuck/completion; nonblocking question permits ordinary deadman detection while the turn is genuinely silent; answering one of two blocking controls keeps it paused; draining the final blocking control rearms a fresh full quiet window; provider activity clears stuck; close/death cancels waiters. Recheck membership at deadline firing to cover an arrival racing an already armed timer. +Render/reload/hidden-pane tests keep async questions in saved/retained transcript, synchronous controls in retained pending state, exact response IDs and native choice/secret behavior. Server-authoritative attention distinguishes awaited waiting from nonblocking actionable async content, deduplicates existing timestamps, seeds reload silently and never steals focus. An ordinary accepted async answer clears only its local action indication, independently of native work/terminal status. - [ ] **Step 2: Confirm red** @@ -507,7 +504,7 @@ Run: `python3 /home/dan/code/freshell/.worktrees/.the-usual-logs/freshcodex-comp Run: `pnpm run test:vitest run test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/ test/unit/client/lib/fresh-agent-ws.test.ts --config config/vitest/vitest.config.ts`. -Expected: native flags lost, wrong visible choices/plain text secret input, MCP rejected, and legitimate blocking input trips deadman. +Expected: native flags/choices lost, secret input unmasked, MCP rejected, awaited false-flag request trips deadman, or true async reply is incorrectly routed as a control/pauses work. - [ ] **Step 3: Extend the existing control path and bounded elicitation UI** @@ -515,22 +512,17 @@ Normalize native fields into the existing public control records and live events `FreshAgentQuestionBanner` passes flags through its current question props. Show Other only when native allows it; use free entry for questions with no options; mask secrets. Keep native single-choice/free-entry values verbatim. Respond native `{answers:{[questionId]:{answers:[...]}}}` without splitting human answer text. On response, check request still pending and belongs to the same turn/incarnation immediately before send; cancelled old request cannot consume a later question's answer. +Keep `request_user_input_async` AgentMessage delivery/questions in the transcript item path. Render its question/options through shared bounded question controls, but populate/send ordinary contextual user input rather than registering a native pending control. Distinguish multiple questions by item/question context; preserve exact answer text, and use the existing send receipt to show accepted/refused/ambiguous delivery. Active input follows Task 4 queue/expected-ID steer and settings remain next-turn defaults. Historical questions can be answered deliberately, without restoring or resending old secret drafts. Async visibility neither pauses deadman nor proves completion/waiting. Do not force experimental model flags in production; the native fixture proves advertisement through owned supported metadata. + Approval cards show a bounded plain-language description for command, stdin, network, file change and additional filesystem entries, then only supported offered choices. Continue existing simple Allow/Deny where valid, add Stop/cancel distinctly when offered, and validate requested decision against native `availableDecisions`. `decline` continues, `cancel` interrupts; do not translate both into the same decision. Permissions responses grant explicit requested subset or none with honest turn/session scope; no persistent policy editor is required. After the early native gate has established actual elicitation setup/lifetimes, `elicitation.rs` accepts only representable flat object properties using primitive/enum rules, required membership, numeric/string bounds, and supported format hints. Normalize it into the new small banner, preserving title/message, field ID/type/default/allowed choices and secret hint. Use native `{action,content}`. For URL, show server name/message/link and explicit Continue/Decline/Cancel; open through the existing browser/link affordance and wait for user confirmation. A verified unrepresentable subtype receives the explicit unsupported result and a notice naming the subtype; do not leave the provider blocked or fabricate a successful form. Ensure turnless elicitation remains scoped to the owning native thread and is cleared on thread death/close. -Publish `blocking_control_count` before arming/draining transitions. The required deadman condition is: - -```rust -let should_watch = turn_state.has_owed_work() - && blocking_control_count.load(Ordering::SeqCst) == 0; -``` - -Apply it both when feeding/arming and when the timer validates its generation at firing. Draining the final blocker feeds current activity and rearms a full window; nonblocking questions do not fake idle/completion. Keep waiting attention and turn completion distinct. +Publish `awaited_control_count` from actual reply-bearing pending insert/remove/drain transitions before deadman changes. Watch only when work remains owed and that count is zero, rechecking the same facts and timer generation at firing. Both synchronous question flags count; a true async transcript item does not. Draining the final awaited request feeds activity/rearms a full window. Preserve independent progress, server-authoritative awaited waiting/nonblocking question attention, and turn completion. The earlier flag-based draft condition is superseded by this behavioral requirement; implementation/tests must use Task 1's executed lifetimes. - [ ] **Step 4: Confirm green** -Run Step 2 commands. Expected: PASS; exact native responses and retained metadata through snapshot/live path, no false deadman for blocking controls. +Run Step 2 commands. Expected: PASS; exact native control responses/retained metadata, no false deadman for awaited false-flag questions, and genuine async contextual answers through ordinary input without pausing ongoing work. - [ ] **Step 5: Refactor while green** @@ -548,6 +540,7 @@ Expected: PASS. The new typed response goes through ordinary and managed WS ingr ```bash git add crates/freshell-freshagent/src/codex.rs crates/freshell-freshagent/src/codex/controls.rs crates/freshell-freshagent/src/codex/controls/tests.rs crates/freshell-freshagent/src/codex/elicitation.rs crates/freshell-protocol/src/client_messages.rs crates/freshell-ws/src/terminal.rs crates/freshell-ws/src/hosted_fresh_agent.rs crates/freshell-server/src/fresh_agent_proxy.rs crates/freshell-session-host/src/providers/fresh_agent.rs shared/fresh-agent-contract.ts shared/ws-protocol.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/lib/fresh-agent-ws.ts src/components/fresh-agent/FreshAgentQuestionBanner.tsx src/components/fresh-agent/FreshAgentApprovalBanner.tsx src/components/fresh-agent/FreshAgentApprovalCard.tsx src/components/fresh-agent/FreshAgentElicitationBanner.tsx src/components/fresh-agent/FreshAgentView.tsx test/unit/shared/fresh-agent-contract.test.ts test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentApprovalBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx test/unit/client/lib/fresh-agent-ws.test.ts +git add crates/freshell-freshagent/src/codex/projection.rs src/components/fresh-agent/FreshAgentItemCard.tsx src/components/fresh-agent/FreshAgentComposer.tsx src/components/fresh-agent/FreshAgentTranscript.tsx test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx git commit -m "feat(codex): honor native controls and MCP elicitation" ``` @@ -561,12 +554,12 @@ git commit -m "feat(codex): honor native controls and MCP elicitation" **Interfaces:** - Consumes: existing `AttentionLedger`, Codex monotonic completion/waiting clocks, current negotiated `freshAgentAttentionReplayV1`. - Produces: `FreshCodexState::attention_marks() -> Vec` and retain-before-broadcast helpers; combined existing ready payload. The provider state, not what happened to reach the gateway, is authoritative. -- Produces: `FreshCodexState::retained_state(session_id:&str) -> Result` with existing source identity, event-owned projection/memberships, attention marks and pending public controls; `subscribe_retained_state(session_id:&str) -> watch::Receiver` is a coalesced wakeup for changes, not a new history clock/journal. Native facts update retained state before best-effort broadcast. Expose this through the existing provider transport/snapshot route; an optional default-unsupported trait method for non-Codex providers preserves their behavior. +- Produces: `FreshCodexState::retained_state(session_id:&str) -> Result` with existing source identity, projection/memberships/finite `turnOutputStates`, attention marks and actual pending public controls; true async questions remain transcript content plus actionable nonblocking metadata, never durable permission requests. `subscribe_retained_state(session_id:&str) -> watch::Receiver` coalesces wakeups, not a new history clock/journal. Native facts update retained state before best-effort broadcast. Expose through existing provider transport/snapshot; default-unsupported trait behavior preserves other providers. - Produces: one owned bridge recovery worker that reacts to the coalesced wakeup/loss, awaits downstream channel capacity without holding provider locks, and drains the latest retained source. It sends current marks/projection resync in a properly routed `FreshAgentEvent` envelope using the existing `freshAgent.host.backpressure` recovery event with retained/source data, plus every retained pending control through its durable `PermissionRequested` conversion. The retained snapshot includes last exact native history plus event-owned active state and does not wait for another native RPC merely to recover a dropped frame. Gateway/client apply this fenced retained state immediately and request native history refresh separately through the bounded scheduler. It runs even when the dropped frame was the last frame; no later provider token is required. Gateway ready/reset reconciliation reads this retained source under the existing host/incarnation fence, rather than treating its lossy journal as complete history. - [ ] **Step 1: Write red replay tests** -Mirror the real WS harness in the existing Claude replay target and drive Codex native completion/control facts through its actual state/consumer. After disconnect: complete/fail turn, reconnect negotiated client, compare exact retained timestamp and session type; request blocking question/approval, reconnect, see waiting once; answer last pending control, reconnect, waiting absent but completion retained. Unrequested death retains completion and clears dead controls, explicit close forgets all aliases, materialization maps both old and canonical identity, unnegotiated ready contains no replay. Also prove first load/reload silent seeding, same-page reconnect alerts once, no double ring after late duplicate completion. Saturate both the upstream broadcast and downstream provider-event channels, drop the final completion/waiting/control frame, then produce **no more provider frames**. Drain capacity and assert visible transcript/retained attention recover and the exact pending question/elicitation becomes a durable answerable decision. Repeat gateway journal reset and source replacement while recovery is paused; old-incarnation retained data is rejected. No attention/focus event from the other pane may leak. +Mirror the real WS harness and drive native facts through the consumer. After disconnect: complete/fail, reconnect, compare retained timestamp/session type; awaited question with either flag or approval recovers waiting once; last awaited response clears waiting independently of completion. True async question recovers transcript/action attention while ongoing work remains active, without a pending decision or false waiting/completion. Normal accepted contextual answer clears its action indication only; refusal/ambiguity preserves it. Unrequested death retains completion/clears dead controls, close forgets aliases, materialization aliases, unnegotiated ready has no replay. Prove silent reload seed and reconnect/duplicate dedup. Saturate upstream/downstream channels, drop the final completion/waiting/control/async-content frame and emit **no more provider frames**. Drain capacity and recover exact finite transcript/attention; only real pending question/elicitation becomes a durable answerable decision. Repeat journal reset/source replacement; reject old incarnation, preserve available interrupted/unsaved/truncated retained evidence without reopening appends. No cross-pane focus/attention leak. The ledger acceptance used by provider tests is: @@ -590,7 +583,7 @@ Expected: real Codex marks are absent from ready replay and the current bridge l - [ ] **Step 3: Integrate the existing ledger** -Add clone-shared `Arc` to Codex state; observe sessions at admission. Completion and synthesized death/deadman edges record timestamp **before** broadcasting. Waiting transitions retain before broadcast; final pending drain clears waiting; explicit close forgets; materialization aliases; unrequested death keeps completion. Reuse existing clocks/dedup gates, never mint a second clock solely for replay. Combine `fresh_claude.attention_marks()` and `fresh_codex.attention_marks()` at the current capability-gated ready construction. For managed providers the existing arbitrary payload/extension forwarding already works; retain it. Repair the source-proven loss before journaling: `spawn_broadcast_bridge` currently ignores lagged broadcast records and `try_send` drops generic provider events, while its backpressure notice waits for a later frame. Retain Codex facts before that boundary and use the reliable coalesced worker above to publish latest state once capacity is available, even after a dropped last edge. Keep ordinary token patches best effort; on a gap/loss send one resync and read retained state rather than replaying an unbounded token journal. Pending elicitation/approval/question requests use the same existing durable actor conversion as Task 5, including requests missed by broadcast. +Add clone-shared `Arc` and observe sessions at admission. Completion and synthesized death/deadman edges record timestamp **before** broadcast under existing semantics. Waiting comes from actual awaited controls, including synchronous false-flag questions; final awaited drain clears it. Async transcript question visibility uses existing nonblocking actionable attention/timestamp dedup, without claiming native waiting, idle or completion; accepted ordinary answer clears only that action. Close forgets, materialization aliases, unrequested death retains completion. Reuse clocks/dedup, combine Claude/Codex marks in negotiated ready and preserve existing managed extension forwarding. Repair `spawn_broadcast_bridge` lag/drop before journal through retain-before-broadcast and the coalesced capacity-awaiting worker, even for the final lost frame. Patches stay best effort with one fenced resync on loss; no unbounded journal. Only real pending elicitation/approval/question requests enter durable actor conversion, including missed requests; async content stays in projection/history recovery. At the gateway, retain authoritative host marks for ready replay and refetch the provider-retained source on journal reset/backpressure; recheck current source tuple before applying it. Client `fresh-agent-ws`/View marks a patch gap, host backpressure or host journal reset dirty and schedules one coalesced snapshot; visible rows stay until authoritative replacement. Do not wait for a later provider frame to trigger recovery. A gateway-only ledger cannot reconstruct an edge lost before the journal, so it is not the sole source. @@ -697,7 +690,7 @@ git commit -m "feat(codex): use native model and reasoning capabilities" - Test: settings helper/native request tests; existing settings/create/resume/fork tests; `FreshAgentSettingsButton.test.tsx`; persistence/view tests. **Interfaces:** -- Consumes: Task 7 selected native model/effort, existing `FreshAgentSendSettings`, sandbox/permission mappings, per-session turn lock, durable fenced settings persistence. +- Consumes: Task 7 selected native model/effort, Task 1 owned prepared-config/typed-approval compatibility and refusal contract, Task 4 idle-start/active-steer/Stop issuance rules, existing `FreshAgentSendSettings`, sandbox/permission mappings and durable fenced settings persistence. - Produces: `CodexEffectiveSettings { model:String, effort:Option, cwd:Option, sandbox:Option, permission_mode:Option, collaboration_mode:Option }`, with `CodexWorkMode::{Default,Plan}`. A pure `resolve(base,patch)` validates and merges; apply under existing serialization boundary; emit/persist complete effective settings. Wire create/send/configure/pane/default types add optional `collaborationMode:'default'|'plan'`; shared scopes add cwd/collaborationMode optional keys. - Produces: `StartTurnParams.collaboration_mode:Option` serialized as native `collaborationMode` with selected model, reasoning_effort and developer_instructions:null. All existing Rust struct-literal callers add `None` or use `..Default::default()` deliberately; update native client drive tests accordingly. @@ -725,6 +718,8 @@ fn plan_mode_uses_selected_model_and_effort_without_changing_sandbox() { Drive configure cwd/sandbox/permission only, then send and snapshot: each value reaches native and returns unchanged. Hold an in-flight send response while a newer configure arrives; once both settle, the newer configure remains the default for the next send and reload. Make durable settings write fail: useful typed/logged refusal and no false durable success. Test configured model/effort and work mode survive create/resume/fork and native effective resume values override stale requested assumptions. Backward compatible panes lacking new fields stay Build/default. +Active expected-ID steer carries no setting overrides: change model/effort/cwd/approval/sandbox/work mode while active, accept contextual input/steer, verify current turn unchanged and exact staged defaults applied by the next idle start/reload. Exercise Task 1 untrusted prepared layers through create/resume/fork/send/child with exact native effective policy, original sources/unrelated setting effects preserved, and requirement-constrained typed refusal before work rather than silent policy substitution. Legacy public declaration left in place must still fail; typed override alone is not a startup fix. Restricted file-access commands retain Task 1 actual permitted/denied-effect proof; Plan is tested independently. + Component tests use real store/settings callback: select File access Read only/Workspace write/Full access and Work mode Plan/Build, observe pane patch and configure payload. Read-only and approval Never ask remain distinct; create-only/unsupported scope shows honest hint/disabled action. Plan→native question→answer→Build is later proven in browser, not just by static label assertions. - [ ] **Step 2: Confirm red** @@ -737,7 +732,7 @@ Expected: configure ignores cwd; snapshot omits sandbox/cwd; send overwrites new - [ ] **Step 3: Implement one effective-settings path** -Use the existing turn lock for configure as for send/rollback, so a later configure applies after the older send completes rather than being overwritten by its response. Resolve all fields once; validate directory using the same existing cwd rules as create/send; honor native per-turn semantics. Persist only applied/accepted fields with captured ownership fence and handle errors explicitly. Configuration changes stage next-message defaults; they must not falsely claim the current native turn was reconfigured. Broadcast metadata for any changed field, including sandbox/permission/cwd/work mode, and snapshot all of them. Carry work mode through `FreshAgentTurnSettings`, host actor durable profiles/recovery, configure/send forwarding and session-host native `StartTurnParams`; extend hosted `snapshot_projection::valid_scopes` to allow cwd/collaborationMode, otherwise it currently drops the whole scope map. Extend client metadata adoption so older absent fields do not clear valid values. +Use existing configure/send/rollback serialization without preventing native consumer progress or violating Task 4 Stop gate. A later configure cannot be overwritten by an older response. Resolve/validate all fields once; persist accepted next-turn defaults with captured owner fence and explicit write errors, distinguishing staged defaults from current native-turn effective settings. Active expected-ID steer has no overrides; apply staged settings only to the next idle `turn/start`, never route through convenience start to pretend reconfiguration. Broadcast/snapshot exact fields and staged scope. Carry work mode through `FreshAgentTurnSettings`, durable host profiles/recovery and native `StartTurnParams`; allow cwd/collaborationMode in hosted `snapshot_projection::valid_scopes`. Older absent metadata cannot clear valid fields. Reuse Task 1 compatibility projection and typed native policy on every create/resume/fork/next-turn path: preserve effective untrusted intent, prepared-layer/source precedence and native requirements, with precise early refusal when faithful preparation is impossible. Never edit user HOME/config or broaden policy for startup. Build native collaborationMode using resolved model and **wire-normalized** effort. Selecting Build explicitly switches native `mode:'default'`, not omission that might preserve Plan. Start/resume effective values, when native echoes them, are authoritative. Plan setting is capability/scoped and separate from approval policy. Extend the existing settings popover with plain-language File access and Work mode rows, persist defaults via current provider settings patch, and stage/commit through the same helper as model/permission. Snapshot capability `settingScopes` truthfully advertises per-send cwd/sandbox/model/effort/approval/work mode supported by installed Codex; no invented live-only reconfiguration. @@ -795,7 +790,7 @@ it('creates a fresh Codex pane with canonical resume identity', async () => { Add the split equivalent with target lookup. The CLI test invokes the real `tools/freshell-cli/index.ts` child against an owned HTTP recording server; `--resume` maps to provider=codex canonical ref and never raw fresh resumeSessionId. Keep terminal mode=codex raw-resume rejection separate. -Rust HTTP tests construct default local server state with real Codex lane and ChannelTransport: headless new/split honors cwd/model/effort/approval/sandbox/defaults, initial prompt acceptance, canonical resume reuses writer/session, send-keys Enter-only no-op, send waits for actual native terminal evidence for its submitted turn (including same-ID steering and an acknowledged Stop held before terminal), capture matches normalized transcript with byte limit/truncated, wait-for sees actual output, rename reaches shared name authority. Failed create leaves no blank pane and only cleans newly owned runtime; adopting a live writer never kills it on layout rollback. Test ambiguous acceptance never auto-replays input and timeout reports incomplete truthfully. +Rust HTTP tests construct default state with real Codex lane/ChannelTransport: headless new/split honors all settings, initial acceptance, canonical resume reuses writer, Enter-only no-op, and send waits for actual matching terminal evidence, including same-ID expected-ID steer and ACK before observed terminal. Later idle start remains unissued while old Stop is unresolved; event/terminal consumption progresses without lock deadlock. Capture returns finite saved/retained transcript with byte-limit truncation separate from native persistence truncation; wait-for observes actual output, rename uses shared authority. Failed create leaves no blank pane/only cleans owned runtime; adoption never grants cleanup ownership. Proven refusal preserves prompt; arbitrary internal error/timeout is ambiguous, never auto-replayed or reported complete. Startup Stop requires actual result/owned exit, not invented terminal. - [ ] **Step 2: Confirm red** @@ -807,7 +802,7 @@ Expected: default local Codex create/split is unavailable and standalone clients - [ ] **Step 3: Adapt the existing runtime rather than duplicate it** -Refactor `handle_create`/`handle_send` to return typed outcomes while retaining current WebSocket broadcasts; callers that need no result may ignore it explicitly. Publish a private typed turn-settlement subscription from the same reducer/edge path, as Claude does, and subscribe **before** dispatch so fast turns cannot be missed. Correlate native turn, session incarnation, ownership identity and request; if turn/start steers into an existing turn, wait for that native turn without inventing another completion. Cancellation/death/close uses confirmed owned termination or actual native terminal evidence; a requested Stop or its RPC acknowledgement alone leaves the waiter pending. Ambiguous dispatch returns a truthful incomplete/uncertain result and retains same-owner history reconciliation, never resends input. +Refactor create/send to typed outcomes while retaining broadcasts; publish the private typed settlement subscription from the authoritative reducer and subscribe **before** dispatch. Correlate session/native turn/incarnation/ownership/request, use idle start or active expected-ID steer, and wait for the same owed native turn without an extra completion. Task 4's unresolved Stop gates later start issuance without blocking events; settings remain next-turn defaults on steer. Cancellation/death/close requires actual terminal evidence or confirmed owned termination, not ACK/WebSocket loss. Only proven pre-admission rejection is Refused; timeout/lost reply/arbitrary internal error stays Ambiguous with recoverable prompt/receipt and no replay. Finite saved-only interrupted or unsaved/truncated transcript state does not invent a terminal outcome. Implement CodexRestLane by translating existing RestLane request into `FreshAgentCreate/Send`, calling the same state and mapping typed failures. Capture uses side-effect-free exact snapshot and the shared existing transcript-to-text/byte-bound helper; do not scrape rendered DOM or spin up a second sidecar. Cleanup requires owned token identity and existing safe kill/confirm; adopted runtime has no cleanup token. Preserve observed epoch/generation fences. @@ -873,13 +868,13 @@ The Task 1 staged native harness and actual 0.164 receipts are prerequisites for Required journeys and exact evidence: -1. **Streaming/transcript:** text/reasoning/tool output visible before completion; plan replacement and aggregate diff visible; usage nonzero with unknown cost; completion snapshots converge once; final error and declined rows remain truthful after reload. Hide one of two distinct Codex panes while it streams/awaits input, reveal, assert no transcript/control/model leak and no focus theft. -2. **Native controls/planning:** choose Plan and catalog-only model/xhigh; provider log has native collaborationMode with that pair. Receive blocking constrained question, reload, no Other, exact question IDs; secret question masks input and provider response retains punctuation; nonblocking question does not freeze running work. Network-without-command and stdin approvals show correct action; Deny produces declined row; Stop cancellation sends cancel without duplicate response. Flat MCP form and URL prompt succeed/cancel; unsupported complex/device-proof subtype yields visible bounded explanation and releases native request. +1. **Streaming/transcript:** text/reasoning/tool output before completion, plan/diff replacement, measured usage/unknown cost, exact normal final assistant turn once after save/reload. Cover Summary/NotLoaded terminal frames, interrupted warm retained fragments versus cold saved-only recovery, delayed/failed persistence with finite unsaved diagnostic, and command/MCP saved truncation with actual markers/richer retained disclosure and stopped retries. Legacy synthetic IDs, duplicate equal messages and multi-role turns stay ordered/exact. Undo/source change fences old appends and retained membership. Error/declined states remain truthful. Hide one of two distinct panes while streaming/awaiting input, reveal without transcript/control/model leak or focus theft. +2. **Native controls/planning:** choose Plan and catalog-only model/xhigh; native collaborationMode carries that pair. Awaited Plan `isBlocking:true` and Default `isBlocking:false` requests both recover/answer exact IDs without false stalled-work detection. Constrained questions omit Other; secrets stay masked/exact. Separately exercise real model-advertised async AgentMessage questions: native work continues before answer, no pending RPC/durable decision, question/context/choices recover through transcript, ordinary contextual input follows idle start or active queue/expected-ID steer, and accepted/refused/ambiguous indication says only delivery status. Async visibility does not pause deadman or invent completion. Network-without-command/stdin approvals retain distinct actual IDs/action/offered choices and denied effects are absent; Stop cancels actual controls once. Ordinary MCP flat, both distinct OpenAI form routes/capabilities, and explicit URL accept/decline/cancel traverse native resolution; opening URL alone does not accept. Unsupported complex/device-proof requests explain/refuse/release. 3. **Catalog/settings:** open picker before ordinary create acknowledgement and preserve an exact staged choice; after binding, probe only its actual provider context, ignore stale-source replies, and keep custom models omitted by a successful list eligible for native validation; install fixture model not in static menu and native effort not in old enum; configure/send/reload/send preserve exact pair. Change cwd/file access/approval/work mode and verify next native turn, snapshot and resumed defaults. Restore existing Codex leg in live-model-convergence spec; refresh failure preserves selection. Two browsers observing same session converge settings/name without changing native identity. -4. **Attention:** saturate hosted channels and drop the final control/completion frame with no later provider token; recover from retained state without focus theft, then disconnect only the browser WebSocket; complete/fail or raise input through independent native/WS driver; reconnect produces one correct completion/waiting indication. Reload stays silent, pending response drains waiting, hidden-pane reveal retains the card, no cross-pane edge. The spec's Codex cases must actually execute on cloud. +4. **Attention:** saturate hosted channels and drop final control/completion/async-question content with no later provider token; recover retained state without focus theft. Disconnect browser WS, then complete/fail or raise input independently; reconnect yields one correct completion/awaited-waiting or nonblocking async-action indication. Both synchronous flags suppress false deadman; async content does not. Reload seeds silently, final awaited response drains waiting, an ordinary accepted async answer clears only its action, hidden reveal retains cards, no cross-pane edge. Codex cases must execute on cloud. 5. **Automation:** REST creates a headless background Codex pane while active pane/tab/focused element remain unchanged; initial prompt accepted; send/capture/wait see real native output; rename uses shared authority; reload sees the same durable ref; split and resume work. CLI/MCP dispatchers already have HTTP proof; invoke at least one actual standalone CLI and MCP dispatcher against the owned real server in supported integration coverage to prove end-to-end routing rather than recording-only proof. -6. **Preserved lifecycle:** parent-scoped child observer shows exact child transcript, stays read-only and restores its selected child after reload while issuing zero child create/resume/send/stop/configure RPCs; /new, native compact (no turn/start text), fork from a specific row/native boundary, conversation undo with no file change, honest redo refusal, image input reaches native and an uploaded image/text attachment remains meaningfully visible in its user transcript before/after reload, native image output path/result remains inspectable, file-checkpoint/rewind and `!command` cwd, saved-name precedence, same-thread reload/recovery, cross-mode handoff without duplicate writer, Stop in start-response window and rejected steer/late interrupt. Fault/kill/restart variants run only in disposable Docker; never bounce production or run destructive browser specs directly on host. -7. **Managed hosted:** extend `fresh-agent-parity.test.ts` existing rig for Codex same catalog/model/effort/settings/Plan, live transcript+usage, controls response, REST send/capture and reconnect attention. Assert provider operation log inside owned provider volume and gateway-visible state. Include one session-host restart/crash recovery in the existing isolated Docker rig if required by changed projection/replay forwarding; same native ref/settings/name survive, stale host patches cannot overwrite replacement incarnation. Do not replace this with a gateway-local fake state. Add a second actual-native Codex leg using the now-aligned managed image: omit freshcodex from fixtureFreshAgentModes, use the Task 1 loopback Responses/MCP fixture inside the owning provider container/namespace with synthetic config, and run the real Rust/gateway→session-host→actual Codex journey. Extend `codex-local-provider.mjs` with `startInManagedContext(rig,source) -> Promise<{baseUrl,stop,traffic}>` using the existing owned RuntimeSandbox/broker test mechanism, never a live host. The actual-native leg proves native version, streamed/landed text, Plan/control response, faithful settings and parent-scoped child observation through that production adapter; deterministic fake-native parity remains for forced channel/race cases. The Docker-backed local companion must discover and execute that leg, not silently select only the fake transport. +6. **Preserved lifecycle:** ordinary durable parent-scoped child observer shows exact available active/final/saved native history, restores selection after reload, bounds unmaterialized pending and retains available content on temporary source loss. Ephemeral child returns specific unavailable/absent snapshot without changing ephemerality, empty success or spinning; zero observer child create/resume/send/stop/configure RPCs or inferred release. Preserve /new, native compact (no turn/start text), specific-row/native-boundary fork, conversation undo without file effects, honest redo refusal, native image input/output and meaningful image/text attachment history after reload, checkpoint/rewind and `!command` cwd, saved-name precedence, same-thread recovery and handoff without duplicate writer. Stop during start/expected-ID steer/ambiguous delivery and later-start gating respect actual terminal/owned-exit evidence; late old replies cannot retire new work. Fault/kill/restart variants remain inside disposable Docker, never production/host destructive runs. +7. **Managed hosted:** extend existing `fresh-agent-parity.test.ts` rig for native catalog/model/effort/settings/Plan, finite transcript+usage, awaited controls/async contextual answers, REST capture/send and authoritative reconnect attention. Assert actual provider-volume log and gateway-visible state; required isolated session-host crash/restart retains ref/settings/name and rejects stale incarnation without losing available frozen output. Add actual-native leg with freshcodex omitted from fixtureFreshAgentModes, aligned image and Task 1 release-supported loopback Responses/MCP fixture in the owning provider namespace. `codex-local-provider.mjs` exposes `startInManagedContext(rig,source) -> Promise<{baseUrl,stop,traffic}>` through owned RuntimeSandbox/broker. Real gateway→session-host→Codex proves wrapper/native version/UID/context, streamed/saved/retained semantics, native Plan/control/async responses, all faithful settings including prepared untrusted create/resume/fork/send/child/requirements behavior, actual permitted restricted commands and absent denied effects, and durable/ephemeral observation through production routing. Keep deterministic fake transport for forced races; it cannot replace this native leg. Docker local companion must discover/execute it, preserving unchanged isolation and no live-host operations. - [ ] **Step 2: Run affected cloud specs and confirm intended red evidence** @@ -889,7 +884,7 @@ During each earlier implementation slice, select its new affected spec with `GCL Repair only production gaps revealed by the accepted journeys, within the earlier task intents. For schema fixture inventory update, represent 0.164 supported/unsupported classifications honestly and preserve unknown-type tolerance; do not add test assertions that merely require version/comment text. Keep native fake configured via existing behavior JSON, exact ID replacement, durable turn recording, and response/operation logs. Add opt-in prompt scripts and release barriers there, not production test seams. -Update README usage for installed Codex model selection, Work mode Plan/Build, file-access versus approval, native questions/MCP prompts, CLI/MCP create/split, native undo/no-redo. Keep it concise and user-facing. Update the nonfunctional `docs/index.html` default-experience mock for major new settings/control affordances using existing design patterns. Do not create another end-user Markdown guide. +Update README usage for installed model selection, Plan/Build, file access versus approval, awaited versus async questions/MCP prompts, truthful saved/retained/interrupted output and unavailable ephemeral child history, CLI/MCP create/split and undo/no-redo. Describe precise incompatible-config diagnostics without exposing implementation internals. Keep it concise/user-facing; update `docs/index.html` major affordances with existing patterns. No additional end-user Markdown guide. - [ ] **Step 4: Run exact focused browser/hosted verification** @@ -933,12 +928,12 @@ Commit any later review fixes separately. The file lists above are the exact int ## Mandatory self-review of this implementation plan 1. **Current intent:** The complete dispatcher-authored User Request is unchanged exactly once. No obligation or accepted residual was rewritten. The ten tasks retain the complete original user-visible coverage. -2. **Early proof and version:** Task 1 is an independently testable actual-native/managed-runtime upgrade, with staged cloud/offline execution and hard native F1/F2/F3/F6/F7 gates before dependent implementation. Managed Codex 0.147→0.164 alignment follows that early proof, preserves other provider pins, and deploys nothing. Artifact metadata is not execution evidence; only actually executed platforms are claimed. -3. **Source authority:** Event revisions cover consumed application events only. Active bodies remain event-owned, independent partial reads are nonappendable previews, complete native/event bodies replace atomically, and generation/retained-history membership prevents late reads/deltas from recreating removed/final history. No eight-entry-cache finality dependency, shared native watermark or new generic clock/journal is assumed. -4. **Ownership and receipts:** Acceptance, same-ID steering, Stop acknowledgement, ambiguity and actual terminal settlement are separate. Child observation is explicitly parent-scoped and read-only locally and managed; no create/resume/release predicate is inferred from completed activity. Existing identity/handoff/fork/undo/names/cleanup fences remain authoritative. -5. **Host and catalogs:** Provider-retained projection/attention/pending state precedes lossy broadcast and a reliable coalesced worker recovers the last dropped frame without requiring another token. Elicitation joins durable pending-decision conversion. Existing payload extensions already forward; cwd/work-mode valid_scopes is a known Task 8 update. Pre-create catalog is pending, ordinary create binds context, only that current provider is probed, and source changes invalidate stale cache/replies. List omission never rejects exact custom intent. +2. **Early proof and version:** Task 1 requires separately attributable F1/F2/F3/F4/F6/F7 actual release-native cloud/offline and intended managed-provider receipts before dependent code. Staging fixes connected/offline aliases, pinned-pnpm/image warmup, full verified resources/final-user traversal and writable canonical state; explicit node-builder→final copy/ENV preserves upload exclusions. Only executed byte/path/version/UID/platform receipts count. Managed alignment preserves other pins/isolation; restricted commands and denied effects remain hard gates, with no legacy Landlock/full-access escape or deployment. +3. **Source authority:** Revisions cover consumed application events only. Partial reads never become append baselines. Whole-turn Legacy comparison ignores only demonstrated synthetic IDs and preserves ordered duplicates/roles. Correct saved history and richer actually retained output reach finite interrupted/unsaved/provider-truncated states; Summary/NotLoaded/ACK cannot invent full bodies or durability. Existing bounded retention freezes extras and releases incremental logs, with no universal journal/impossible equality retries. Source/history membership prevents old appends or undo revival. +4. **Ownership and receipts:** Idle start/active expected-ID steer, staged next-turn settings, acceptance, ambiguity, Stop ACK and actual settlement are distinct. Per-session Stop/new-start issuance ordering permits consumer progress; startup Stop requires actual result/owned exit. Child observation is parent-scoped/nonmutating: durable reads work, unmaterialized pending is bounded, ephemeral history is specifically unavailable/absent, and temporary failure preserves available evidence without ephemerality rewrite or inferred writer release. Existing lifecycle fences remain authoritative. +5. **Host, controls and catalogs:** Provider retention precedes lossy broadcast; coalesced recovery needs no later token. Actual awaited controls (including false-flag native questions) determine waiting/deadman and durable decisions; true async transcript questions use ordinary contextual input, independent action attention and honest acceptance status. Release fixture controls system/trusted layers/env, real advertised tools and both distinct MCP OpenAI routes; no debug-only hooks. Prepared untrusted config plus typed policy preserves intent through lifecycle/child or refuses precisely before work. Catalog binds only current owner/source, keeps staged/custom choices and rejects stale replies, never list omission. 6. **Outcome map:** Task 1 supplies actual native/runtime evidence and version alignment; 2 covers streaming/usage/plan/diff; 3 native items/declined outcomes/parent child view; 4 turn/Stop races; 5 native controls/MCP/deadman; 6 retained attention and hosted loss; 7 catalog/model/effort; 8 complete settings/Plan; 9 REST/CLI/MCP; 10 final actual-native/real-Rust/fake-native/browser/hosted/regression/documentation proof. -7. **Tests and interfaces:** Every task retains seven red/green/refactor/impact/commit steps. Deterministic barriers test both native-read/consumer schedules, old mutation reads, final bodies beyond cache eviction, real response IDs, same-ID steering and last-frame channel loss. Complete representative snippets exercise actual behavior. New symbols/paths are declared, exact focused helper/cloud commands remain, and required cloud cases must be discovered/executed. No missing-artifact/native scenario skip or cloud→local substitute is allowed. +7. **Tests and interfaces:** Ten tasks retain seventy red/green/refactor/impact/commit checkboxes. The unchanged request, paths, task ownership/commits, interfaces, commands and acceptance crossreferences were checked together. Source-falsified draft snippets were removed in favor of precise corrected decisions/test intent, without new unexecuted correction code. Deterministic schedules protect both read orders, finite output states, observer availability, Stop issuance, actual request IDs, async separation and final-frame loss. All native/local/managed/configured-cloud/browser coverage remains mandatory and nonvacuous; no skipped essential scenario/backend substitute counts. 8. **Operations and status:** Root dependencies/lock/workspaces/.bin, global configuration and host production CLI remain unchanged; managed pin change is branch-only. Secrets stay out of persistence/logs. Existing executable volume/test-image staging avoids offline downloads and undersized tmpfs. README/mock cover major changes. Plan publication is documentation-only; formal load-bearing validation, independent plan review and implementation remain separate required steps. -**Self-review result:** PASS for source-plan publication: ten tasks, seventy checkbox steps, the complete unchanged User Request exactly once, and explicit files/interfaces/commands. Native execution, image staging/pin behavior and resulting native claim verdicts remain mandatory early implementation gates; they are not claimed as verified by this documentation commit. The coordinator owns subsequent formal workflow transitions. +**Self-review result:** Documentation self-review completed: ten tasks, seventy checkbox steps, complete unchanged User Request exactly once, and reconciled files/interfaces/commands/coverage. This amendment adopts the cited source-supported bounded policies; it is not Stage 2 completion, an independent review pass or a native execution receipt. All early release/cloud/offline/managed gates and final full journeys remain unexecuted mandatory work; the coordinator owns formal workflow transitions. From 7a47a9b2c3d09a299d9123ba9f6f5e9e8a14fb99 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:30:43 -0700 Subject: [PATCH 03/92] feat(codex): verify native artifacts and enforce sandbox ownership --- crates/freshell-supervisor/src/backend.rs | 183 +++- .../src/backend/codex_sandbox.rs | 182 ++++ .../src/backend/input_publisher.rs | 9 +- crates/freshell-supervisor/src/service.rs | 4 + docker/cloud-run/Dockerfile | 8 + docker/runtime/codex-native-sandbox.json | 996 ++++++++++++++++++ scripts/sandbox-test.sh | 20 + scripts/testing/prepare-codex-contract.mjs | 227 ++++ .../coding-cli/codex-native-artifact.json | 12 + .../providers/codex-cli-contract.mjs | 673 ++++++++++++ .../providers/codex-cli-contract.test.ts | 30 + .../providers/codex-local-provider.mjs | 121 +++ 12 files changed, 2450 insertions(+), 15 deletions(-) create mode 100644 crates/freshell-supervisor/src/backend/codex_sandbox.rs create mode 100644 docker/runtime/codex-native-sandbox.json create mode 100644 scripts/testing/prepare-codex-contract.mjs create mode 100644 test/fixtures/coding-cli/codex-native-artifact.json create mode 100644 test/integration/providers/codex-cli-contract.mjs create mode 100644 test/integration/providers/codex-cli-contract.test.ts create mode 100644 test/integration/providers/codex-local-provider.mjs diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 654b89ba6..1d3f3667d 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -1,4 +1,5 @@ mod claude_source_observation; +mod codex_sandbox; pub mod docker; mod helper_lifecycle; pub(crate) use helper_lifecycle::CLAUDE_SOURCE_READER_RETRY; @@ -42,6 +43,8 @@ pub enum BackendError { Malformed(String), #[error("runtime backend configuration rejected: {0}")] InvalidConfig(String), + #[error("Codex native configuration incompatible: {0}")] + CodexIncompatible(String), } #[derive(Debug, Clone)] @@ -455,7 +458,9 @@ fn docker_create_body( operator_home_record: Option, requested_limits: String, memory_swap: u64, -) -> Value { +) -> Result { + let security_options = + codex_sandbox::security_options(spec.terminal.as_ref(), spec.fresh_agent.as_ref())?; let cap_add: Vec<&str> = if spec.terminal.is_some() || spec.fresh_agent.is_some() { vec!["CHOWN", "SETGID", "SETUID"] } else { @@ -472,7 +477,7 @@ fn docker_create_body( if let Some(record) = operator_home_record { labels[OPERATOR_HOME_MOUNT_LABEL] = json!(record); } - json!({ + Ok(json!({ "Image": spec.image_ref, "Env": host_env, "Cmd": [ @@ -493,7 +498,7 @@ fn docker_create_body( "Privileged": false, "CapDrop": ["ALL"], "CapAdd": cap_add, - "SecurityOpt": ["no-new-privileges:true"], + "SecurityOpt": security_options, "RestartPolicy": {"Name":"no","MaximumRetryCount":0}, "NanoCpus": spec.limits.cpu_milli.saturating_mul(1_000_000), "Memory": spec.limits.memory_bytes, @@ -502,7 +507,7 @@ fn docker_create_body( "Binds": binds, "Tmpfs": runtime_tmpfs_config } - }) + })) } fn workload_mcp_capability<'a>( @@ -883,7 +888,10 @@ impl RuntimeBackend for DockerEngineBackend { let operator_home = terminal_mounts .as_ref() .and_then(|mounts| mounts.operator_home.as_ref()); - let expected = ExpectedConfig::new(spec, &binary, &runtime_dir); + let expected = ExpectedConfig::new(spec, &binary, &runtime_dir)?; + if let Some(policy) = &expected.codex_native_sandbox { + tracing::info!(provider = "codex", incarnation_id = spec.incarnation_id.as_str(), image = %spec.image_ref, profile_id = %policy.id, profile_sha256 = %policy.profile_sha256, declared_native_version = %policy.declared_managed_version, supported_native_version = %policy.supported_native_version, helper_sha256 = %policy.helper_sha256, "runtime.codex_native_sandbox.selected"); + } let immutable_config_digest = digest_expected(&expected)?; let (daemon_id, rootless) = self.daemon_info().await?; self.ensure_reserved_initial_volume(spec, &daemon_id, rootless) @@ -944,7 +952,7 @@ impl RuntimeBackend for DockerEngineBackend { operator_home_record, requested_limits, memory_swap, - ); + )?; if let Some(mounts) = &terminal_mounts { let context = spec .terminal @@ -1258,6 +1266,8 @@ struct ExpectedConfig { terminal: Option, fresh_agent: Option, provider_volume_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + codex_native_sandbox: Option, } /// The registry updates session identity and turn settings while a container @@ -1299,8 +1309,12 @@ impl From<&FreshAgentLaunchSpec> for ImmutableFreshAgentConfig { } impl ExpectedConfig { - fn new(spec: &CreateRuntimeSpec, binary: &Path, runtime_dir: &Path) -> Self { - Self { + fn new( + spec: &CreateRuntimeSpec, + binary: &Path, + runtime_dir: &Path, + ) -> Result { + Ok(Self { image_ref: spec.image_ref.clone(), installation_id: spec.installation_id.to_string(), soul_id: spec.soul_id.to_string(), @@ -1311,7 +1325,11 @@ impl ExpectedConfig { terminal: spec.terminal.clone(), fresh_agent: spec.fresh_agent.as_ref().map(Into::into), provider_volume_name: spec.provider_volume_name.clone(), - } + codex_native_sandbox: codex_sandbox::identity( + spec.terminal.as_ref(), + spec.fresh_agent.as_ref(), + )?, + }) } } @@ -1327,6 +1345,7 @@ fn immutable_digest_from_handle(handle: &OwnedRuntimeHandle) -> Result Result<( let host_config = value .get("HostConfig") .ok_or_else(|| BackendError::OwnershipMismatch("missing HostConfig".into()))?; + if codex_sandbox::selected(handle.terminal(), handle.fresh_agent()) { + codex_sandbox::verify(host_config)?; + } let expected_network = if handle.terminal().is_some() || handle.fresh_agent().is_some() { "bridge" } else { @@ -1944,6 +1966,9 @@ mod tests { "PidMode":"", "Privileged":false, "CapAdd":["CHOWN","SETGID","SETUID"], + "CapDrop":["ALL"], + "ReadonlyRootfs":true, + "SecurityOpt":codex_sandbox::security_options(None, handle.fresh_agent()).unwrap(), "Tmpfs":runtime_tmpfs(Some("codex")), }, "Mounts": [ @@ -2172,7 +2197,7 @@ mod tests { serde_json::to_string(&spec.limits).unwrap(), spec.limits.memory_bytes, ); - let durable_json = serde_json::to_string(&body).unwrap(); + let durable_json = serde_json::to_string(&body.unwrap()).unwrap(); assert!(durable_json.contains(&keys.to_string_lossy().to_string())); // Docker needs only the exact read-only mount. The profile selector is // delivered later over the authenticated host grant, while resolved @@ -2264,6 +2289,7 @@ mod tests { terminal: None, fresh_agent: Some((&agent).into()), provider_volume_name: "freshell-provider-one".into(), + codex_native_sandbox: codex_sandbox::identity(None, Some(&agent)).unwrap(), }) .unwrap() }; @@ -2338,6 +2364,11 @@ mod tests { terminal: (!fresh_route).then_some(terminal.clone()), fresh_agent: fresh_route.then(|| (&fresh).into()), provider_volume_name: "owned-provider".into(), + codex_native_sandbox: codex_sandbox::identity( + (!fresh_route).then_some(&terminal), + fresh_route.then_some(&fresh), + ) + .unwrap(), }; let before = digest_expected(&expected).unwrap(); let context = if let Some(terminal) = expected.terminal.as_mut() { @@ -2607,6 +2638,138 @@ mod tests { assert!(verify_inspect_config(&handle, &inspected).is_ok()); } + fn codex_runtime_fixture(root: &Path) -> (OwnedRuntimeHandle, Value) { + let (original, mut inspected) = terminal_runtime_fixture(root, None); + let mut terminal = original.terminal().unwrap().clone(); + terminal.mode = "codex".into(); + terminal.program = "codex".into(); + let make_handle = |digest: String| { + OwnedRuntimeHandle::from_registry( + original.installation_id().clone(), + original.soul_id().clone(), + original.incarnation_id().clone(), + original.launch_nonce().clone(), + original.daemon_id().clone(), + original.container_id().into(), + original.image_ref().into(), + original.runtime_dir().into(), + original.host_binary_path().into(), + digest, + original.requested_limits(), + None, + Some(terminal.clone()), + None, + original.provider_volume_name().into(), + ) + }; + let provisional = make_handle(original.immutable_config_digest().into()); + let handle = make_handle(immutable_digest_from_handle(&provisional).unwrap()); + inspected["Config"]["Env"] = + json!(runtime_host_environment(Some(&terminal), None, None).unwrap()); + inspected["HostConfig"]["Tmpfs"] = json!(runtime_tmpfs(Some("codex"))); + inspected["HostConfig"]["ReadonlyRootfs"] = json!(true); + inspected["HostConfig"]["CapDrop"] = json!(["ALL"]); + inspected["HostConfig"]["SecurityOpt"] = + json!(codex_sandbox::security_options(Some(&terminal), None).unwrap()); + inspected["Id"] = json!(handle.container_id()); + inspected["State"] = json!({"Status":"created","Running":false}); + (handle, inspected) + } + + #[test] + fn codex_native_sandbox_policy_is_required_before_reusing_runtime() { + let root = tempfile::tempdir().unwrap(); + let (handle, mut inspected) = codex_runtime_fixture(root.path()); + assert!(verify_inspect_config(&handle, &inspected).is_ok()); + inspected["HostConfig"]["SecurityOpt"] = json!(["no-new-privileges:true"]); + assert!( + matches!( + verify_inspect_config(&handle, &inspected), + Err(BackendError::OwnershipMismatch(_)) + ), + "a live Codex runtime without the required native sandbox policy must not be reused" + ); + } + + #[tokio::test] + async fn codex_native_policy_is_verified_before_docker_start() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + let root = tempfile::tempdir().unwrap(); + let (handle, inspected) = codex_runtime_fixture(root.path()); + let socket = root.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let inspected = Arc::new(Mutex::new(inspected)); + let starts = Arc::new(AtomicUsize::new(0)); + let (stop, mut stopped) = tokio::sync::oneshot::channel::<()>(); + let (observed, calls, daemon) = ( + inspected.clone(), + starts.clone(), + handle.daemon_id().to_string(), + ); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = tokio::select! { + _ = &mut stopped => break, + accepted = listener.accept() => accepted.unwrap(), + }; + let mut request = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let count = stream.read(&mut chunk).await.unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&chunk[..count]); + if find_bytes(&request, b"\r\n\r\n").is_some() { + break; + } + } + let line = String::from_utf8_lossy(&request) + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let (status, body) = if line.starts_with("GET /v1.47/info ") { + ( + 200, + serde_json::to_vec( + &json!({"ID":daemon,"SecurityOptions":["name=rootless"]}), + ) + .unwrap(), + ) + } else if line.starts_with("GET /v1.47/containers/container-one/json ") { + (200, serde_json::to_vec(&*observed.lock().unwrap()).unwrap()) + } else if line.starts_with("POST /v1.47/containers/container-one/start ") { + calls.fetch_add(1, Ordering::SeqCst); + (204, Vec::new()) + } else { + (404, Vec::new()) + }; + stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap(); + stream.write_all(&body).await.unwrap(); + stream.shutdown().await.unwrap(); + } + }); + let backend = DockerEngineBackend::new(&socket); + backend.start_host(&handle).await.unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 1); + inspected.lock().unwrap()["HostConfig"]["SecurityOpt"] = json!(["no-new-privileges:true"]); + assert!(matches!( + backend.start_host(&handle).await, + Err(BackendError::OwnershipMismatch(_)) + )); + assert_eq!( + starts.load(Ordering::SeqCst), + 1, + "missing native policy must not issue another Docker start" + ); + stop.send(()).unwrap(); + server.await.unwrap(); + } + #[test] fn verify_inspect_config_uses_the_persisted_creation_time_mask_list() { // Late-directory case: a soul created with masks [".freshell"] because diff --git a/crates/freshell-supervisor/src/backend/codex_sandbox.rs b/crates/freshell-supervisor/src/backend/codex_sandbox.rs new file mode 100644 index 000000000..8843cd78a --- /dev/null +++ b/crates/freshell-supervisor/src/backend/codex_sandbox.rs @@ -0,0 +1,182 @@ +//! Codex's native sandbox needs only the declared namespace/mount delta. +//! Docker receives the policy itself, never a daemon-local file pathname. +use super::BackendError; +use freshell_runtime_protocol::{FreshAgentLaunchSpec, FreshProvider, TerminalLaunchSpec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::sync::OnceLock; + +const RESOURCE: &str = include_str!("../../../../docker/runtime/codex-native-sandbox.json"); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct Identity { + pub id: String, + pub profile_sha256: String, + pub supported_native_version: String, + pub declared_managed_version: String, + pub helper_sha256: String, + pub launch_wrapper: String, +} + +struct Policy { + identity: Identity, + profile: Value, + security_option: String, +} + +pub(super) fn selected( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> bool { + terminal.is_some_and(|terminal| terminal.mode == "codex") + || agent.is_some_and(|agent| { + agent.provider == FreshProvider::Codex && agent.fixture_transport.is_none() + }) +} + +fn parse(raw: &str) -> Result { + let resource: Value = + serde_json::from_str(raw).map_err(|error| format!("invalid resource JSON: {error}"))?; + if resource["schemaVersion"] != 1 + || resource["profile"]["defaultAction"] != "SCMP_ACT_ERRNO" + || resource["profile"]["defaultErrnoRet"] != 1 + || !resource["profile"]["syscalls"].is_array() + { + return Err("unsupported resource schema or native default-deny policy".into()); + } + let field = |pointer| { + resource + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("missing native policy field {pointer}")) + }; + let profile = resource["profile"].clone(); + let profile_json = serde_json::to_string(&profile).map_err(|error| error.to_string())?; + let identity = Identity { + id: field("/id")?, + profile_sha256: format!("sha256:{:x}", Sha256::digest(profile_json.as_bytes())), + supported_native_version: field("/native/version")?, + declared_managed_version: freshell_agent_runtime::pinned_provider_version("codex") + .ok_or("managed Codex version is undeclared")? + .to_owned(), + helper_sha256: field("/native/bwrapSha256")?, + launch_wrapper: field("/native/launchWrapper")?, + }; + Ok(Policy { + identity, + profile, + security_option: format!("seccomp={profile_json}"), + }) +} + +fn policy() -> Result<&'static Policy, BackendError> { + static POLICY: OnceLock> = OnceLock::new(); + POLICY + .get_or_init(|| parse(RESOURCE)) + .as_ref() + .map_err(|error| BackendError::CodexIncompatible(error.clone())) +} + +pub(super) fn identity( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> Result, BackendError> { + if selected(terminal, agent) { + Ok(Some(policy()?.identity.clone())) + } else { + Ok(None) + } +} + +pub(super) fn security_options( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> Result, BackendError> { + let mut options = vec!["no-new-privileges:true".to_owned()]; + if selected(terminal, agent) { + options.push(policy()?.security_option.clone()); + } + Ok(options) +} + +pub(super) fn verify(host: &Value) -> Result<(), BackendError> { + let expected = policy()?; + let fail = || { + BackendError::OwnershipMismatch( + "Codex native sandbox policy or outer isolation changed".into(), + ) + }; + if host["ReadonlyRootfs"] != true + || host["CapDrop"].as_array().is_none_or(|caps| { + caps.len() != 1 || !matches!(caps[0].as_str(), Some("ALL" | "CAP_ALL")) + }) + { + return Err(fail()); + } + let options = host["SecurityOpt"].as_array().ok_or_else(fail)?; + if options.len() != 2 { + return Err(fail()); + } + let mut no_new_privileges = false; + let mut matched = false; + for option in options { + let option = option.as_str().ok_or_else(fail)?; + if matches!( + option, + "no-new-privileges" | "no-new-privileges:true" | "no-new-privileges=true" + ) && !no_new_privileges + { + no_new_privileges = true; + } else if let Some(raw) = option.strip_prefix("seccomp=").filter(|_| !matched) { + let actual: Value = serde_json::from_str(raw).map_err(|_| fail())?; + if actual != expected.profile { + return Err(fail()); + } + matched = true; + } else { + return Err(fail()); + } + } + if !no_new_privileges || !matched { + return Err(fail()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_invalid_policy_is_a_configuration_refusal() { + assert!(parse("not JSON").is_err()); + assert!(parse("{}").is_err()); + let mut invalid: Value = serde_json::from_str(RESOURCE).unwrap(); + invalid["profile"]["defaultAction"] = Value::String("SCMP_ACT_ALLOW".into()); + assert!(parse(&invalid.to_string()).is_err()); + } + + #[test] + fn codex_policy_rejects_mutated_profile_and_outer_isolation() { + let policy = policy().unwrap(); + let valid = serde_json::json!({"ReadonlyRootfs":true,"CapDrop":["ALL"],"SecurityOpt":["no-new-privileges:true",policy.security_option]}); + assert!(verify(&valid).is_ok()); + let mut changed = valid.clone(); + changed["ReadonlyRootfs"] = Value::Bool(false); + assert!(verify(&changed).is_err()); + changed = valid.clone(); + changed["CapDrop"] = serde_json::json!([]); + assert!(verify(&changed).is_err()); + changed = valid.clone(); + changed["SecurityOpt"][1] = Value::String("seccomp=unconfined".into()); + assert!(verify(&changed).is_err()); + changed = valid; + let mut profile = policy.profile.clone(); + profile["defaultErrnoRet"] = Value::from(0); + changed["SecurityOpt"][1] = Value::String(format!("seccomp={profile}")); + assert!(verify(&changed).is_err()); + } +} diff --git a/crates/freshell-supervisor/src/backend/input_publisher.rs b/crates/freshell-supervisor/src/backend/input_publisher.rs index a1266138a..182264e58 100644 --- a/crates/freshell-supervisor/src/backend/input_publisher.rs +++ b/crates/freshell-supervisor/src/backend/input_publisher.rs @@ -1820,11 +1820,10 @@ mod tests { candidate.image_ref.clone(), candidate.runtime_dir.clone(), binary.clone(), - digest_expected(&ExpectedConfig::new( - candidate, - &binary, - &candidate.runtime_dir, - )) + digest_expected( + &ExpectedConfig::new(candidate, &binary, &candidate.runtime_dir) + .unwrap(), + ) .unwrap(), candidate.limits, None, diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 53282b1c3..449c2f955 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -4427,6 +4427,7 @@ fn map_registry(error: RegistryError) -> RuntimeError { fn map_backend(error: BackendError) -> RuntimeError { let code = match &error { BackendError::OwnershipMismatch(_) => RuntimeErrorCode::OwnershipMismatch, + BackendError::CodexIncompatible(_) => RuntimeErrorCode::InvalidRequest, BackendError::Unavailable(_) | BackendError::DockerHttp { .. } => { RuntimeErrorCode::BackendUnavailable } @@ -4442,6 +4443,9 @@ fn backend_failure_diagnostic(error: &BackendError) -> serde_json::Value { BackendError::DockerHttp { status, body } => ("docker_http", Some(*status), body.as_str()), BackendError::Malformed(detail) => ("malformed", None, detail.as_str()), BackendError::InvalidConfig(detail) => ("invalid_config", None, detail.as_str()), + BackendError::CodexIncompatible(detail) => { + ("codex_configuration_incompatible", None, detail.as_str()) + } }; const MAX_DETAIL_CHARS: usize = 512; let redacted = freshell_runtime_observability::scrub(detail, ""); diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index 5df621a5a..e42759c12 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -38,6 +38,7 @@ COPY Cargo.toml Cargo.lock ./ COPY crates/ ./crates/ COPY extensions/ ./extensions/ COPY docker/runtime/provider-versions.json ./docker/runtime/provider-versions.json +COPY docker/runtime/codex-native-sandbox.json ./docker/runtime/codex-native-sandbox.json RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked # --------------------------------------------------------------------------- @@ -68,6 +69,7 @@ RUN npm install -g pnpm@10.34.5 \ && pnpm install --frozen-lockfile --ignore-scripts COPY . . +RUN node scripts/testing/prepare-codex-contract.mjs --output /opt/freshell-test-providers/codex/0.164.0-linux-x64 RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" pnpm run build:client && pnpm run build:tools # --------------------------------------------------------------------------- @@ -113,6 +115,7 @@ COPY --from=node-builder /app/node_modules ./node_modules # The pinned Claude contract exercises the isolated sidecar package directly. # Its node_modules links back to the root pnpm store copied above. COPY --from=node-builder /app/crates/freshell-claude-sidecar/node_modules ./crates/freshell-claude-sidecar/node_modules +COPY --from=node-builder /opt/freshell-test-providers/codex/0.164.0-linux-x64 /opt/freshell-test-providers/codex/0.164.0-linux-x64 # Fail the image build if the copied test toolchain cannot execute in this # Debian runtime. A container-level test checks the configured runtime user. @@ -131,6 +134,7 @@ COPY . . RUN scripts/verify-container-layout.sh --fixture /app --runtime-root ENV FRESHELL_E2E_RUST_SERVER_BIN=/app/target/release/freshell-server +ENV FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64 # COPY's requested mode can still be narrowed by rootless build/ACL metadata. # Normalize the copied artifact in the image layer so the final unprivileged @@ -143,6 +147,10 @@ RUN chmod 755 /usr/local/bin/e2e-entrypoint.sh \ RUN chown -R node:node /app USER node +# Verify the complete copied native tree as the final runtime identity, +# including /opt ancestors outside /app's ownership adjustment. +RUN node --input-type=module -e 'import { verifyArtifact } from "./scripts/testing/prepare-codex-contract.mjs"; await verifyArtifact(process.env.FRESHELL_CODEX_CONTRACT_ROOT)' + # Verify the final runtime user can read, syntax-check, and execute the exact # entrypoint artifact. `--dry-run` exits before Playwright and exercises the # image's configured ENTRYPOINT behavior without running a test workload. diff --git a/docker/runtime/codex-native-sandbox.json b/docker/runtime/codex-native-sandbox.json new file mode 100644 index 000000000..c4df5befb --- /dev/null +++ b/docker/runtime/codex-native-sandbox.json @@ -0,0 +1,996 @@ +{ + "schemaVersion": 1, + "id": "codex-native-0.164-docker-seccomp-v1", + "native": { + "version": "0.164.0", + "platform": "linux", + "arch": "x64", + "executable": "vendor/x86_64-unknown-linux-musl/bin/codex", + "executableSha256": "b93f6811d3f653f0fb83be4050d857fb9abbb111dfa2ce160563327daff7f306", + "bwrap": "vendor/x86_64-unknown-linux-musl/codex-resources/bwrap", + "bwrapSha256": "3a81c19ad10a70436529c58e77898e2a447abe80167f813fc20ec34bd9eafdeb", + "npmWrapper": "/usr/local/bin/codex", + "launchWrapper": "/opt/freshell-codex/launch", + "nodeExecutable": "/usr/local/bin/node" + }, + "provenance": { + "daemonVersion": "29.7.2", + "daemonGitCommit": "6a43e3d", + "daemonSource": "https://raw.githubusercontent.com/moby/moby/6a43e3d/go.mod", + "defaultModule": "github.com/moby/profiles/seccomp v0.2.3", + "defaultSource": "https://raw.githubusercontent.com/moby/profiles/836ae4d37ef2ec995c77c99fc55f5b5f3af3a897/seccomp/default.json", + "defaultSha256": "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74", + "nativeSource": "https://github.com/openai/codex/tree/6aaac694803004a63c6f393d153c96d22521a6b9/codex-rs/linux-sandbox", + "addedRules": [ + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ] + } + ] + }, + "profile": { + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ] + } + ] + } +} diff --git a/scripts/sandbox-test.sh b/scripts/sandbox-test.sh index f75d9e2a3..fd86aec7d 100755 --- a/scripts/sandbox-test.sh +++ b/scripts/sandbox-test.sh @@ -133,6 +133,20 @@ else fi CMD="$1" + # Only the closed native Codex contract entrypoint receives its declared + # sandbox construction policy. Every other runtime suite keeps Docker's + # existing default. Verify before the install-capable entrypoint can run. + CODEX_NATIVE_CONTRACT=0 + if [ "${RUNTIME_SUITE}" -eq 1 ]; then + case "${CMD}" in + 'node test/integration/providers/codex-cli-contract.mjs --scenario all' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec') + node "${REPO_ROOT}/scripts/testing/prepare-codex-contract.mjs" --verify-sandbox >/dev/null + CODEX_NATIVE_CONTRACT=1 + ;; + esac + fi + if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then echo "[sandbox] image ${IMAGE_TAG} not found, building it first..." >&2 "${REPO_ROOT}/scripts/sandbox-build.sh" @@ -370,6 +384,12 @@ if [ "${RUNTIME_SUITE}" -eq 1 ]; then --security-opt no-new-privileges -e FRESHELL_SANDBOX_MODE=runtime-suite ) + if [ "${CODEX_NATIVE_CONTRACT:-0}" -eq 1 ]; then + DOCKER_ARGS+=( + --security-opt "seccomp=${REPO_ROOT}/.runtime-build/codex-contract/codex-policy.json" + -e FRESHELL_CODEX_CONTRACT_POLICY=prepared + ) + fi if [ "${CHECKPOINT_FIXTURE}" -eq 1 ]; then DOCKER_ARGS+=(-e FRESHELL_SANDBOX_CHECKPOINT_FIXTURE=1) fi diff --git a/scripts/testing/prepare-codex-contract.mjs b/scripts/testing/prepare-codex-contract.mjs new file mode 100644 index 000000000..b91aeb66f --- /dev/null +++ b/scripts/testing/prepare-codex-contract.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +// Connected preparation is deliberately separate from every native runner. +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash, randomUUID } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +export const artifactMetadata = JSON.parse(fs.readFileSync(path.join(repoRoot, 'test/fixtures/coding-cli/codex-native-artifact.json'), 'utf8')) +const stageRoot = path.join(repoRoot, '.runtime-build/codex-contract') +const stageManifest = path.join(stageRoot, 'staged.json') +const imageTag = 'freshell-sandbox:latest' +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') +const fail = (message) => { throw new Error(`CODEX_CONTRACT_PREREQUISITE: ${message}`) } +const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value + +export function nativeSandboxPolicy() { + const resource = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docker/runtime/codex-native-sandbox.json'), 'utf8')) + if (resource.schemaVersion !== 1 || resource.profile?.defaultAction !== 'SCMP_ACT_ERRNO' || resource.profile?.defaultErrnoRet !== 1 || resource.native?.version !== artifactMetadata.version) fail('declared Codex native sandbox resource is incompatible') + const json = JSON.stringify(canonical(resource.profile)) + return { id: resource.id, profileSha256: `sha256:${digest(json)}`, profile: resource.profile, native: resource.native, json } +} + +async function preparePolicy() { + const policy = nativeSandboxPolicy() + const temporary = path.join(stageRoot, `codex-policy.${randomUUID()}.json`) + await fsp.writeFile(temporary, policy.json, { mode: 0o644, flag: 'wx' }) + await fsp.rename(temporary, path.join(stageRoot, 'codex-policy.json')) + return policy +} + +export function ambientCodex() { + for (const directory of (process.env.PATH ?? '').split(path.delimiter)) { + const candidate = path.resolve(directory, 'codex') + try { fs.accessSync(candidate, fs.constants.X_OK); return fs.realpathSync(candidate) } catch {} + } + return null +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', timeout: 1_800_000, maxBuffer: 16 * 1024 * 1024, ...options }) + if (result.error || result.status !== 0) throw new Error(`${command} failed (${result.status}): ${result.error?.message ?? ''}\n${result.stderr ?? ''}\n${result.stdout ?? ''}`) + return result.stdout +} + +export function checkAncestors(directory) { + const records = [] + for (let current = path.resolve(directory); ; current = path.dirname(current)) { + fs.accessSync(current, fs.constants.R_OK | fs.constants.X_OK) + const stat = fs.statSync(current) + records.push({ path: current, uid: stat.uid, gid: stat.gid, mode: stat.mode & 0o777 }) + if (path.dirname(current) === current) return records + } +} + +async function inventory(root, relative = '') { + const files = [] + for (const name of (await fsp.readdir(path.join(root, relative))).sort()) { + if (!relative && name === 'artifact-receipt.json') continue + const rel = path.join(relative, name) + const absolute = path.join(root, rel) + const stat = await fsp.lstat(absolute) + if (stat.isDirectory()) files.push(...await inventory(root, rel)) + else if (stat.isFile()) files.push({ path: rel, bytes: stat.size, sha256: digest(await fsp.readFile(absolute)), executable: (stat.mode & 0o111) !== 0 }) + else fail(`nonregular artifact member ${rel}`) + } + return files +} + +async function normalizeOwnedTree(root) { + await fsp.chmod(root, 0o755) + for (const name of await fsp.readdir(root)) { + const absolute = path.join(root, name) + const stat = await fsp.lstat(absolute) + if (stat.isDirectory()) await normalizeOwnedTree(absolute) + else if (stat.isFile()) await fsp.chmod(absolute, stat.mode & 0o111 ? 0o755 : 0o644) + else fail(`nonregular artifact member ${absolute}`) + } +} + +export async function verifyArtifact(root, metadata = artifactMetadata) { + const absolute = await fsp.realpath(root) + const receipt = JSON.parse(await fsp.readFile(path.join(absolute, 'artifact-receipt.json'), 'utf8')) + if (receipt.integrity !== metadata.integrity || receipt.packageVersion !== metadata.packageVersion || receipt.complete !== true) fail('artifact receipt does not match pinned metadata') + const files = await inventory(absolute) + if (JSON.stringify(files) !== JSON.stringify(receipt.files)) fail('artifact bytes/layout/modes changed after verified preparation') + if (files.length !== metadata.fileCount || files.reduce((sum, file) => sum + file.bytes, 0) !== metadata.unpackedBytes) fail('artifact size/file count differs from package metadata') + const manifest = JSON.parse(await fsp.readFile(path.join(absolute, 'package.json'), 'utf8')) + if (manifest.name !== metadata.packageName || manifest.version !== metadata.packageVersion || !manifest.os?.includes(metadata.platform) || !manifest.cpu?.includes(metadata.arch)) fail('native package manifest does not match pinned platform') + const permissions = checkAncestors(absolute) + for (const file of files) { + fs.accessSync(path.join(absolute, file.path), fs.constants.R_OK | (file.executable ? fs.constants.X_OK : 0)) + checkAncestors(path.dirname(path.join(absolute, file.path))) + } + fs.accessSync(path.join(absolute, metadata.executable), fs.constants.X_OK) + return { ...receipt, path: absolute, permissions, uid: process.getuid?.(), gid: process.getgid?.() } +} + +export async function prepareArtifact(output, metadata = artifactMetadata) { + if (!path.isAbsolute(output)) fail('--output must be absolute') + if (fs.existsSync(output)) return verifyArtifact(output, metadata) + const ambientBefore = ambientCodex() + await fsp.mkdir(path.dirname(output), { recursive: true }) + const temporary = await fsp.mkdtemp(`${output}.partial-`) + const archive = path.join(temporary, 'native.tgz') + const unpacked = path.join(temporary, 'package') + try { + const response = await fetch(metadata.url, { signal: AbortSignal.timeout(180_000) }) + if (!response.ok || !response.body) fail(`download HTTP ${response.status}`) + const sha512 = createHash('sha512') + let archiveBytes = 0 + await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, _encoding, callback) { sha512.update(chunk); archiveBytes += chunk.length; callback(null, chunk) } }), fs.createWriteStream(archive, { flags: 'wx' })) + const integrity = `sha512-${sha512.digest('base64')}` + if (integrity !== metadata.integrity) fail('download archive integrity mismatch') + const members = run('tar', ['-tzf', archive]).trim().split('\n') + if (members.some(member => !member.startsWith('package/') || member.split('/').includes('..') || member.includes('\\'))) fail('unexpected archive path') + await fsp.mkdir(unpacked) + run('tar', ['-xzf', archive, '--strip-components=1', '-C', unpacked, '--no-same-owner']) + await normalizeOwnedTree(unpacked) + const files = await inventory(unpacked) + const receipt = { complete: true, ...metadata, archiveBytes, files, ambientBefore, ambientAfter: ambientCodex() } + if (receipt.ambientAfter !== ambientBefore) fail('preparation changed ambient codex resolution') + await fsp.writeFile(path.join(unpacked, 'artifact-receipt.json'), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o644 }) + await verifyArtifact(unpacked, metadata) + try { await fsp.rename(unpacked, output) } catch (error) { if (error.code !== 'EEXIST' && error.code !== 'ENOTEMPTY') throw error } + return await verifyArtifact(output, metadata) + } finally { await fsp.rm(temporary, { recursive: true, force: true }) } +} + +function dependencyFingerprint() { + let input = 'manager=pnpm@10.34.5\npolicy=install --frozen-lockfile --store-dir /home/sandbox/.local/share/pnpm/store\n' + for (const name of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'crates/freshell-claude-sidecar/package.json', 'packages/freshell-mcp-runtime/package.json']) { + const file = path.join(repoRoot, name) + if (fs.existsSync(file)) input += `${digest(fs.readFileSync(file))} ${name}\n` + } + return digest(input) +} + +async function stagePrepared() { + if (repoRoot !== '/workspace') fail('--stage-prepared runs only in the connected wrapper') + const artifact = await verifyArtifact(path.join(stageRoot, 'artifact')) + const state = await fsp.readFile('/workspace/node_modules/.sandbox-deps-state', 'utf8') + const fingerprint = dependencyFingerprint() + if (!state.includes(`fingerprint=${fingerprint}\n`) || !state.includes('manager_version=10.34.5\n')) fail('connected dependency warmup did not produce the expected fingerprint') + const subdir = `freshcodex-contract-${randomUUID()}` + const owned = `/workspace/target/${subdir}` + await fsp.mkdir(owned, { mode: 0o755 }) + await fsp.cp(artifact.path, path.join(owned, 'artifact'), { recursive: true, errorOnExist: true }) + for (const name of ['state', 'receipts']) await fsp.mkdir(path.join(owned, name), { mode: 0o700 }) + await verifyArtifact(path.join(owned, 'artifact')) + const policy = nativeSandboxPolicy() + const preparedPolicy = await fsp.readFile(path.join(stageRoot, 'codex-policy.json'), 'utf8') + if (`sha256:${digest(preparedPolicy)}` !== policy.profileSha256) fail('connected prepared policy is stale or corrupt') + await fsp.writeFile(path.join(owned, 'codex-policy.json'), preparedPolicy, { mode: 0o644 }) + const manifest = { complete: true, subdir, root: `/freshell-test-artifacts/${subdir}`, artifactRoot: `/freshell-test-artifacts/${subdir}/artifact`, stateRoot: `/freshell-test-artifacts/${subdir}/state`, receiptRoot: `/freshell-test-artifacts/${subdir}/receipts`, nativeSandboxPolicy: { id: policy.id, profileSha256: policy.profileSha256, path: `/freshell-test-artifacts/${subdir}/codex-policy.json` }, fingerprint, uid: process.getuid(), gid: process.getgid(), image: null } + return manifest +} + +async function verifyPrepared() { + const manifest = JSON.parse(await fsp.readFile(stageManifest, 'utf8')) + if (!manifest.complete || !/^freshcodex-contract-[a-f0-9-]{36}$/.test(manifest.subdir) || manifest.root !== `/freshell-test-artifacts/${manifest.subdir}`) fail('invalid offline stage ownership') + if (manifest.fingerprint !== dependencyFingerprint()) fail('dependency inputs changed since connected warmup') + const state = await fsp.readFile('/workspace/node_modules/.sandbox-deps-state', 'utf8') + if (!state.includes(`fingerprint=${manifest.fingerprint}\n`) || !state.includes('manager_version=10.34.5\n')) fail('sandbox dependency cache is absent or stale') + const artifact = await verifyArtifact(manifest.artifactRoot) + const policy = nativeSandboxPolicy() + if (manifest.nativeSandboxPolicy?.id !== policy.id || manifest.nativeSandboxPolicy?.profileSha256 !== policy.profileSha256 || manifest.nativeSandboxPolicy.path !== `${manifest.root}/codex-policy.json`) fail('offline native sandbox policy is absent, incompatible or stale') + const policyBytes = await fsp.readFile(manifest.nativeSandboxPolicy.path) + if (`sha256:${digest(policyBytes)}` !== policy.profileSha256) fail('offline native sandbox policy bytes changed') + for (const key of ['stateRoot', 'receiptRoot']) { + if (manifest[key] !== `${manifest.root}/${key === 'stateRoot' ? 'state' : 'receipts'}`) fail('writable root escaped owned directory') + const stat = await fsp.stat(manifest[key]) + if (!stat.isDirectory() || stat.uid !== process.getuid() || !(stat.mode & 0o200)) fail(`owned writable root unavailable: ${key}`) + checkAncestors(manifest[key]) + } + return { manifest, artifact, fingerprint: manifest.fingerprint, uid: process.getuid(), gid: process.getgid() } +} + +export async function verifySandbox() { + const manifest = JSON.parse(await fsp.readFile(stageManifest, 'utf8')) + const policy = nativeSandboxPolicy() + const hostPolicy = await fsp.readFile(path.join(stageRoot, 'codex-policy.json')) + if (`sha256:${digest(hostPolicy)}` !== policy.profileSha256 || manifest.nativeSandboxPolicy?.profileSha256 !== policy.profileSha256) fail('prepared Codex native policy is absent or stale') + const image = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() + if (manifest.image !== image) fail('sandbox image changed since connected warmup') + for (const volume of ['freshell-sandbox-cargo-target', 'freshell-sandbox-node-modules', 'freshell-sandbox-sidecar-node-modules', 'freshell-sandbox-mcp-node-modules']) run('docker', ['volume', 'inspect', volume]) + const name = `freshcodex-verifier-${randomUUID()}` + const args = ['run', '--rm', '--name', name, '--label', `org.freshell.codex-contract=${name}`, '--read-only', '--network', 'none', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', `${manifest.uid}:${manifest.gid}`, '--entrypoint', 'node', '-v', `${repoRoot}:/workspace:ro`, '-v', 'freshell-sandbox-cargo-target:/freshell-test-artifacts:ro', '-v', 'freshell-sandbox-node-modules:/workspace/node_modules:ro', image, '/workspace/scripts/testing/prepare-codex-contract.mjs', '--verify-prepared'] + try { return JSON.parse(run('docker', args, { timeout: 60_000 })) } finally { + const inspection = spawnSync('docker', ['container', 'inspect', name], { encoding: 'utf8', timeout: 15_000 }) + if (inspection.status === 0) { + const container = JSON.parse(inspection.stdout)[0] + if (container.Config?.Labels?.['org.freshell.codex-contract'] !== name) fail('verifier container ownership mismatch') + run('docker', ['rm', '--force', container.Id], { timeout: 30_000 }) + } + } +} + +async function main(args) { + let result + if (args[0] === '--output' && args.length === 2) result = await prepareArtifact(args[1]) + else if (args[0] === '--output' && args.length === 4 && args[2] === '--probe-metadata') { + const metadata = JSON.parse(await fsp.readFile(path.resolve(args[3]), 'utf8')) + if (metadata.probeOnly !== true) fail('candidate artifact requires an explicit probe-only manifest') + result = await prepareArtifact(args[1], metadata) + } + else if (args.length === 1 && args[0] === '--stage-prepared') result = await stagePrepared() + else if (args.length === 1 && args[0] === '--verify-prepared') result = await verifyPrepared() + else if (args.length === 1 && args[0] === '--verify-sandbox') result = await verifySandbox() + else if (args.length === 1 && args[0] === '--sandbox') { + await prepareArtifact(path.join(stageRoot, 'artifact')) + await preparePolicy() + result = JSON.parse(run('bash', ['scripts/sandbox-test.sh', 'node scripts/testing/prepare-codex-contract.mjs --stage-prepared'], { stdio: ['ignore', 'pipe', 'inherit'] })) + result.image = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() + await fsp.writeFile(stageManifest, `${JSON.stringify(result, null, 2)}\n`) + await verifySandbox() + } else fail('usage: --output ABS | --sandbox | --stage-prepared | --verify-sandbox') + process.stdout.write(`${JSON.stringify(result)}\n`) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch(error => { console.error(JSON.stringify({ severity: 'error', event: 'codex_contract_preparation_failed', message: error.message })); process.exitCode = 1 }) +} diff --git a/test/fixtures/coding-cli/codex-native-artifact.json b/test/fixtures/coding-cli/codex-native-artifact.json new file mode 100644 index 000000000..d5afc39e7 --- /dev/null +++ b/test/fixtures/coding-cli/codex-native-artifact.json @@ -0,0 +1,12 @@ +{ + "version": "0.164.0", + "packageName": "@openai/codex", + "packageVersion": "0.164.0-linux-x64", + "platform": "linux", + "arch": "x64", + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.164.0-linux-x64.tgz", + "integrity": "sha512-XLAoaWsGOT2khzl+P4FYDPTkzAr9W0VFXFaVkqlFdkM0soa1gH/4S0wSD57Ek3HHwkirAEGz2/Mwehseh+l5Mg==", + "unpackedBytes": 444349626, + "fileCount": 45, + "executable": "vendor/x86_64-unknown-linux-musl/bin/codex" +} diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs new file mode 100644 index 000000000..9d3c840ed --- /dev/null +++ b/test/integration/providers/codex-cli-contract.mjs @@ -0,0 +1,673 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import { spawn, spawnSync } from 'node:child_process' +import { createInterface } from 'node:readline' +import { randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { artifactMetadata, repoRoot, verifyArtifact, ambientCodex, verifySandbox, nativeSandboxPolicy } from '../../../scripts/testing/prepare-codex-contract.mjs' +import { createCodexLocalProvider, MCP_FORM_CONTENT } from './codex-local-provider.mjs' + +const scenarios = ['text-history', 'active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'] +const deadline = (promise, ms, label) => { + let timer + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`CODEX_CONTRACT_TIMEOUT: ${label}`)), ms) })]).finally(() => clearTimeout(timer)) +} +function deferred() { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +async function roots(options) { + const metadata = options.probeMetadata ?? artifactMetadata + let staged + if (fs.existsSync(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'))) staged = JSON.parse(await fsp.readFile(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'), 'utf8')) + const artifactRoot = options.artifactRoot ?? process.env.FRESHELL_CODEX_CONTRACT_ROOT ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.artifactRoot : undefined) + if (!artifactRoot) throw new Error('CODEX_CONTRACT_PREREQUISITE: explicit prepared artifact root is missing; no PATH fallback') + const artifact = await verifyArtifact(artifactRoot, metadata) + const stateBase = options.stateRoot ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.stateRoot : undefined) ?? os.tmpdir() + const stateRoot = await fsp.mkdtemp(path.join(stateBase, 'codex-contract-')) + const receiptRoot = path.resolve(options.receiptDir ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.receiptRoot : undefined) ?? path.join(stateRoot, 'receipts')) + await fsp.mkdir(receiptRoot, { recursive: true, mode: 0o700 }) + for (const directory of ['home', 'codex', 'xdg-config', 'xdg-cache', 'xdg-data', 'workspace', 'tmp']) await fsp.mkdir(path.join(stateRoot, directory), { mode: 0o700 }) + const systemConfig = ['/etc/codex/config.toml', '/etc/codex/requirements.toml'].map(file => ({ path: file, present: fs.existsSync(file) })) + if (systemConfig.some(file => file.present)) throw new Error('CODEX_CONTRACT_PREREQUISITE: unexpected system Codex configuration in fixture environment') + const environment = { PATH: process.env.PATH, HOME: path.join(stateRoot, 'home'), CODEX_HOME: await fsp.realpath(path.join(stateRoot, 'codex')), XDG_CONFIG_HOME: path.join(stateRoot, 'xdg-config'), XDG_CACHE_HOME: path.join(stateRoot, 'xdg-cache'), XDG_DATA_HOME: path.join(stateRoot, 'xdg-data'), TMPDIR: path.join(stateRoot, 'tmp'), LANG: 'C.UTF-8', RUST_LOG: 'warn' } + if (!options.probeMetadata || options.preferBundledBwrap) { + const bundled = artifact.files.filter(file => file.path.endsWith('/codex-resources/bwrap')) + assert.equal(bundled.length, 1, 'fixture PATH selection needs the single verified bundled bubblewrap resource') + environment.PATH = `${path.dirname(path.join(artifact.path, bundled[0].path))}:${environment.PATH}` + } + return { artifact, metadata, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged } +} + +class NativeClient { + constructor(binary, context, capture) { + this.capture = capture + this.pending = new Map() + this.waiters = new Set() + this.nextId = 1 + this.child = spawn(binary, ['app-server'], { cwd: context.workspace, env: context.environment, stdio: ['pipe', 'pipe', 'pipe'] }) + if (capture.environment.seccompProbe || capture.environment.nativeSandboxPolicy) { + const observed = new Map((capture.observations.executedSandboxHelpers ?? []).map(helper => [helper.pid, helper])) + this.helperWatcher = setInterval(() => { + const visit = pid => { + try { + const executable = fs.readlinkSync(`/proc/${pid}/exe`) + if (executable.endsWith('/bwrap')) observed.set(pid, { pid, executable, argv0: fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] }) + } catch {} + try { + const children = new Set() + for (const task of fs.readdirSync(`/proc/${pid}/task`)) { + try { for (const child of fs.readFileSync(`/proc/${pid}/task/${task}/children`, 'utf8').trim().split(/\s+/).filter(Boolean)) children.add(Number(child)) } catch {} + } + for (const child of children) visit(child) + } catch {} + } + if (this.child.exitCode === null) visit(this.child.pid) + capture.observations.executedSandboxHelpers = [...observed.values()] + }, 5) + } + this.exit = new Promise(resolve => this.child.once('exit', (code, signal) => { + capture.processExit = { code, signal } + clearInterval(this.helperWatcher) + for (const entry of this.pending.values()) entry.reject(new Error(`owned Codex exited: ${code}/${signal}`)) + resolve({ code, signal }) + })) + this.child.stderr.on('data', bytes => { capture.stderr = `${capture.stderr ?? ''}${bytes.toString()}`.slice(-64 * 1024) }) + createInterface({ input: this.child.stdout }).on('line', line => { + let frame + try { frame = JSON.parse(line) } catch { return } + capture.frames.push({ index: capture.frames.length, direction: 'native', frame }) + if (frame.id !== undefined && !frame.method) { + capture.responses.push(frame) + const entry = this.pending.get(frame.id) + if (entry) { this.pending.delete(frame.id); if (frame.error) entry.reject(Object.assign(new Error(frame.error.message), { nativeError: frame.error })); else entry.resolve(frame.result) } + } else if (frame.method && frame.id !== undefined) { + capture.serverRequests.push(frame) + this.onServerRequest?.(frame) + } + else capture.notifications.push(frame) + for (const waiter of [...this.waiters]) if (waiter.predicate(frame)) { this.waiters.delete(waiter); waiter.resolve(frame) } + }) + } + send(frame) { this.capture.frames.push({ index: this.capture.frames.length, direction: 'client', frame }); this.child.stdin.write(`${JSON.stringify(frame)}\n`) } + request(method, params = {}, timeoutMs = 30_000) { + const id = this.nextId++ + const frame = { id, method, params } + this.capture.requests.push(frame) + return deadline(new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.send(frame) }), timeoutMs, method) + } + wait(predicate, timeoutMs = 30_000) { + const existing = this.capture.frames.find(entry => entry.direction === 'native' && predicate(entry.frame)) + if (existing) return Promise.resolve(existing.frame) + let waiter + return deadline(new Promise(resolve => { waiter = { predicate, resolve }; this.waiters.add(waiter) }), timeoutMs, 'native notification').finally(() => this.waiters.delete(waiter)) + } + async close() { + this.child.stdin.end() + if (this.child.exitCode === null) this.child.kill('SIGTERM') + try { await deadline(this.exit, 5000, 'owned process exit') } catch { this.child.kill('SIGKILL'); await deadline(this.exit, 5000, 'owned process kill') } + } +} + +function assistantText(read, nativeTurnId) { + return read.thread.turns.filter(turn => !nativeTurnId || turn.id === nativeTurnId).flatMap(turn => turn.items.filter(item => item.type === 'agentMessage').map(item => item.text)).join('\n') +} + +async function nativeRefusal(client, method, params) { + try { await client.request(method, params) } catch (error) { assert(error.nativeError, 'refusal must come from the native provider'); return error.nativeError } + assert.fail(`native ${method} unexpectedly accepted invalid input`) +} + +async function configure(context, baseUrl, extra = '') { + await fsp.writeFile(path.join(context.environment.CODEX_HOME, 'config.toml'), `model = "contract-model"\nmodel_provider = "contract-loopback"\nmodel_reasoning_effort = "low"\n${extra}\n[model_providers.contract-loopback]\nname = "Codex contract loopback"\nbase_url = ${JSON.stringify(baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n`) +} + +function advertisedCall(record, name, args, id) { + let namespace + let tool = record.body.tools.find(tool => tool.type === 'function' && tool.name === name) + if (!tool) { + const group = record.body.tools.find(tool => tool.type === 'namespace' && tool.tools?.some(member => member.name === name)) + tool = group?.tools.find(member => member.name === name) + namespace = group?.name + } + assert(tool, `actual native model request did not advertise ${name}`) + return { items: [{ id: `fc_${id}`, type: 'function_call', call_id: `call_${id}`, ...(namespace ? { namespace } : {}), name, arguments: JSON.stringify(args), status: 'completed' }] } +} + +async function questionResponse(record, flow, capture) { + const current = flow.current + assert(current, 'question model work must belong to the selected native case') + const step = current.step++ + if (current.kind === 'contextual-reply') { + capture.observations.contextualAnswerModelInput = record.body.input + return { text: 'First fragment and final fragment' } + } + if (step === 0) { + if (current.kind === 'async') return advertisedCall(record, 'request_user_input_async', { questions: [{ title: 'Which fixture environment should be used?', options: ['Staging', 'Production'] }, { title: 'What deadline should be used?' }] }, `async_${current.id}`) + return advertisedCall(record, 'request_user_input', { questions: [{ id: 'fixture_choice', header: 'Choice', question: 'Which fixture result should be used?', options: [{ label: 'Alpha (Recommended)', description: 'Use the first result.' }, { label: 'Beta', description: 'Use the second result.' }] }] }, `question_${current.id}`) + } + assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === `call_${current.kind === 'async' ? 'async' : 'question'}_${current.id}`), 'continued model work must receive the real native question result') + if (current.kind === 'async') { + capture.observations.asyncContinuedBeforeAnswer = true + return { text: 'Work continued after the asynchronous question.' } + } + return { text: current.mode === 'plan' ? 'Preface\n\n# Fixture plan\n- Inspect the fixture.\n- Return the result.\n\nPostscript' : 'First fragment and final fragment' } +} + +async function driveQuestions(client, capture, flow) { + capture.observations.questionCases = [] + for (const mode of ['plan', 'default']) { + for (const cancelled of [false, true]) { + const current = { id: `${mode}_${cancelled ? 'cancelled' : 'answered'}`, kind: 'awaited', mode, cancelled, step: 0 } + flow.current = current + const turn = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: `Ask the controlled ${mode} question.` }], collaborationMode: { mode, settings: { model: 'contract-model', reasoning_effort: 'low', developer_instructions: null } } }) + const request = await client.wait(frame => frame.method === 'item/tool/requestUserInput' && frame.params?.turnId === turn.turn.id) + assert.equal(request.params.isBlocking, mode === 'plan') + assert.equal(request.params.questions[0].id, 'fixture_choice') + const responsesBeforeAnswer = current.step + if (cancelled) await client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id }) + else client.send({ id: request.id, result: { answers: { fixture_choice: { answers: ['Alpha (Recommended)'] } } } }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert.equal(terminal.params.turn.status, cancelled ? 'interrupted' : 'completed') + assert.equal(responsesBeforeAnswer, 1, 'both true and false isBlocking requests await their actual reply') + const read = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: current.id, body: read }) + if (mode === 'plan' && !cancelled) { + assert(capture.notifications.some(frame => frame.method === 'item/plan/delta' && frame.params?.turnId === turn.turn.id), 'Plan mode must emit actual native plan deltas') + assert(read.thread.turns.some(nativeTurn => nativeTurn.id === turn.turn.id && nativeTurn.items.some(item => item.type === 'plan'))) + } + capture.observations.questionCases.push({ id: current.id, turnId: turn.turn.id, requestId: request.id, isBlocking: request.params.isBlocking, cancelled }) + } + } + flow.current = { id: 'async', kind: 'async', mode: 'default', step: 0 } + const asynchronous = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Ask the true asynchronous fixture questions and continue work.' }], collaborationMode: { mode: 'default', settings: { model: 'contract-model', reasoning_effort: 'low', developer_instructions: null } } }) + const asyncItem = await client.wait(frame => frame.params?.turnId === asynchronous.turn.id && frame.params.item?.type === 'agentMessage' && frame.params.item.delivery === 'async') + capture.observations.asyncQuestionItem = asyncItem.params.item + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === asynchronous.turn.id) + assert.equal(capture.observations.asyncContinuedBeforeAnswer, true) + assert(!capture.serverRequests.some(frame => frame.method === 'item/tool/requestUserInput' && frame.params?.turnId === asynchronous.turn.id), 'true async questions must not create a pending reply RPC') + flow.current = { id: 'contextual-reply', kind: 'contextual-reply', step: 0 } + const answer = 'Question: Which fixture environment should be used? Answer: Staging. Question: What deadline should be used? Answer: Friday.' + const reply = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: answer }] }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === reply.turn.id) + assert(JSON.stringify(capture.observations.contextualAnswerModelInput).includes(answer), 'ordinary contextual answer must reach the real next model request') + return reply +} + +function mcpResponse(record, flow) { + const current = flow.current + assert(current, 'MCP model work must belong to the selected native case') + if (current.step++ === 0) return advertisedCall(record, 'elicit', { route: current.route, sequence: current.hold ? 'hold' : 'matrix' }, `mcp_${current.id}`) + assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === `call_mcp_${current.id}` && JSON.stringify(item.output).includes('Fixture sequence completed')), 'MCP result must traverse native tools/call and return to the real model request') + return { text: 'First fragment and final fragment' } +} + +async function driveMcp(client, capture, flow, context) { + capture.observations.elicitationCases = [] + let last + for (const route of ['form', 'openai/form', 'openaiForm', 'url']) { + const current = { id: route.replaceAll('/', '_'), route, step: 0 } + flow.current = current + last = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: `Run the controlled ${route} MCP matrix.` }] }) + for (const action of ['accept', 'decline', 'cancel']) { + const already = new Set(capture.observations.elicitationCases.filter(record => record.turnId === last.turn.id).map(record => record.requestId)) + const request = await client.wait(frame => frame.method === 'mcpServer/elicitation/request' && frame.params?.turnId === last.turn.id && !already.has(frame.id)) + assert.equal(request.params.mode, route) + const result = { action, ...(action === 'accept' && route !== 'url' ? { content: MCP_FORM_CONTENT, _meta: { 'fixture/response': 'accepted' } } : {}) } + client.send({ id: request.id, result }) + capture.observations.elicitationCases.push({ route, action, requestId: request.id, turnId: last.turn.id, elicitationId: request.params.elicitationId ?? null }) + } + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === last.turn.id) + assert.equal(terminal.params.turn.status, 'completed') + capture.reads.push({ label: `mcp-${route}`, body: await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) }) + } + const trace = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + capture.observations.mcpTraffic = trace + assert.equal(trace.filter(record => record.event === 'elicitation_result').length, 12) + assert(!trace.some(record => record.event === 'fixture_error'), 'ordinary MCP route must return actual native responses without fixture errors') + const initialized = trace.find(record => record.direction === 'native' && record.frame?.method === 'initialize') + assert(initialized, 'configured MCP server must actually initialize') + capture.observations.mcpInitialize = initialized.frame.params + assert.equal(capture.observations.elicitationCases.length, 12) + return last +} + +export async function captureNativeScenario(name, options = {}) { + assert(scenarios.includes(name), `unknown native scenario ${name}`) + const context = await roots(options) + const capture = { scenario: name, probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + if (options.seccompProbeReceipt) capture.environment.seccompProbe = JSON.parse(await fsp.readFile(options.seccompProbeReceipt, 'utf8')) + if (process.env.FRESHELL_CODEX_CONTRACT_POLICY === 'prepared') { + const declared = nativeSandboxPolicy() + assert.equal(context.staged?.nativeSandboxPolicy?.id, declared.id) + assert.equal(context.staged.nativeSandboxPolicy.profileSha256, declared.profileSha256) + capture.environment.nativeSandboxPolicy = context.staged.nativeSandboxPolicy + } + capture.environment.bwrapCandidates = [] + for (const directory of context.environment.PATH.split(path.delimiter)) { + const candidate = path.join(directory, 'bwrap') + try { + fs.accessSync(candidate, fs.constants.X_OK) + const absolute = fs.realpathSync(candidate) + const version = spawnSync(absolute, ['--version'], { env: context.environment, encoding: 'utf8', timeout: 5000 }) + capture.environment.bwrapCandidates.push({ path: absolute, status: version.status, stdout: version.stdout, stderr: version.stderr }) + } catch {} + } + const readable = file => { try { return fs.readFileSync(file, 'utf8').trim() } catch (error) { return { unavailable: error.code } } } + const status = readable('/proc/self/status') + capture.environment.kernelBoundary = { + appArmorProfile: readable('/proc/self/attr/current'), + status: typeof status === 'string' ? status.split('\n').filter(line => /^(Uid|Gid|Groups|CapInh|CapPrm|CapEff|CapBnd|NoNewPrivs|Seccomp|Seccomp_filters):/.test(line)) : status, + sysctls: Object.fromEntries(['kernel/unprivileged_userns_clone', 'kernel/apparmor_restrict_unprivileged_userns', 'kernel/apparmor_restrict_unprivileged_unconfined', 'user/max_user_namespaces'].map(key => [key, readable(`/proc/sys/${key}`)])), + } + let client + let fixture + let modelStep = 0 + let releaseStream + const childRequested = deferred() + const childStream = deferred() + const parentStream = deferred() + const controlFlow = { current: null } + try { + assert.equal(process.platform, 'linux'); assert.equal(process.arch, 'x64') + const version = spawnSync(capture.binary.path, ['--version'], { env: context.environment, encoding: 'utf8', timeout: 15_000 }) + assert.equal(version.status, 0, version.stderr) + capture.binary.version = version.stdout.trim().replace(/^codex-cli /, '') + assert.equal(capture.binary.version, context.metadata.version) + fixture = await createCodexLocalProvider(async record => { + const step = modelStep++ + if (name === 'plan-question-controls') return questionResponse(record, controlFlow, capture) + if (name === 'mcp-form-url') return mcpResponse(record, controlFlow) + if (name === 'parent-child-read') { + const childInput = record.body.input.some(item => item.role === 'user' && JSON.stringify(item.content).includes('freshell-child-native-probe')) + if (childInput) { + childRequested.resolve() + return { text: 'Exact child final answer.', pauseAfterDelta: () => childStream.promise } + } + if (step === 0) { + const namespace = record.body.tools.find(tool => tool.type === 'namespace' && tool.tools?.some(member => member.name === 'spawn_agent')) + assert(namespace, 'native parent must actually advertise spawn_agent') + capture.observations.advertisedSpawn = namespace.tools.find(member => member.name === 'spawn_agent') + return { items: [{ id: 'fc_native_child', type: 'function_call', call_id: 'call_native_child', namespace: namespace.name, name: 'spawn_agent', arguments: JSON.stringify({ message: 'freshell-child-native-probe: return the controlled child answer.', fork_context: false }), status: 'completed' }] } + } + await parentStream.promise + return { text: 'First fragment and final fragment' } + } + if (name === 'active-input-stop' && step === 0) return { text: 'Interrupted native fragment', pauseAfterDelta: () => new Promise(resolve => { releaseStream = resolve }) } + if (name === 'text-history' && options.duplicateBodies) return { items: [0, 1].map(index => ({ id: `msg_duplicate_${index}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'First fragment and final fragment', annotations: [] }] })) } + const commands = [options.sandboxMode === 'workspace-write' ? 'cat contract-input.txt && printf codex-workspace-write > contract-output.txt' : 'cat contract-input.txt'] + if (options.seccompProbeReceipt) commands[0] += '; sleep 0.3' + if (options.probeNetworkEnabled) { + const script = `import socket; s=socket.create_connection(('127.0.0.1', ${new URL(fixture.baseUrl).port}), timeout=2); s.sendall(b'GET /native-permitted-network-canary HTTP/1.0\\r\\n\\r\\n'); print(s.recv(128))` + commands[0] += `; python3 -c '${script.replaceAll("'", "'\\''")}'` + } + if (options.probePolicyEffects) { + const quote = value => `'${value.replaceAll("'", "'\\''")}'` + const networkScript = `import socket; s=socket.create_connection(('127.0.0.1', ${new URL(fixture.baseUrl).port}), timeout=2); s.sendall(b'GET /native-network-canary HTTP/1.0\\r\\n\\r\\n'); print(s.recv(128))` + commands.push(`printf forbidden-outside > ${quote(path.join(context.stateRoot, 'outside/forbidden.txt'))}`, 'printf forbidden-git > .git/freshell-contract-denied', 'printf forbidden-codex > .codex/freshell-contract-denied', `python3 -c ${quote(networkScript)}`) + if (options.seccompProbeReceipt) commands.push('printf forbidden-agents > .agents/freshell-contract-denied', `printf forbidden-home > ${quote(path.join(context.environment.HOME, 'forbidden.txt'))}`, `printf forbidden-state > ${quote(path.join(context.environment.CODEX_HOME, 'forbidden.txt'))}`, `printf forbidden-tmpdir > ${quote(path.join(context.environment.TMPDIR, 'forbidden.txt'))}`, 'printf forbidden-global-temp > /tmp/freshell-codex-excluded-temp', ...(options.sandboxMode === 'read-only' ? ['printf forbidden-workspace > contract-readonly-forbidden.txt'] : [])) + } + const deniedStep = options.probePolicyEffects && step === commands.length + 1 + if (name === 'approval-policy-restricted-exec' && (step < commands.length || deniedStep)) { + const tools = record.body.tools ?? [] + const tool = tools.find(tool => tool.name === 'exec_command') ?? tools.find(tool => tool.name === 'shell') ?? tools.find(tool => tool.name === 'shell_command') + assert(tool, `native command tool not advertised: ${tools.map(tool => tool.name).join(',')}`) + capture.observations.advertisedCommandTool = tool + const command = deniedStep ? 'printf denied-by-user > denied-by-user.txt' : commands[step] + const args = tool.name === 'exec_command' ? { cmd: command, max_output_tokens: 1000, shell: '/bin/sh', login: false } : tool.name === 'shell' ? { command: ['sh', '-c', command] } : { command } + const callId = step === 0 ? 'call_contract_command' : `call_contract_${step}` + return { items: [{ id: `fc_contract_${step}`, type: 'function_call', call_id: callId, name: tool.name, arguments: JSON.stringify(args), status: 'completed' }] } + } + if (name === 'approval-policy-restricted-exec') assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === 'call_contract_command'), 'follow-up model response requires the actual native command result') + return { text: 'First fragment and final fragment' } + }) + let extraConfig = options.legacyLandlock ? '[features]\nuse_legacy_landlock = true\n' : '' + if (['plan-question-controls', 'mcp-form-url'].includes(name)) { + const catalogPath = path.join(context.stateRoot, 'models.json') + await fsp.writeFile(catalogPath, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Contract model', description: 'Owned loopback native contract', default_reasoning_level: 'low', supported_reasoning_levels: [{ effort: 'low', description: 'Contract effort' }], shell_type: 'disabled', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: "Follow the user's request using the available tools." } }] })) + extraConfig = `model_catalog_json = ${JSON.stringify(catalogPath)}\nweb_search = "disabled"\n[features]\napps = false\ndefault_mode_request_user_input = true\nauth_elicitation = true\nnon_prefixed_mcp_tool_names = false\n[tools.experimental_request_user_input]\nenabled = true\n` + if (name === 'mcp-form-url') { + context.mcpTrace = path.join(context.stateRoot, 'mcp-traffic.jsonl') + await fsp.writeFile(context.mcpTrace, '', { mode: 0o600 }) + extraConfig += `\n[mcp_servers.contract]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([path.join(repoRoot, 'test/integration/providers/codex-local-provider.mjs'), '--mcp-fixture', context.mcpTrace, fixture.baseUrl.replace(/\/v1$/, '')])}\nenabled = true\nrequired = true\nstartup_readiness = "connection"\nstartup_timeout_sec = 15\ntool_timeout_sec = 120\n` + } + } + await configure(context, fixture.baseUrl, extraConfig) + const restricted = name === 'approval-policy-restricted-exec' + if (restricted) { + await fsp.writeFile(path.join(context.workspace, 'contract-input.txt'), 'codex-restricted-command-ran\n') + if (options.probePolicyEffects) { + const initialized = spawnSync('git', ['init', '--quiet'], { cwd: context.workspace, env: context.environment, encoding: 'utf8', timeout: 15_000 }) + assert.equal(initialized.status, 0, initialized.stderr) + await fsp.mkdir(path.join(context.workspace, '.codex')) + await fsp.mkdir(path.join(context.workspace, '.agents')) + await fsp.mkdir(path.join(context.stateRoot, 'outside'), { mode: 0o700 }) + capture.observations.gitCommonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: context.workspace, env: context.environment, encoding: 'utf8', timeout: 15_000 }).stdout.trim() + if (options.seccompProbeReceipt) { + const mountRoot = path.join(context.stateRoot, 'raw-mount-attempt') + await fsp.mkdir(mountRoot) + const mounted = spawnSync('mount', ['-t', 'tmpfs', 'tmpfs', mountRoot], { env: context.environment, encoding: 'utf8', timeout: 15_000 }) + capture.observations.outerMountAttempt = { status: mounted.status, stderr: mounted.stderr, stdout: mounted.stdout, error: mounted.error?.message } + assert.notEqual(mounted.status, 0, 'outer mount without a user namespace must remain denied') + } + } + if (!options.probeMetadata) { + await configure(context, fixture.baseUrl, `approval_policy = "untrusted"\n${extraConfig}`) + const rejection = spawnSync(capture.binary.path, ['exec', '--skip-git-repo-check', '--json', 'Do not perform work; public config rejection probe.'], { env: context.environment, cwd: context.workspace, encoding: 'utf8', timeout: 15_000 }) + capture.observations.publicUntrustedRejection = { status: rejection.status, stdout: rejection.stdout, stderr: rejection.stderr, fixtureRequests: fixture.traffic.length } + assert.notEqual(rejection.status, 0) + assert.match(rejection.stderr, /approval_policy\s*=\s*"untrusted" is no longer supported/) + assert.equal(fixture.traffic.length, 0, 'public config rejection must precede model work') + } + // Only this owned fixture configuration is rewritten. Production source + // projection is a later gate, and is never claimed by this native probe. + await configure(context, fixture.baseUrl, extraConfig) + } + client = new NativeClient(capture.binary.path, context, capture) + client.onServerRequest = frame => { + if (frame.method === 'item/commandExecution/requestApproval') { + const decision = frame.params.command?.includes('denied-by-user') ? 'cancel' : 'accept' + const approval = { id: frame.id, params: frame.params, decision } + capture.observations.commandApproval ??= approval + ;(capture.observations.commandApprovals ??= []).push(approval) + client.send({ id: frame.id, result: { decision } }) + } + } + const capabilities = { experimentalApi: true, ...(name === 'mcp-form-url' ? { extensions: { 'openai/form': {}, 'openai/elicitation': { form: {} }, 'openai/standard-form-input': {} } } : {}) } + capture.observations.initialize = await client.request('initialize', { clientInfo: { name: 'freshell_codex_contract', title: 'Freshell isolated native contract', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + capture.observations.effectiveConfig = await client.request('config/read', { includeLayers: true }) + if (name === 'model-effort-usage') { + capture.observations.models = await client.request('model/list', { includeHidden: false, limit: 100 }) + assert(capture.observations.models.data.length > 0, 'release model discovery must return actual metadata') + } + const policy = options.probeApprovalPolicy ?? (restricted ? 'untrusted' : name === 'mcp-form-url' ? 'on-request' : 'never') + const sandboxMode = options.sandboxMode ?? 'read-only' + const started = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: policy, sandbox: sandboxMode, experimentalRawEvents: false, persistExtendedHistory: true, ephemeral: options.ephemeralParent ?? false, ...(options.historyMode ? { historyMode: options.historyMode } : {}) }) + capture.threadId = started.thread.id + capture.observations.threadStart = started + assert.equal(started.approvalPolicy, policy, 'typed native policy must remain effective') + const sandboxPolicy = sandboxMode === 'workspace-write' ? { type: 'workspaceWrite', writableRoots: [context.workspace], networkAccess: options.probeNetworkEnabled ?? false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly', networkAccess: options.probeNetworkEnabled ?? false } + let turn = name === 'plan-question-controls' ? await driveQuestions(client, capture, controlFlow) : name === 'mcp-form-url' ? await driveMcp(client, capture, controlFlow, context) : await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(restricted && !options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the controlled contract scenario.' }] }) + capture.observations.startedTurnId = turn.turn.id + if (name === 'parent-child-read') { + const spawned = await client.wait(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'collabAgentToolCall' && frame.params.item.receiverThreadIds?.length > 0) + const childId = spawned.params.item.receiverThreadIds[0] + capture.observations.childId = childId + await deadline(childRequested.promise, 30_000, 'actual child model request') + const activeMetadata = await client.request('thread/read', { threadId: childId, includeTurns: false }) + capture.reads.push({ label: 'child-active-metadata', body: activeMetadata }) + assert.equal(activeMetadata.thread.parentThreadId, capture.threadId, 'read child must carry the exact native parent relationship') + assert.equal(activeMetadata.thread.ephemeral, options.ephemeralParent ?? false) + if (options.ephemeralParent) { + capture.observations.ephemeralChildReadRefusal = await nativeRefusal(client, 'thread/read', { threadId: childId, includeTurns: true }) + assert.match(capture.observations.ephemeralChildReadRefusal.message, /ephemeral/i) + capture.observations.childAvailability = 'ephemeral_history_unavailable' + } else { + const activeRead = await client.request('thread/read', { threadId: childId, includeTurns: true }) + capture.reads.push({ label: 'child-active-history', body: activeRead }) + } + childStream.resolve() + const childSettled = await deadline((async () => { + for (;;) { + const body = await client.request('thread/read', { threadId: childId, includeTurns: !options.ephemeralParent }) + if (options.ephemeralParent ? body.thread.status.type === 'idle' : assistantText(body) === 'Exact child final answer.') return body + await new Promise(resolve => setTimeout(resolve, 25)) + } + })(), 30_000, 'child native completion/saving') + capture.reads.push({ label: 'child-settled', body: childSettled }) + capture.observations.childFinalText = options.ephemeralParent ? null : assistantText(childSettled) + parentStream.resolve() + } + if (name === 'active-input-stop') { + const firstTurnId = turn.turn.id + const delta = await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === firstTurnId) + assert(delta.params.delta.length > 0, 'interruption must follow actual nonempty native output') + capture.observations.mismatchedSteer = await nativeRefusal(client, 'turn/steer', { threadId: capture.threadId, expectedTurnId: 'not-the-active-turn', input: [{ type: 'text', text: 'Must be refused.' }] }) + const steered = await client.request('turn/steer', { threadId: capture.threadId, expectedTurnId: firstTurnId, input: [{ type: 'text', text: 'Controlled same-turn steering input.' }] }) + assert.equal(steered.turnId, firstTurnId) + capture.observations.acceptedSteer = steered + capture.observations.stopAcknowledgement = await client.request('turn/interrupt', { threadId: capture.threadId, turnId: firstTurnId }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === firstTurnId) + capture.observations.interruptedTurn = terminal.params.turn + assert.equal(terminal.params.turn.status, 'interrupted') + capture.observations.retainedPartial = capture.notifications.filter(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === firstTurnId).map(frame => frame.params.delta).join('') + assert.equal(capture.observations.retainedPartial, 'Interrupted native fragment') + const interruptedRead = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'interrupted-warm-native-read', body: interruptedRead }) + capture.observations.idleSteer = await nativeRefusal(client, 'turn/steer', { threadId: capture.threadId, expectedTurnId: firstTurnId, input: [{ type: 'text', text: 'No active native turn; must be refused.' }] }) + releaseStream?.() + turn = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'New work after resolved Stop.' }] }) + assert.notEqual(turn.turn.id, firstTurnId) + capture.observations.followingTurnId = turn.turn.id + } + const completed = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id, options.timeoutMs ?? 120_000) + capture.observations.completedTurn = completed.params.turn + if (name === 'parent-child-read' && options.ephemeralParent) { + capture.observations.ephemeralParentReadRefusal = await nativeRefusal(client, 'thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.observations.childObserverMethods = capture.requests.filter(frame => frame.params.threadId === capture.observations.childId).map(frame => frame.method) + assert(capture.observations.childObserverMethods.every(method => method === 'thread/read')) + capture.passed = true + return capture + } + const read = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'completed', body: read }) + capture.observations.finalAssistantText = assistantText(read, turn.turn.id) + const parentCompletedMessages = name === 'parent-child-read' ? capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'agentMessage').map(frame => frame.params.item.text) : null + if (parentCompletedMessages) { + assert(parentCompletedMessages.length > 0) + assert(parentCompletedMessages.every(text => text === 'First fragment and final fragment')) + capture.observations.parentCompletedMessages = parentCompletedMessages + } + const expectedAssistantText = parentCompletedMessages?.join('\n') ?? Array(options.duplicateBodies ? 2 : 1).fill('First fragment and final fragment').join('\n') + assert.equal(capture.observations.finalAssistantText, expectedAssistantText) + capture.observations.finalItems = read.thread.turns.filter(nativeTurn => nativeTurn.id === turn.turn.id).flatMap(nativeTurn => nativeTurn.items) + if (name === 'model-effort-usage') { + const usage = capture.notifications.filter(frame => frame.method === 'thread/tokenUsage/updated' && frame.params?.turnId === turn.turn.id).at(-1)?.params.tokenUsage + assert.equal(usage?.last?.totalTokens, 15, 'usage must come from the actual model completion') + capture.observations.latestUsage = usage.last + capture.observations.usageLabel = 'Latest usage' + assert.equal(fixture.traffic[0].body.model, 'contract-model') + assert.equal(fixture.traffic[0].body.reasoning.effort, 'low') + } + if (name === 'approval-policy-restricted-exec') { + const commandItems = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'commandExecution').map(frame => frame.params.item) + capture.observations.commandItems = commandItems + capture.observations.workspaceWritePresent = fs.existsSync(path.join(context.workspace, 'contract-output.txt')) + if (policy === 'untrusted') assert(capture.observations.commandApproval, 'untrusted command must produce an actual approval request') + capture.observations.restrictedCommandExecuted = commandItems.some(item => item.exitCode === 0 && item.aggregatedOutput?.includes('codex-restricted-command-ran')) + if (options.probeNetworkEnabled) { + capture.observations.permittedNetworkRequests = fixture.traffic.filter(request => request.path === '/native-permitted-network-canary').length + assert.equal(capture.observations.permittedNetworkRequests, 1, 'native network-enabled policy must actually reach the parent loopback canary') + } + if (options.probePolicyEffects) { + capture.observations.policyEffects = { + outsideWrite: fs.existsSync(path.join(context.stateRoot, 'outside/forbidden.txt')), + gitWrite: fs.existsSync(path.join(context.workspace, '.git/freshell-contract-denied')), + codexWrite: fs.existsSync(path.join(context.workspace, '.codex/freshell-contract-denied')), + networkRequests: fixture.traffic.filter(request => request.path === '/native-network-canary').length, + } + if (options.seccompProbeReceipt) Object.assign(capture.observations.policyEffects, { + agentsWrite: fs.existsSync(path.join(context.workspace, '.agents/freshell-contract-denied')), + homeWrite: fs.existsSync(path.join(context.environment.HOME, 'forbidden.txt')), + stateWrite: fs.existsSync(path.join(context.environment.CODEX_HOME, 'forbidden.txt')), + excludedTmpdirWrite: fs.existsSync(path.join(context.environment.TMPDIR, 'forbidden.txt')), + excludedGlobalTempWrite: fs.existsSync('/tmp/freshell-codex-excluded-temp'), + ...(sandboxMode === 'read-only' ? { workspaceWrite: fs.existsSync(path.join(context.workspace, 'contract-readonly-forbidden.txt')) } : {}), + }) + if (policy === 'untrusted') { + const denial = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(!options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the separate user-denied command probe.' }] }) + capture.observations.deniedTurn = (await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === denial.turn.id, options.timeoutMs ?? 120_000)).params.turn + capture.observations.policyEffects.userDeniedWrite = fs.existsSync(path.join(context.workspace, 'denied-by-user.txt')) + } + } + } else if (!['text-history', 'active-input-stop', 'model-effort-usage', 'parent-child-read', 'plan-question-controls', 'mcp-form-url'].includes(name)) { + throw new Error(`CODEX_CONTRACT_UNIMPLEMENTED: ${name}; text-only execution does not establish this scenario`) + } + await client.close() + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_contract_resume', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + const resumedThread = await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, persistExtendedHistory: true }) + capture.observations.resumeApprovalPolicy = resumedThread.approvalPolicy + assert.equal(resumedThread.approvalPolicy, policy) + const resumed = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'cold-resume', body: resumed }) + capture.observations.resumedAssistantText = assistantText(resumed, turn.turn.id) + assert.equal(capture.observations.resumedAssistantText, capture.observations.finalAssistantText) + if (name === 'parent-child-read') { + const savedChild = await client.request('thread/read', { threadId: capture.observations.childId, includeTurns: true }) + capture.reads.push({ label: 'child-cold-saved-history', body: savedChild }) + assert.equal(savedChild.thread.parentThreadId, capture.threadId) + assert.equal(assistantText(savedChild), 'Exact child final answer.') + capture.observations.childObserverMethods = capture.requests.filter(frame => frame.params.threadId === capture.observations.childId).map(frame => frame.method) + assert(capture.observations.childObserverMethods.every(method => method === 'thread/read'), 'observer must not create/resume/send/stop/configure the child') + const pending = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: 'never', sandbox: 'read-only', ephemeral: false }) + capture.observations.unmaterializedRead = await nativeRefusal(client, 'thread/read', { threadId: pending.thread.id, includeTurns: true }) + } + if (restricted) { + const fork = await client.request('thread/fork', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, ephemeral: false, persistExtendedHistory: true }) + capture.observations.fork = { threadId: fork.thread.id, approvalPolicy: fork.approvalPolicy, sandbox: fork.sandbox } + assert.equal(fork.approvalPolicy, policy) + assert(capture.observations.restrictedCommandExecuted, `restricted command did not execute: ${JSON.stringify(capture.observations.commandItems)}`) + if (sandboxMode === 'workspace-write') assert(capture.observations.workspaceWritePresent, 'permitted workspace write must actually exist') + if (options.probePolicyEffects) { + // Native untrusted acceptance can authorize retry without sandboxing. + // Only never-policy isolates filesystem/network enforcement; a real + // user cancellation remains an absence-of-effect check for untrusted. + if (policy === 'never') for (const [effect, value] of Object.entries(capture.observations.policyEffects)) assert.equal(value, effect === 'networkRequests' ? 0 : false, `restricted policy allowed forbidden effect: ${effect}`) + if (policy === 'untrusted') assert.equal(capture.observations.policyEffects.userDeniedWrite, false, 'native-cancelled command must have no effect') + if (policy === 'untrusted') assert(capture.observations.commandApprovals.some(approval => approval.decision === 'cancel'), 'denied command must receive an actual native cancellation') + } + } + capture.passed = true + } catch (error) { + capture.passed = false + capture.failure = { message: error.message, nativeError: error.nativeError } + error.capture = capture + throw error + } finally { + if (client) await client.close() + releaseStream?.() + childStream.resolve() + parentStream.resolve() + if (fixture) { capture.fixtureTraffic = fixture.traffic; await fixture.close() } + if (context.mcpTrace && fs.existsSync(context.mcpTrace)) capture.observations.mcpTraffic = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + capture.environment.ambientAfter = ambientCodex() + assert.equal(capture.environment.ambientAfter, capture.environment.ambientBefore) + await fsp.writeFile(path.join(context.receiptRoot, `${name}-${randomUUID()}.json`), `${JSON.stringify(capture, null, 2)}\n`, { mode: 0o600 }) + if (options.emitReceipt) process.stdout.write(`${JSON.stringify({ nativeCapture: true, capture })}\n`) + } + return capture +} + +async function managedVersion(options) { + const { register } = await import('tsx/esm/api') + const unregister = register() + const { RuntimeHarness } = await import('../../../scripts/testing/runtime-sandbox.ts') + const harness = new RuntimeHarness(repoRoot, `codex-contract-${randomUUID()}`, 3) + const receiptDir = path.resolve(options.receiptDir ?? path.join(repoRoot, '.runtime-build/codex-contract/managed')) + await fsp.mkdir(receiptDir, { recursive: true, mode: 0o700 }) + await fsp.mkdir(harness.evidenceDir, { recursive: true, mode: 0o700 }) + try { + // Existing exact run-owned image seam; no supervisor or broker needed for + // this initial version gate, and no global tag is adopted or overwritten. + if (options.probeSeccomp && options.probeUid === '1000:1000') { + const prerequisite = await verifySandbox() + harness.imageRef = prerequisite.manifest.image + } else harness.ensureRuntimeImage() + let output + if (options.probeUid !== '1000:1000') { + output = spawnSync('docker', ['run', '--rm', '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '65534:0', '--tmpfs', '/tmp:rw,nosuid,nodev,size=64m', harness.imageRef, '/usr/local/bin/codex', '--version'], { encoding: 'utf8', timeout: 30_000 }) + const receipt = { imageDigest: harness.imageRef, architecture: process.arch, uid: 65534, gid: 0, command: '/usr/local/bin/codex --version', status: output.status, stdout: output.stdout, stderr: output.stderr, requiredVersion: artifactMetadata.version } + await fsp.writeFile(path.join(receiptDir, 'binary.json'), `${JSON.stringify(receipt, null, 2)}\n`) + assert.equal(output.status, 0, output.stderr) + } + if (options.providerIdentityProbe) { + const artifactRoot = options.probeArtifactRoot ?? path.join(repoRoot, '.runtime-build/codex-contract/artifact') + await verifyArtifact(artifactRoot, options.probeMetadata ?? artifactMetadata) + const name = `freshcodex-provider-probe-${randomUUID()}` + const command = ['node', '/contract/test/integration/providers/codex-cli-contract.mjs', '--scenario', 'approval-policy-restricted-exec', '--receipt-dir', '/tmp/native-receipts', '--sandbox-mode', options.sandboxMode ?? 'read-only', '--emit-receipt'] + if (options.probeMetadataPath) command.push('--probe-metadata', `/contract/${path.relative(repoRoot, options.probeMetadataPath)}`) + if (options.legacyLandlock) command.push('--legacy-landlock') + if (options.probePolicyEffects) command.push('--probe-policy-effects') + if (options.probeApprovalPolicy) command.push('--probe-approval-policy', options.probeApprovalPolicy) + if (options.probeDefaultPolicy) command.push('--probe-default-policy') + if (options.probePolicyEffects) command.push('--state-root', '/probe-state') + if (options.probeNetworkEnabled) command.push('--probe-network-enabled', '--state-root', '/probe-state') + if (options.probeSeccomp) command.push('--probe-seccomp-receipt', `/contract/${path.relative(repoRoot, path.join(path.dirname(options.probeSeccomp), 'receipt.json'))}`) + if (options.preferBundledBwrap) command.push('--probe-bundled-bwrap') + const identity = options.probeUid ?? '65534:0' + assert(['1000:1000', '65534:0'].includes(identity), 'only the two declared native probe identities are allowed') + const [uid, gid] = identity.split(':') + const createArgs = ['create', '--name', name, '--label', `org.freshell.codex-contract=${name}`, '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', ...(options.probeSeccomp ? ['--security-opt', `seccomp=${options.probeSeccomp}`] : []), '--user', identity, '--pids-limit', '512', '--memory', '1g', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777', '--tmpfs', `/probe-state:rw,nosuid,nodev,size=128m,mode=700,uid=${uid},gid=${gid}`, '--mount', `type=bind,source=${repoRoot},target=/contract,readonly`, '--mount', `type=bind,source=${artifactRoot},target=/opt/freshell-codex-contract,readonly`, '--env', 'FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-codex-contract', '--entrypoint', '/usr/local/bin/node', harness.imageRef, ...command.slice(1)] + const created = spawnSync('docker', createArgs, { encoding: 'utf8', timeout: 30_000 }) + assert.equal(created.status, 0, created.stderr) + const id = created.stdout.trim() + assert.match(id, /^[a-f0-9]{64}$/) + try { + const inspection = spawnSync('docker', ['inspect', id], { encoding: 'utf8', timeout: 15_000 }) + assert.equal(inspection.status, 0, inspection.stderr) + const observed = JSON.parse(inspection.stdout)[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], name) + await fsp.writeFile(path.join(receiptDir, 'container.json'), JSON.stringify({ id, image: observed.Image, appArmorProfile: observed.AppArmorProfile, config: observed.Config, hostConfig: observed.HostConfig }, null, 2)) + const result = spawnSync('docker', ['start', '--attach', id], { encoding: 'utf8', timeout: 180_000, maxBuffer: 32 * 1024 * 1024 }) + await fsp.writeFile(path.join(receiptDir, 'execution.json'), JSON.stringify({ status: result.status, stderr: result.stderr, error: result.error?.message }, null, 2)) + const captured = result.stdout.split('\n').filter(Boolean).map(line => JSON.parse(line)).find(frame => frame.nativeCapture === true)?.capture + assert(captured, 'provider-identity probe must preserve an actual native receipt') + await fsp.writeFile(path.join(receiptDir, 'native.json'), JSON.stringify(captured, null, 2), { mode: 0o600 }) + assert.equal(result.status, 0, result.stderr) + } finally { + const removal = spawnSync('docker', ['rm', '--force', id], { encoding: 'utf8', timeout: 30_000 }) + assert.equal(removal.status, 0, removal.stderr) + } + return + } + assert.equal(output.stdout.trim(), `codex-cli ${artifactMetadata.version}`, 'managed runtime must execute the required Codex release') + throw new Error('CODEX_CONTRACT_UNIMPLEMENTED: managed native scenarios have not run; a version receipt is insufficient') + } finally { const cleanup = await harness.cleanup(); unregister(); assert.equal(cleanup.ok, true, JSON.stringify(cleanup.errors)) } +} + +async function main(args) { + let scenario = 'all' + const options = {} + let managed = false + for (let index = 0; index < args.length; index++) { + if (args[index] === '--scenario') scenario = args[++index] + else if (args[index] === '--receipt-dir') options.receiptDir = path.resolve(args[++index]) + else if (args[index] === '--managed-runtime') managed = true + else if (args[index] === '--sandbox-mode') options.sandboxMode = args[++index] + else if (args[index] === '--provider-identity-probe') { managed = true; options.providerIdentityProbe = true } + else if (args[index] === '--emit-receipt') options.emitReceipt = true + else if (args[index] === '--probe-metadata') { options.probeMetadataPath = path.resolve(args[++index]); options.probeMetadata = JSON.parse(await fsp.readFile(options.probeMetadataPath, 'utf8')); assert.equal(options.probeMetadata.probeOnly, true) } + else if (args[index] === '--probe-artifact-root') options.probeArtifactRoot = path.resolve(args[++index]) + else if (args[index] === '--legacy-landlock') options.legacyLandlock = true + else if (args[index] === '--probe-policy-effects') options.probePolicyEffects = true + else if (args[index] === '--probe-approval-policy') options.probeApprovalPolicy = args[++index] + else if (args[index] === '--probe-default-policy') options.probeDefaultPolicy = true + else if (args[index] === '--state-root') options.stateRoot = path.resolve(args[++index]) + else if (args[index] === '--probe-seccomp') options.probeSeccomp = path.resolve(args[++index]) + else if (args[index] === '--probe-seccomp-receipt') options.seccompProbeReceipt = path.resolve(args[++index]) + else if (args[index] === '--probe-uid') options.probeUid = args[++index] + else if (args[index] === '--probe-bundled-bwrap') options.preferBundledBwrap = true + else if (args[index] === '--history-mode') { options.historyMode = args[++index]; assert(['legacy', 'paginated'].includes(options.historyMode)) } + else if (args[index] === '--duplicate-bodies') options.duplicateBodies = true + else if (args[index] === '--ephemeral-parent') options.ephemeralParent = true + else if (args[index] === '--probe-network-enabled') options.probeNetworkEnabled = true + else throw new Error(`unknown argument ${args[index]}`) + } + if (options.probePolicyEffects || options.probeApprovalPolicy || options.probeDefaultPolicy || options.legacyLandlock) assert(options.probeMetadata || options.probeSeccomp || options.seccompProbeReceipt, 'probe options require an explicit candidate manifest or declared seccomp probe') + if (managed) return managedVersion(options) + for (const name of scenario === 'all' ? scenarios : [scenario]) { + const capture = await captureNativeScenario(name, options) + console.log(JSON.stringify({ severity: 'info', event: 'codex_native_scenario_passed', scenario: name, version: capture.binary.version, threadId: capture.threadId })) + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2)).catch(error => { console.error(JSON.stringify({ severity: 'error', event: 'codex_native_contract_failed', message: error.message })); process.exitCode = 1 }) diff --git a/test/integration/providers/codex-cli-contract.test.ts b/test/integration/providers/codex-cli-contract.test.ts new file mode 100644 index 000000000..173b6f74e --- /dev/null +++ b/test/integration/providers/codex-cli-contract.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +// @ts-expect-error Native executable harness is shared with the offline runner. +import { captureNativeScenario } from './codex-cli-contract.mjs' + +describe('Codex 0.164.0 release contract through an isolated Responses provider', () => { + it.each(['legacy', 'paginated'])('retains the actual final answer through %s history and a cold resume', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, timeoutMs: 120_000 }) + expect(capture.binary.version).toBe('0.164.0') + expect(capture.observations.finalAssistantText).toBe('First fragment and final fragment') + expect(capture.observations.resumedAssistantText).toBe(capture.observations.finalAssistantText) + expect(capture.notifications.some((frame: any) => frame.method === 'item/agentMessage/delta')).toBe(true) + expect(capture.fixtureTraffic.length).toBeGreaterThan(0) + expect(capture.fixtureTraffic.every((request: any) => request.loopback)).toBe(true) + }, 180_000) + + it.each(['legacy', 'paginated'])('preserves duplicate equal assistant bodies in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, duplicateBodies: true, timeoutMs: 120_000 }) + expect(capture.observations.finalAssistantText).toBe('First fragment and final fragment\nFirst fragment and final fragment') + expect(capture.observations.resumedAssistantText).toBe(capture.observations.finalAssistantText) + }, 180_000) + + it.each(['active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'])( + 'executes the complete native %s scenario', async scenario => { + const capture = await captureNativeScenario(scenario, { timeoutMs: 120_000 }) + expect(capture.passed).toBe(true) + expect(capture.fixtureTraffic.length).toBeGreaterThan(0) + }, 180_000, + ) +}) diff --git a/test/integration/providers/codex-local-provider.mjs b/test/integration/providers/codex-local-provider.mjs new file mode 100644 index 000000000..7ccafd936 --- /dev/null +++ b/test/integration/providers/codex-local-provider.mjs @@ -0,0 +1,121 @@ +// Model-wire fixture only: all app-server events originate in the release CLI. +import http from 'node:http' +import fs from 'node:fs' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import assert from 'node:assert/strict' + +export const MCP_FORM_CONTENT = { name: 'fixture', count: 2.5, enabled: true, color: 'blue' } +const FORM_SCHEMA = { type: 'object', properties: { name: { type: 'string', title: 'Name' }, count: { type: 'number', title: 'Count' }, enabled: { type: 'boolean', title: 'Enabled' }, color: { type: 'string', title: 'Color', enum: ['blue', 'green'] } }, required: ['name', 'count', 'enabled', 'color'] } + +function serveMcpFixture(tracePath, url) { + const pending = new Map() + let nextId = 1 + const trace = record => fs.appendFileSync(tracePath, `${JSON.stringify(record)}\n`, { mode: 0o600 }) + const send = frame => { trace({ direction: 'server', frame }); process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`) } + const request = (method, params) => { + const number = nextId++ + const id = number % 2 ? number : `mcp-${number}` + return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ id, method, params }) }) + } + const handle = async frame => { + trace({ direction: 'native', frame }) + if (!frame.method && frame.id !== undefined) { + const waiter = pending.get(frame.id) + if (waiter) { pending.delete(frame.id); if (frame.error) waiter.reject(new Error(frame.error.message)); else waiter.resolve(frame.result) } + return + } + if (frame.method === 'notifications/cancelled') { + for (const [id, waiter] of pending) { pending.delete(id); waiter.resolve({ action: 'cancel', cancelledByNative: true }) } + return + } + if (frame.id === undefined) return + if (frame.method === 'initialize') send({ id: frame.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'freshell-contract', version: '1' } } }) + else if (frame.method === 'ping') send({ id: frame.id, result: {} }) + else if (frame.method === 'tools/list') send({ id: frame.id, result: { tools: [{ name: 'elicit', description: 'Run the selected native elicitation fixture.', inputSchema: { type: 'object', properties: { route: { type: 'string', enum: ['form', 'openai/form', 'openaiForm', 'url'] }, sequence: { type: 'string', enum: ['matrix', 'hold'] } }, required: ['route', 'sequence'], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }] } }) + else if (frame.method === 'tools/call') { + const { route, sequence } = frame.params.arguments + const results = [] + for (const action of sequence === 'hold' ? ['cancel'] : ['accept', 'decline', 'cancel']) { + const method = route === 'openai/form' ? 'openai/form' : route === 'openaiForm' ? 'openai/elicitation/create' : 'elicitation/create' + const params = route === 'url' ? { mode: 'url', message: 'Open the fixture page, then choose an action.', url: `${url}/elicitation/example`, elicitationId: `fixture-url-${nextId}` } : { ...(route === 'openai/form' ? {} : { mode: 'form' }), message: 'Fixture form', requestedSchema: FORM_SCHEMA } + const result = await request(method, params) + trace({ event: 'elicitation_result', route, expectedAction: action, result }) + if (result.action !== action) throw new Error(`MCP route ${route} returned the wrong action`) + if (action === 'accept' && route !== 'url') assert.deepEqual(result.content, MCP_FORM_CONTENT, 'MCP form content must preserve typed native answers') + results.push(result) + } + send({ id: frame.id, result: { content: [{ type: 'text', text: 'Fixture sequence completed' }], isError: false } }) + } else send({ id: frame.id, error: { code: -32601, message: 'Unsupported fixture method' } }) + } + createInterface({ input: process.stdin }).on('line', line => { + let frame + try { frame = JSON.parse(line) } catch { return } + handle(frame).catch(error => { + trace({ event: 'fixture_error', message: error.message }) + if (frame.id !== undefined) send({ id: frame.id, error: { code: -32603, message: error.message } }) + }) + }) +} + +export async function createCodexLocalProvider(handler) { + const traffic = [] + let sequence = 0 + const sockets = new Set() + const server = http.createServer(async (request, response) => { + try { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const body = Buffer.concat(chunks).toString('utf8') + const parsed = body ? JSON.parse(body) : null + const record = { index: traffic.length, method: request.method, path: request.url, loopback: request.socket.remoteAddress === '127.0.0.1', body: parsed } + traffic.push(record) + if (request.method !== 'POST' || request.url !== '/v1/responses' || !record.loopback) { + response.writeHead(404).end(); return + } + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) + const emit = (type, fields = {}) => response.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`) + const id = `resp_contract_${record.index}` + const base = { id, object: 'response', created_at: 1, status: 'in_progress', output: [] } + emit('response.created', { response: base }) + const plan = await handler(record, { emit, response }) + if (!plan || response.destroyed || response.writableEnded) return + const items = plan.items ?? [{ id: `msg_contract_${record.index}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: plan.text ?? 'First fragment and final fragment', annotations: [] }] }] + for (let outputIndex = 0; outputIndex < items.length; outputIndex++) { + const item = items[outputIndex] + emit('response.output_item.added', { output_index: outputIndex, item: item.type === 'message' ? { ...item, status: 'in_progress', content: [] } : { ...item, arguments: '' } }) + if (item.type === 'message') { + for (let contentIndex = 0; contentIndex < item.content.length; contentIndex++) { + const content = item.content[contentIndex] + emit('response.content_part.added', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, part: { ...content, text: '' } }) + for (const delta of plan.deltas ?? [content.text.slice(0, 14), content.text.slice(14)]) { + emit('response.output_text.delta', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, delta }) + } + if (plan.pauseAfterDelta) await plan.pauseAfterDelta() + if (response.destroyed) return + emit('response.output_text.done', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, text: content.text }) + emit('response.content_part.done', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, part: content }) + } + } else if (item.type === 'function_call') { + emit('response.function_call_arguments.delta', { item_id: item.id, output_index: outputIndex, delta: item.arguments }) + emit('response.function_call_arguments.done', { item_id: item.id, output_index: outputIndex, arguments: item.arguments }) + } + emit('response.output_item.done', { output_index: outputIndex, item }) + } + emit('response.completed', { response: { ...base, status: 'completed', output: items, usage: { input_tokens: 10, input_tokens_details: { cached_tokens: 0 }, output_tokens: 5, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 15 } } }) + response.end() + } catch (error) { + if (!response.headersSent) response.writeHead(500) + response.end(JSON.stringify({ error: { message: error.message } })) + } + }) + server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)) }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + return { + baseUrl: `http://127.0.0.1:${server.address().port}/v1`, traffic, + async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)) }, + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--mcp-fixture') serveMcpFixture(process.argv[3], process.argv[4]) From 41fb6d8a8248b4652923ec40429f1b88cc19c993 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:28:47 -0700 Subject: [PATCH 04/92] test(codex): capture native approvals, races and persistence limits --- docker/cloud-run/Dockerfile | 5 + scripts/sandbox-test.sh | 15 + scripts/testing/prepare-codex-contract.mjs | 28 +- .../providers/codex-cli-contract.mjs | 570 +++++++++++++++++- .../providers/codex-cli-contract.test.ts | 43 ++ .../providers/codex-local-provider.mjs | 16 +- 6 files changed, 638 insertions(+), 39 deletions(-) diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index e42759c12..e4fcc314a 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -126,6 +126,10 @@ RUN cargo --version && rustc --version # overwrite the staged artifacts. COPY . . +# This controlled test-only requirements layer enables the native network +# approval controller. No production runtime image receives it. +RUN node --input-type=module -e 'import fs from "node:fs"; import { CODEX_NETWORK_REQUIREMENTS } from "./test/integration/providers/codex-local-provider.mjs"; fs.mkdirSync("/etc/codex", { recursive: true }); fs.writeFileSync("/etc/codex/requirements.toml", CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 })' + # Check the assembled runtime tree after all staged artifacts are present. # With --runtime-root the guard checks the required Rust/client/tools files and # executable, scans only shipped dist/, target/, and node_modules/ roots, @@ -135,6 +139,7 @@ RUN scripts/verify-container-layout.sh --fixture /app --runtime-root ENV FRESHELL_E2E_RUST_SERVER_BIN=/app/target/release/freshell-server ENV FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64 +ENV FRESHELL_CODEX_CONTRACT_CAPTURE_STDOUT=1 # COPY's requested mode can still be narrowed by rootless build/ACL metadata. # Normalize the copied artifact in the image layer so the final unprivileged diff --git a/scripts/sandbox-test.sh b/scripts/sandbox-test.sh index fd86aec7d..a779ad62a 100755 --- a/scripts/sandbox-test.sh +++ b/scripts/sandbox-test.sh @@ -140,14 +140,29 @@ else if [ "${RUNTIME_SUITE}" -eq 1 ]; then case "${CMD}" in 'node test/integration/providers/codex-cli-contract.mjs --scenario all' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url --history-mode paginated --large-output' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url --history-mode legacy --large-output' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario active-input-stop' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario active-input-stop --startup-cancellation' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec --approval-control stdin' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec --approval-control network' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --history-mode paginated --persistence-mode delayed' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --history-mode legacy --persistence-mode delayed' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --history-mode legacy --persistence-mode missing' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --history-mode paginated --persistence-mode missing' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec') node "${REPO_ROOT}/scripts/testing/prepare-codex-contract.mjs" --verify-sandbox >/dev/null CODEX_NATIVE_CONTRACT=1 + IMAGE_TAG="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).image)' "${REPO_ROOT}/.runtime-build/codex-contract/staged.json")" ;; esac fi if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + if [ "${CODEX_NATIVE_CONTRACT}" -eq 1 ]; then + echo '[sandbox] prepared Codex contract image is unavailable; connected preparation is required' >&2 + exit 2 + fi echo "[sandbox] image ${IMAGE_TAG} not found, building it first..." >&2 "${REPO_ROOT}/scripts/sandbox-build.sh" fi diff --git a/scripts/testing/prepare-codex-contract.mjs b/scripts/testing/prepare-codex-contract.mjs index b91aeb66f..def55e770 100644 --- a/scripts/testing/prepare-codex-contract.mjs +++ b/scripts/testing/prepare-codex-contract.mjs @@ -8,6 +8,7 @@ import { createHash, randomUUID } from 'node:crypto' import { spawnSync } from 'node:child_process' import { Readable, Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' +import { CODEX_NETWORK_REQUIREMENTS } from '../../test/integration/providers/codex-local-provider.mjs' export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') export const artifactMetadata = JSON.parse(fs.readFileSync(path.join(repoRoot, 'test/fixtures/coding-cli/codex-native-artifact.json'), 'utf8')) @@ -185,8 +186,13 @@ export async function verifySandbox() { const policy = nativeSandboxPolicy() const hostPolicy = await fsp.readFile(path.join(stageRoot, 'codex-policy.json')) if (`sha256:${digest(hostPolicy)}` !== policy.profileSha256 || manifest.nativeSandboxPolicy?.profileSha256 !== policy.profileSha256) fail('prepared Codex native policy is absent or stale') - const image = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() - if (manifest.image !== image) fail('sandbox image changed since connected warmup') + const baseImage = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() + if ((manifest.baseImage ?? manifest.image) !== baseImage) fail('sandbox image changed since connected warmup') + const image = manifest.image + if (manifest.contractImage) { + const observed = JSON.parse(run('docker', ['image', 'inspect', image]))[0] + if (observed.Id !== image || observed.Config?.Labels?.['org.freshell.codex-contract-owner'] !== manifest.subdir || manifest.contractImage.requirementsSha256 !== digest(CODEX_NETWORK_REQUIREMENTS)) fail('owned Codex test image or system prerequisites changed') + } for (const volume of ['freshell-sandbox-cargo-target', 'freshell-sandbox-node-modules', 'freshell-sandbox-sidecar-node-modules', 'freshell-sandbox-mcp-node-modules']) run('docker', ['volume', 'inspect', volume]) const name = `freshcodex-verifier-${randomUUID()}` const args = ['run', '--rm', '--name', name, '--label', `org.freshell.codex-contract=${name}`, '--read-only', '--network', 'none', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', `${manifest.uid}:${manifest.gid}`, '--entrypoint', 'node', '-v', `${repoRoot}:/workspace:ro`, '-v', 'freshell-sandbox-cargo-target:/freshell-test-artifacts:ro', '-v', 'freshell-sandbox-node-modules:/workspace/node_modules:ro', image, '/workspace/scripts/testing/prepare-codex-contract.mjs', '--verify-prepared'] @@ -215,7 +221,23 @@ async function main(args) { await prepareArtifact(path.join(stageRoot, 'artifact')) await preparePolicy() result = JSON.parse(run('bash', ['scripts/sandbox-test.sh', 'node scripts/testing/prepare-codex-contract.mjs --stage-prepared'], { stdio: ['ignore', 'pipe', 'inherit'] })) - result.image = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() + result.baseImage = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() + const context = path.join(stageRoot, 'image-context', result.subdir) + await fsp.mkdir(context, { recursive: true, mode: 0o700 }) + await fsp.writeFile(path.join(context, 'requirements.toml'), CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 }) + await fsp.writeFile(path.join(stageRoot, 'requirements.toml'), CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 }) + const baseTag = `freshell-codex-contract-base:${result.subdir}` + run('docker', ['image', 'tag', result.baseImage, baseTag]) + await fsp.writeFile(path.join(context, 'Dockerfile'), `FROM ${baseTag}\nCOPY requirements.toml /etc/codex/requirements.toml\nRUN chmod 755 /etc/codex && chmod 644 /etc/codex/requirements.toml\n`) + const tag = `freshell-codex-contract:${result.subdir}` + try { run('docker', ['build', '--pull=false', '--label', `org.freshell.codex-contract-owner=${result.subdir}`, '-t', tag, context], { stdio: ['ignore', 'inherit', 'inherit'] }) } + finally { + const observedBase = run('docker', ['image', 'inspect', baseTag, '--format', '{{.Id}}']).trim() + if (observedBase !== result.baseImage) fail('owned temporary base tag changed during preparation') + run('docker', ['image', 'rm', baseTag]) + } + result.image = run('docker', ['image', 'inspect', tag, '--format', '{{.Id}}']).trim() + result.contractImage = { tag, requirementsSha256: digest(CODEX_NETWORK_REQUIREMENTS) } await fsp.writeFile(stageManifest, `${JSON.stringify(result, null, 2)}\n`) await verifySandbox() } else fail('usage: --output ABS | --sandbox | --stage-prepared | --verify-sandbox') diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs index 9d3c840ed..f58211b19 100644 --- a/test/integration/providers/codex-cli-contract.mjs +++ b/test/integration/providers/codex-cli-contract.mjs @@ -4,14 +4,30 @@ import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import os from 'node:os' +import http from 'node:http' import { spawn, spawnSync } from 'node:child_process' import { createInterface } from 'node:readline' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' import { artifactMetadata, repoRoot, verifyArtifact, ambientCodex, verifySandbox, nativeSandboxPolicy } from '../../../scripts/testing/prepare-codex-contract.mjs' -import { createCodexLocalProvider, MCP_FORM_CONTENT } from './codex-local-provider.mjs' +import { createCodexLocalProvider, MCP_FORM_CONTENT, MCP_LARGE_TEXT, CODEX_NETWORK_REQUIREMENTS } from './codex-local-provider.mjs' const scenarios = ['text-history', 'active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'] +export function nativeContractCases() { + const cases = [] + for (const historyMode of ['legacy', 'paginated']) { + cases.push(['text-history', { historyMode }], ['text-history', { historyMode, duplicateBodies: true }]) + } + for (const name of scenarios.filter(name => name !== 'text-history')) cases.push([name, {}]) + for (const sandboxMode of ['read-only', 'workspace-write']) cases.push(['approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true }]) + for (const approvalControl of ['stdin', 'network']) cases.push(['approval-policy-restricted-exec', { approvalControl }]) + cases.push(['active-input-stop', { startupCancellation: true }], ['parent-child-read', { ephemeralParent: true }]) + for (const historyMode of ['legacy', 'paginated']) { + cases.push(['mcp-form-url', { historyMode, largeOutput: true }]) + for (const persistenceMode of ['delayed', 'missing']) cases.push(['text-history', { historyMode, persistenceMode }]) + } + return cases +} const deadline = (promise, ms, label) => { let timer return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`CODEX_CONTRACT_TIMEOUT: ${label}`)), ms) })]).finally(() => clearTimeout(timer)) @@ -21,6 +37,25 @@ function deferred() { const promise = new Promise(done => { resolve = done }) return { promise, resolve } } +function sanitizedCapture(value, secrets, key = '') { + if (typeof value === 'string') { + if (/^(?:authorization|api_?key|access_?token|password|secret)$/i.test(key)) return '[redacted]' + let result = value.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[a-zA-Z0-9+/=]+/g, '[uploaded image omitted]') + for (const secret of secrets) if (secret) result = result.replaceAll(secret, '[redacted]') + return result + } + if (Array.isArray(value)) return value.map(item => sanitizedCapture(item, secrets)) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, sanitizedCapture(item, secrets, name)])) + return value +} + +function exportCloudCapture(capture, json) { + const id = randomUUID() + const partSize = 16_000 + const parts = Math.ceil(json.length / partSize) + for (let part = 0; part < parts; part++) process.stdout.write(`${JSON.stringify({ severity: 'INFO', event: 'codex_native_capture_part', id, scenario: capture.scenario, part, parts, payload: json.slice(part * partSize, (part + 1) * partSize) })}\n`) + process.stdout.write(`${JSON.stringify({ severity: 'INFO', event: 'codex_native_capture_complete', id, scenario: capture.scenario, parts, bytes: Buffer.byteLength(json), sha256: createHash('sha256').update(json).digest('hex'), passed: capture.passed, version: capture.binary.version, uid: capture.environment.uid, gid: capture.environment.gid, platform: capture.binary.platform, requestCount: capture.requests.length, fixtureRequestCount: capture.fixtureTraffic.length })}\n`) +} async function roots(options) { const metadata = options.probeMetadata ?? artifactMetadata @@ -35,7 +70,7 @@ async function roots(options) { await fsp.mkdir(receiptRoot, { recursive: true, mode: 0o700 }) for (const directory of ['home', 'codex', 'xdg-config', 'xdg-cache', 'xdg-data', 'workspace', 'tmp']) await fsp.mkdir(path.join(stateRoot, directory), { mode: 0o700 }) const systemConfig = ['/etc/codex/config.toml', '/etc/codex/requirements.toml'].map(file => ({ path: file, present: fs.existsSync(file) })) - if (systemConfig.some(file => file.present)) throw new Error('CODEX_CONTRACT_PREREQUISITE: unexpected system Codex configuration in fixture environment') + if (systemConfig.some(file => file.present && (file.path !== '/etc/codex/requirements.toml' || fs.readFileSync(file.path, 'utf8') !== CODEX_NETWORK_REQUIREMENTS))) throw new Error('CODEX_CONTRACT_PREREQUISITE: unexpected system Codex configuration in fixture environment') const environment = { PATH: process.env.PATH, HOME: path.join(stateRoot, 'home'), CODEX_HOME: await fsp.realpath(path.join(stateRoot, 'codex')), XDG_CONFIG_HOME: path.join(stateRoot, 'xdg-config'), XDG_CACHE_HOME: path.join(stateRoot, 'xdg-cache'), XDG_DATA_HOME: path.join(stateRoot, 'xdg-data'), TMPDIR: path.join(stateRoot, 'tmp'), LANG: 'C.UTF-8', RUST_LOG: 'warn' } if (!options.probeMetadata || options.preferBundledBwrap) { const bundled = artifact.files.filter(file => file.path.endsWith('/codex-resources/bwrap')) @@ -51,8 +86,11 @@ class NativeClient { this.pending = new Map() this.waiters = new Set() this.nextId = 1 + this.secretControls = new Map() this.child = spawn(binary, ['app-server'], { cwd: context.workspace, env: context.environment, stdio: ['pipe', 'pipe', 'pipe'] }) - if (capture.environment.seccompProbe || capture.environment.nativeSandboxPolicy) { + this.connectionId = (capture.connections ??= []).length + capture.connections.push({ id: this.connectionId, pid: this.child.pid, binary }) + if (!capture.probeOnly || capture.environment.seccompProbe || capture.environment.nativeSandboxPolicy) { const observed = new Map((capture.observations.executedSandboxHelpers ?? []).map(helper => [helper.pid, helper])) this.helperWatcher = setInterval(() => { const visit = pid => { @@ -74,6 +112,7 @@ class NativeClient { } this.exit = new Promise(resolve => this.child.once('exit', (code, signal) => { capture.processExit = { code, signal } + ;(capture.processExits ??= []).push({ connectionId: this.connectionId, pid: this.child.pid, code, signal }) clearInterval(this.helperWatcher) for (const entry of this.pending.values()) entry.reject(new Error(`owned Codex exited: ${code}/${signal}`)) resolve({ code, signal }) @@ -82,28 +121,46 @@ class NativeClient { createInterface({ input: this.child.stdout }).on('line', line => { let frame try { frame = JSON.parse(line) } catch { return } - capture.frames.push({ index: capture.frames.length, direction: 'native', frame }) + capture.frames.push({ index: capture.frames.length, connectionId: this.connectionId, direction: 'native', frame }) if (frame.id !== undefined && !frame.method) { capture.responses.push(frame) const entry = this.pending.get(frame.id) - if (entry) { this.pending.delete(frame.id); if (frame.error) entry.reject(Object.assign(new Error(frame.error.message), { nativeError: frame.error })); else entry.resolve(frame.result) } + if (entry?.delayDelivery) { entry.nativeReply = frame; entry.receivedIndex = capture.frames.length - 1 } + else if (entry) { this.pending.delete(frame.id); if (frame.error) entry.reject(Object.assign(new Error(frame.error.message), { nativeError: frame.error })); else entry.resolve(frame.result) } } else if (frame.method && frame.id !== undefined) { capture.serverRequests.push(frame) + if (frame.method === 'item/tool/requestUserInput') this.secretControls.set(frame.id, { questions: new Set((frame.params.questions ?? []).filter(question => question.isSecret).map(question => question.id)) }) + if (frame.method === 'mcpServer/elicitation/request') this.secretControls.set(frame.id, { fields: new Set(Object.entries(frame.params.requestedSchema?.properties ?? {}).filter(([, schema]) => schema.format === 'password' || schema.writeOnly === true).map(([name]) => name)) }) this.onServerRequest?.(frame) } else capture.notifications.push(frame) for (const waiter of [...this.waiters]) if (waiter.predicate(frame)) { this.waiters.delete(waiter); waiter.resolve(frame) } }) } - send(frame) { this.capture.frames.push({ index: this.capture.frames.length, direction: 'client', frame }); this.child.stdin.write(`${JSON.stringify(frame)}\n`) } - request(method, params = {}, timeoutMs = 30_000) { + send(frame) { + const secret = this.secretControls.get(frame.id) + for (const question of secret?.questions ?? []) for (const value of frame.result?.answers?.[question]?.answers ?? []) if (typeof value === 'string' && value) this.capture.secretValues.add(value) + for (const field of secret?.fields ?? []) { const value = frame.result?.content?.[field]; if (typeof value === 'string' && value) this.capture.secretValues.add(value) } + if (frame.id !== undefined && frame.result !== undefined) this.secretControls.delete(frame.id) + this.capture.frames.push({ index: this.capture.frames.length, connectionId: this.connectionId, direction: 'client', frame }) + this.child.stdin.write(`${JSON.stringify(frame)}\n`) + } + request(method, params = {}, timeoutMs = 30_000, delayDelivery = false) { const id = this.nextId++ const frame = { id, method, params } this.capture.requests.push(frame) - return deadline(new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.send(frame) }), timeoutMs, method) + return deadline(new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, delayDelivery }); this.send(frame) }), timeoutMs, method) } - wait(predicate, timeoutMs = 30_000) { - const existing = this.capture.frames.find(entry => entry.direction === 'native' && predicate(entry.frame)) + releaseReply(id) { + const entry = this.pending.get(id) + assert(entry?.nativeReply, 'only an actually received native response can be delivered') + this.pending.delete(id) + ;(this.capture.observations.delayedControllerReplies ??= []).push({ connectionId: this.connectionId, requestId: id, nativeReceiptIndex: entry.receivedIndex, controllerReleaseIndex: this.capture.frames.length }) + if (entry.nativeReply.error) entry.reject(Object.assign(new Error(entry.nativeReply.error.message), { nativeError: entry.nativeReply.error })) + else entry.resolve(entry.nativeReply.result) + } + wait(predicate, timeoutMs = 30_000, minimumIndex = 0) { + const existing = this.capture.frames.find(entry => entry.connectionId === this.connectionId && entry.index >= minimumIndex && entry.direction === 'native' && predicate(entry.frame)) if (existing) return Promise.resolve(existing.frame) let waiter return deadline(new Promise(resolve => { waiter = { predicate, resolve }; this.waiters.add(waiter) }), timeoutMs, 'native notification').finally(() => this.waiters.delete(waiter)) @@ -208,6 +265,83 @@ function mcpResponse(record, flow) { return { text: 'First fragment and final fragment' } } +function largeOutputResponse(record, flow, context) { + const current = flow.current + const step = current.step++ + if (step === 0) return advertisedCall(record, 'exec_command', { cmd: `python3 '${context.largeCommand}'`, shell: '/bin/sh', login: false, max_output_tokens: 100000, yield_time_ms: 1000 }, 'large_command') + if (!current.commandDone) { + const output = toolOutput(record, current.polls ? `call_large_poll_${current.polls}` : 'call_large_command') + const running = output.match(/Process running with session ID (\d+)/) + if (running) { + current.polls = (current.polls ?? 0) + 1 + return advertisedCall(record, 'write_stdin', { session_id: Number(running[1]), chars: '', max_output_tokens: 100000, yield_time_ms: 1000 }, `large_poll_${current.polls}`) + } + assert.match(output, /Process exited with code 0/) + current.commandDone = true + return advertisedCall(record, 'large_result', {}, 'large_mcp') + } + assert.match(toolOutput(record, 'call_large_mcp'), /F1_MCP_STRUCTURED/) + return { text: 'First fragment and final fragment' } +} + +function largeSavedItems(read, turnId) { + return read.thread.turns.filter(turn => turn.id === turnId).flatMap(turn => turn.items) +} +async function verifyLargeOutput(capture, read, context, turnId, label, historyMode) { + const live = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.turnId === turnId).map(frame => frame.params.item) + const command = live.find(item => item.type === 'commandExecution' && item.id === 'call_large_command') + assert.equal(command?.exitCode, 0) + assert.equal(Buffer.byteLength(command.aggregatedOutput), 128 * 1024) + assert(command.aggregatedOutput.startsWith('F1_COMMAND_HEAD\n')) + assert(command.aggregatedOutput.endsWith('\nF1_COMMAND_TAIL\n')) + const mcp = live.find(item => item.type === 'mcpToolCall') + assert.equal(mcp?.status, 'completed') + assert.equal(mcp.result.content[0].text, MCP_LARGE_TEXT) + assert.equal(mcp.result.structuredContent.fixture, 'F1_MCP_STRUCTURED') + assert.equal(mcp.result._meta['fixture/result'], 'F1_MCP_META') + const items = largeSavedItems(read, turnId) + const savedCommand = items.find(item => item.type === 'commandExecution') + const savedMcp = items.find(item => item.type === 'mcpToolCall') + assert(savedMcp, 'native history must preserve its actual ordinary MCP result') + const order = items.map(item => ({ id: item.id, type: item.type })) + assert.equal(order[0].type, 'userMessage') + if (savedCommand) assert(order.findIndex(item => item.type === 'commandExecution') < order.findIndex(item => item.type === 'mcpToolCall')) + assert(order.findIndex(item => item.type === 'mcpToolCall') < order.findIndex(item => item.type === 'agentMessage')) + if (historyMode === 'paginated') { + assert(savedCommand, 'paginated canonical command history must be present') + assert.equal(savedCommand.id, command.id) + assert(Buffer.byteLength(savedCommand.aggregatedOutput) <= 65536) + assert(savedCommand.aggregatedOutput.includes('\n... command output truncated for persistence ...\n')) + assert(savedCommand.aggregatedOutput.startsWith('F1_COMMAND_HEAD\n')) + assert(savedCommand.aggregatedOutput.endsWith('\nF1_COMMAND_TAIL\n')) + assert.match(savedMcp.result.content[0].text, /…\d+ chars truncated…/u) + assert.equal(savedMcp.result.structuredContent, null) + assert.equal(savedMcp.result._meta, null) + assert.notDeepEqual(savedMcp.result, mcp.result) + const rollout = read.thread.path + assert(rollout.startsWith(context.environment.CODEX_HOME + '/sessions/'), 'read only the actual owned native rollout') + const records = (await fsp.readFile(rollout, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + const core = records.filter(record => record.type === 'event_msg' && record.payload?.type === 'item_completed').map(record => record.payload.item) + const coreCommand = core.find(item => item.id === command.id && item.type === 'CommandExecution') + const coreMcp = core.find(item => item.id === mcp.id && item.type === 'McpToolCall') + assert(coreCommand && coreMcp, 'actual paginated canonical tool records are required') + assert(Buffer.byteLength(coreCommand.aggregated_output) <= 65536) + assert(Buffer.byteLength(JSON.stringify(coreMcp.result)) <= 65536) + assert.equal(coreMcp.result.isError, false) + assert(!('structuredContent' in coreMcp.result)) + assert(!('_meta' in coreMcp.result)) + capture.observations.canonicalSavedTools = { command: coreCommand, mcp: coreMcp } + } else { + assert.deepEqual(savedMcp.result, mcp.result, 'Legacy ordinary MCP result retains its actual full native form') + const rollout = read.thread.path + assert(rollout.startsWith(context.environment.CODEX_HOME + '/sessions/')) + const records = (await fsp.readFile(rollout, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + capture.observations.legacySavedToolRecords = records.filter(record => record.type === 'event_msg' && /exec|mcp/i.test(record.payload?.type ?? '')) + capture.observations.legacyCommandHistoryAvailability = savedCommand ? 'present' : 'native-history-item-unavailable-with-live-output-retained' + } + ;(capture.observations.largeOutputReads ??= []).push({ label, historyMode, order, retained: { command, mcp }, saved: { command: savedCommand, mcp: savedMcp }, state: historyMode === 'paginated' ? 'saved-provider-truncated-with-richer-retained-output' : 'actual-legacy-saved-representation' }) +} + async function driveMcp(client, capture, flow, context) { capture.observations.elicitationCases = [] let last @@ -227,9 +361,17 @@ async function driveMcp(client, capture, flow, context) { assert.equal(terminal.params.turn.status, 'completed') capture.reads.push({ label: `mcp-${route}`, body: await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) }) } + flow.current = { id: 'held', route: 'form', hold: true, step: 0 } + const held = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Leave the owned MCP form pending for a controlled Stop.' }] }) + const pending = await client.wait(frame => frame.method === 'mcpServer/elicitation/request' && frame.params?.turnId === held.turn.id) + await client.request('turn/interrupt', { threadId: capture.threadId, turnId: held.turn.id }) + const cancelled = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === held.turn.id) + assert.equal(cancelled.params.turn.status, 'interrupted') + const resolved = await client.wait(frame => frame.method === 'serverRequest/resolved' && frame.params?.requestId === pending.id && frame.params.threadId === capture.threadId) + capture.observations.cancelledMcpControl = { requestId: pending.id, turnId: held.turn.id, resolved: resolved.params } const trace = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) capture.observations.mcpTraffic = trace - assert.equal(trace.filter(record => record.event === 'elicitation_result').length, 12) + assert.equal(trace.filter(record => record.event === 'elicitation_result' && record.sequence === 'matrix').length, 12) assert(!trace.some(record => record.event === 'fixture_error'), 'ordinary MCP route must return actual native responses without fixture errors') const initialized = trace.find(record => record.direction === 'native' && record.frame?.method === 'initialize') assert(initialized, 'configured MCP server must actually initialize') @@ -238,10 +380,262 @@ async function driveMcp(client, capture, flow, context) { return last } +function toolOutput(record, callId) { + const output = record.body.input.findLast(item => item.type === 'function_call_output' && item.call_id === callId)?.output + assert(output !== undefined, `actual native tool result missing for ${callId}`) + return typeof output === 'string' ? output : output.filter(item => item.type === 'input_text').map(item => item.text).join('\n') +} + +function stdinResponse(record, flow, capture, context) { + const current = flow.current + assert(current, 'stdin model exchange must belong to an actual selected turn') + const step = current.step++ + if (current.kind === 'stdin-open') { + if (step === 0) return advertisedCall(record, 'request_permissions', { reason: 'Allow writes only to the fixture terminal result directory.', permissions: { file_system: { write: [path.join(context.workspace, 'allowed')] } } }, `permissions_${current.id}`) + if (step === 1) { + toolOutput(record, `call_permissions_${current.id}`) + return advertisedCall(record, 'exec_command', { cmd: '/bin/bash --noprofile --norc', shell: '/bin/sh', login: false, tty: true, yield_time_ms: 250 }, `open_${current.id}`) + } + const output = toolOutput(record, `call_open_${current.id}`) + const match = output.match(/Process running with session ID (\d+)/) + assert(match, 'PTY session handle must come from actual native execution') + flow.sessionId = Number(match[1]) + return { text: 'First fragment and final fragment' } + } + if (current.kind === 'stdin-cancel') return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: 'printf cancelled > allowed/cancelled-marker\n', yield_time_ms: 1000 }, 'stdin_cancel') + if (step === 0) return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: '', yield_time_ms: 1000 }, 'stdin_empty') + if (step === 1) { + toolOutput(record, 'call_stdin_empty') + current.emptyPollReturned = true + return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: 'REJECTED=1; printf denied > allowed/denied-marker\n', yield_time_ms: 1000 }, 'stdin_denied') + } + if (step === 2) { + capture.observations.deniedStdinResult = toolOutput(record, 'call_stdin_denied') + return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: "if test -v REJECTED || test -e allowed/denied-marker; then exit 19; fi; printf accepted > allowed/accepted-marker; printf 'STDIN_ACCEPTED\\n'; exit\n", yield_time_ms: 1000 }, 'stdin_accepted') + } + const output = toolOutput(record, 'call_stdin_accepted') + assert.match(output, /Process exited with code 0/) + capture.observations.acceptedStdinResult = output + return { text: 'First fragment and final fragment' } +} + +async function driveStdin(client, capture, flow, context) { + capture.observations.stdinCases = [] + const open = async id => { + flow.current = { id, kind: 'stdin-open', step: 0 } + const turn = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Grant the narrow owned directory and open a real sandboxed PTY.' }] }) + const permission = await client.wait(frame => frame.method === 'item/permissions/requestApproval' && frame.params?.turnId === turn.turn.id) + const requested = permission.params.permissions + assert(JSON.stringify(requested).includes(path.join(context.workspace, 'allowed'))) + client.send({ id: permission.id, result: { permissions: requested, scope: 'turn', strictAutoReview: false } }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert(Number.isSafeInteger(flow.sessionId)) + const item = capture.notifications.find(frame => frame.method === 'item/started' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'commandExecution')?.params.item + assert(item, 'actual PTY command item identity is required') + capture.observations.stdinCases.push({ kind: 'open', turnId: turn.turn.id, sessionId: flow.sessionId, itemId: item.id, permissionRequestId: permission.id }) + return { turn, item } + } + const first = await open('first') + flow.current = { id: 'write', kind: 'stdin-write', step: 0 } + const write = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Poll the terminal, deny one input, then approve a separate input.' }] }) + const denied = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === write.turn.id) + assert.equal(flow.current.emptyPollReturned, true, 'empty polling must finish without stdin review') + assert.equal(denied.params.itemId, first.item.id) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + client.send({ id: denied.id, result: { decision: 'decline' } }) + const accepted = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === write.turn.id && frame.id !== denied.id) + assert.equal(accepted.params.itemId, first.item.id) + assert.notEqual(accepted.params.approvalId, denied.params.approvalId) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/accepted-marker'))) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + client.send({ id: accepted.id, result: { decision: 'accept' } }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === write.turn.id) + assert.equal(terminal.params.turn.status, 'completed') + assert.equal(await fsp.readFile(path.join(context.workspace, 'allowed/accepted-marker'), 'utf8'), 'accepted') + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + capture.observations.stdinCases.push({ kind: 'write', turnId: write.turn.id, itemId: first.item.id, deniedRequestId: denied.id, acceptedRequestId: accepted.id, deniedApprovalId: denied.params.approvalId, acceptedApprovalId: accepted.params.approvalId, acceptedFile: 'accepted', deniedFilePresent: false }) + const second = await open('cancel') + flow.current = { id: 'cancel', kind: 'stdin-cancel', step: 0 } + const cancelling = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Cancel the separate pending stdin write without changing the owned terminal.' }] }) + const pending = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === cancelling.turn.id) + client.send({ id: pending.id, result: { decision: 'cancel' } }) + const cancelled = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === cancelling.turn.id) + assert.equal(cancelled.params.turn.status, 'interrupted') + assert(!fs.existsSync(path.join(context.workspace, 'allowed/cancelled-marker'))) + capture.observations.stdinCases.push({ kind: 'cancel', turnId: cancelling.turn.id, requestId: pending.id, sessionId: flow.sessionId, filePresent: false, terminalExitInferred: false }) + return second.turn +} + +async function driveNativeRaces(client, capture, flow) { + const cases = capture.observations.stopRaceCases = [] + const terminalFor = id => client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === id) + const input = text => ({ threadId: capture.threadId, input: [{ type: 'text', text }] }) + const outcome = promise => promise.then(result => ({ result }), error => { assert(error.nativeError); return { nativeError: error.nativeError } }) + const hold = () => { const release = deferred(); flow.current = { kind: 'race', release }; return release } + const frameIndex = (id, direction) => capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === direction && entry.frame.id === id && !entry.frame.method)?.index + let release = hold() + const pendingRequest = client.nextId + const start = client.request('turn/start', input('Pending-start Stop native race.')) + const startupStopId = client.nextId + const emptyStop = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: '' })) + const accepted = await start + const startupStop = await emptyStop + // Empty-ID success is submission only. Bind the actual admitted ID and + // obtain its own terminal; a second explicit cancellation is attributed. + const knownTerminal = capture.notifications.find(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === accepted.turn.id) + const followupStop = knownTerminal ? null : await outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: accepted.turn.id })) + const pendingTerminal = knownTerminal ?? await terminalFor(accepted.turn.id) + release.resolve() + cases.push({ kind: 'start-outstanding-at-stop-issuance', startRequestId: pendingRequest, emptyStopRequestId: startupStopId, startReplyIndex: frameIndex(pendingRequest, 'native'), stopReplyIndex: frameIndex(startupStopId, 'native'), turnId: accepted.turn.id, startupStop, followupStop, terminal: pendingTerminal.params.turn }) + let previous = accepted.turn.id + for (const ordering of ['release-then-stop', 'stop-then-release']) { + release = hold() + const turn = await client.request('turn/start', input(`Natural completion/Stop race: ${ordering}.`)) + await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === turn.turn.id) + let stopping + if (ordering === 'release-then-stop') { release.resolve(); stopping = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id })) } + else { stopping = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id })); release.resolve() } + const stop = await stopping + const terminal = await terminalFor(turn.turn.id) + assert(['completed', 'interrupted'].includes(terminal.params.turn.status)) + cases.push({ kind: 'native-natural-completion-race', ordering, turnId: turn.turn.id, stop, terminal: terminal.params.turn }) + previous = turn.turn.id + } + release = hold() + const current = await client.request('turn/start', input('A stale Stop must not affect this distinct active native turn.')) + await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === current.turn.id) + assert.notEqual(current.turn.id, previous) + const stale = await nativeRefusal(client, 'turn/interrupt', { threadId: capture.threadId, turnId: previous }) + const steer = await client.request('turn/steer', { threadId: capture.threadId, expectedTurnId: current.turn.id, input: [{ type: 'text', text: 'Keep the actual new native turn active.' }] }) + assert.equal(steer.turnId, current.turn.id) + await client.request('turn/interrupt', { threadId: capture.threadId, turnId: current.turn.id }) + const currentTerminal = await terminalFor(current.turn.id) + release.resolve() + cases.push({ kind: 'stale-stop-after-turnover', staleTurnId: previous, currentTurnId: current.turn.id, nativeRefusal: stale, steer, terminal: currentTerminal.params.turn }) + release = hold() + const since = capture.frames.length + const delayedStartId = client.nextId + const delayedStart = client.request('turn/start', input('Real native output before delayed controller acceptance delivery.'), 30_000, true) + const started = await client.wait(frame => frame.method === 'turn/started', 30_000, since) + const delta = await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === started.params.turn.id, 30_000, since) + assert(delta.params.delta.length > 0) + client.releaseReply(delayedStartId) + const delayedAccepted = await delayedStart + assert.equal(delayedAccepted.turn.id, started.params.turn.id) + const stopId = client.nextId + const ambiguous = client.request('turn/interrupt', { threadId: capture.threadId, turnId: delayedAccepted.turn.id }, 50, true).then(() => assert.fail('controller response is deliberately withheld'), error => ({ message: error.message })) + const terminal = await terminalFor(delayedAccepted.turn.id) + const timeout = await ambiguous + assert.match(timeout.message, /TIMEOUT/) + assert.equal(capture.requests.at(-1).method, 'turn/interrupt', 'a local deadline must not replay or issue successor input') + client.releaseReply(stopId) + release.resolve() + cases.push({ kind: 'controller-delivery-loss-with-progressing-native-consumer', turnId: delayedAccepted.turn.id, startRequestId: delayedStartId, stopRequestId: stopId, timeout, terminal: terminal.params.turn, promptReplayCount: 0, nativeReceiptOrderPreserved: true }) + flow.current = null + const final = await client.request('turn/start', input('New work only after exact native terminal resolution.')) + const completed = await terminalFor(final.turn.id) + assert.equal(completed.params.turn.status, 'completed') + const lateStop = await nativeRefusal(client, 'turn/interrupt', { threadId: capture.threadId, turnId: final.turn.id }) + cases.push({ kind: 'completed-then-late-stop', turnId: final.turn.id, nativeRefusal: lateStop, terminal: completed.params.turn }) + capture.observations.nativeStartResponseOrder = capture.requests.filter(frame => frame.method === 'turn/start').map(request => { + const reply = capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === 'native' && entry.frame.id === request.id && entry.frame.result?.turn) + const turnId = reply?.frame.result.turn.id + const firstEvent = capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === 'native' && entry.frame.method && (entry.frame.params?.turnId === turnId || entry.frame.params?.turn?.id === turnId)) + return { requestId: request.id, turnId, rawReplyIndex: reply?.index, firstNativeEventIndex: firstEvent?.index, nativeEventBeforeReply: firstEvent?.index < reply?.index } + }) + return final +} + +async function networkFixture(context) { + const hits = [] + const listeners = [] + for (let index = 0; index < 3; index++) { + const server = http.createServer((request, response) => { + hits.push({ listener: index, path: request.url, peer: request.socket.remoteAddress }) + response.end('NATIVE_NETWORK_ALLOWED') + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + listeners.push(server) + } + const reservation = http.createServer() + await new Promise((resolve, reject) => { reservation.once('error', reject); reservation.listen(0, '127.0.0.1', resolve) }) + const proxyPort = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const helper = path.join(context.workspace, 'network-canary.py') + await fsp.writeFile(helper, `import os, socket, sys, urllib.parse +proxy = urllib.parse.urlparse(os.environ['HTTP_PROXY']) +target = sys.argv[1] +s = socket.create_connection((proxy.hostname, proxy.port), timeout=20) +s.settimeout(20) +s.sendall(('GET ' + target + ' HTTP/1.1\\r\\nHost: ' + urllib.parse.urlparse(target).netloc + '\\r\\nConnection: close\\r\\n\\r\\n').encode()) +chunks = [] +while True: + data = s.recv(65536) + if not data: break + chunks.append(data) +body = b''.join(chunks) +print(body.decode(errors='replace')) +sys.exit(0 if b'NATIVE_NETWORK_ALLOWED' in body else 23) +`) + return { hits, helper, proxyPort, urls: listeners.map(server => `http://127.0.0.1:${server.address().port}/canary/${randomUUID()}`), close: () => Promise.all(listeners.map(server => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }))) } +} + +function networkResponse(record, flow, capture, context) { + const current = flow.current + assert(current, 'network command must belong to the actual selected turn') + const step = current.step++ + if (step === 0) return advertisedCall(record, 'exec_command', { cmd: `python3 '${context.network.helper}' '${current.url}'`, shell: '/bin/sh', login: false, yield_time_ms: 1000, max_output_tokens: 2000 }, `network_${current.id}`) + const callId = current.polls ? `call_network_poll_${current.id}_${current.polls}` : `call_network_${current.id}` + const output = toolOutput(record, callId) + const running = output.match(/Process running with session ID (\d+)/) + if (running) { + current.polls = (current.polls ?? 0) + 1 + return advertisedCall(record, 'write_stdin', { session_id: Number(running[1]), chars: '', yield_time_ms: 1000, max_output_tokens: 2000 }, `network_poll_${current.id}_${current.polls}`) + } + current.output = output + if (['once', 'session', 'cached'].includes(current.id)) { assert.match(output, /Process exited with code 0/); assert.match(output, /NATIVE_NETWORK_ALLOWED/) } + return { text: 'First fragment and final fragment' } +} + +async function driveNetwork(client, capture, flow, context) { + capture.observations.networkCases = [] + let last + for (const [index, id, decision] of [[0, 'once', 'accept'], [0, 'session', 'acceptForSession'], [0, 'cached', null], [1, 'declined', 'decline'], [2, 'cancelled', 'cancel']]) { + const beforeHits = context.network.hits.length + const current = { id, step: 0, url: context.network.urls[index] } + flow.current = current + const turn = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', permissions: 'fixture_network', input: [{ type: 'text', text: `Run the controlled ${id} native network decision.` }] }) + let approval + if (decision) { + approval = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.turnId === turn.turn.id && frame.params.networkApprovalContext) + assert.equal(approval.params.kind, 'command') + assert.equal(approval.params.networkApprovalContext.host, '127.0.0.1') + assert.equal(approval.params.networkApprovalContext.protocol, 'http') + assert.equal(context.network.hits.length, beforeHits, 'network traffic must wait for the actual native decision') + if (decision !== 'decline') assert(approval.params.availableDecisions.includes(decision), 'decision must be offered by native Codex') + // Native supports Decline as a controller response while its network UI + // list intentionally omits it. Preserve both facts without inventing a UI choice. + client.send({ id: approval.id, result: { decision } }) + } + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert.equal(terminal.params.turn.status, decision === 'cancel' ? 'interrupted' : 'completed') + if (decision === 'decline') assert.match(current.output, /rejected by user/i) + const requests = capture.serverRequests.filter(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.turnId === turn.turn.id) + assert.equal(requests.length, decision ? 1 : 0, 'session grant must prevent only the matching repeated native request') + assert.equal(context.network.hits.length - beforeHits, ['decline', 'cancel'].includes(decision) ? 0 : 1) + capture.observations.networkCases.push({ id, turnId: turn.turn.id, requestId: approval?.id ?? null, approvalId: approval?.params.approvalId ?? null, listener: index, decision, decisionSource: decision === 'decline' ? 'supported-controller-response-not-advertised' : 'offered-native-decision', availableDecisions: approval?.params.availableDecisions ?? [], hits: context.network.hits.length - beforeHits, output: current.output ?? null, terminal: terminal.params.turn.status }) + if (decision !== 'cancel') last = turn + } + capture.observations.networkCanaryHits = context.network.hits + return last +} + export async function captureNativeScenario(name, options = {}) { assert(scenarios.includes(name), `unknown native scenario ${name}`) const context = await roots(options) - const capture = { scenario: name, probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + Object.defineProperty(capture, 'secretValues', { value: new Set(), enumerable: false }) + capture.environment.cloud = Object.fromEntries(['CLOUD_RUN_JOB', 'CLOUD_RUN_EXECUTION', 'CLOUD_RUN_TASK_INDEX', 'CLOUD_RUN_TASK_ATTEMPT'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])) if (options.seccompProbeReceipt) capture.environment.seccompProbe = JSON.parse(await fsp.readFile(options.seccompProbeReceipt, 'utf8')) if (process.env.FRESHELL_CODEX_CONTRACT_POLICY === 'prepared') { const declared = nativeSandboxPolicy() @@ -283,7 +677,9 @@ export async function captureNativeScenario(name, options = {}) { fixture = await createCodexLocalProvider(async record => { const step = modelStep++ if (name === 'plan-question-controls') return questionResponse(record, controlFlow, capture) - if (name === 'mcp-form-url') return mcpResponse(record, controlFlow) + if (name === 'mcp-form-url') return options.largeOutput ? largeOutputResponse(record, controlFlow, context) : mcpResponse(record, controlFlow) + if (options.approvalControl === 'stdin') return stdinResponse(record, controlFlow, capture, context) + if (options.approvalControl === 'network') return networkResponse(record, controlFlow, capture, context) if (name === 'parent-child-read') { const childInput = record.body.input.some(item => item.role === 'user' && JSON.stringify(item.content).includes('freshell-child-native-probe')) if (childInput) { @@ -299,6 +695,7 @@ export async function captureNativeScenario(name, options = {}) { await parentStream.promise return { text: 'First fragment and final fragment' } } + if (name === 'active-input-stop' && controlFlow.current?.kind === 'race') { const current = controlFlow.current; return { text: 'First fragment and final fragment', pauseAfterDelta: () => current.release.promise } } if (name === 'active-input-stop' && step === 0) return { text: 'Interrupted native fragment', pauseAfterDelta: () => new Promise(resolve => { releaseStream = resolve }) } if (name === 'text-history' && options.duplicateBodies) return { items: [0, 1].map(index => ({ id: `msg_duplicate_${index}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'First fragment and final fragment', annotations: [] }] })) } const commands = [options.sandboxMode === 'workspace-write' ? 'cat contract-input.txt && printf codex-workspace-write > contract-output.txt' : 'cat contract-input.txt'] @@ -328,18 +725,29 @@ export async function captureNativeScenario(name, options = {}) { return { text: 'First fragment and final fragment' } }) let extraConfig = options.legacyLandlock ? '[features]\nuse_legacy_landlock = true\n' : '' - if (['plan-question-controls', 'mcp-form-url'].includes(name)) { + if (['plan-question-controls', 'mcp-form-url'].includes(name) || options.approvalControl || options.startupCancellation) { const catalogPath = path.join(context.stateRoot, 'models.json') - await fsp.writeFile(catalogPath, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Contract model', description: 'Owned loopback native contract', default_reasoning_level: 'low', supported_reasoning_levels: [{ effort: 'low', description: 'Contract effort' }], shell_type: 'disabled', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: "Follow the user's request using the available tools." } }] })) + await fsp.writeFile(catalogPath, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Contract model', description: 'Owned loopback native contract', default_reasoning_level: 'low', supported_reasoning_levels: [{ effort: 'low', description: 'Contract effort' }], shell_type: options.approvalControl || options.largeOutput ? 'unified_exec' : 'disabled', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: "Follow the user's request using the available tools." } }] })) extraConfig = `model_catalog_json = ${JSON.stringify(catalogPath)}\nweb_search = "disabled"\n[features]\napps = false\ndefault_mode_request_user_input = true\nauth_elicitation = true\nnon_prefixed_mcp_tool_names = false\n[tools.experimental_request_user_input]\nenabled = true\n` - if (name === 'mcp-form-url') { + if (options.approvalControl || options.largeOutput) extraConfig = extraConfig.replace('[tools.experimental_request_user_input]', 'unified_exec = true\nunified_exec_tty = true\nwrite_stdin_approval = true\nexec_permission_approvals = true\nrequest_permissions_tool = true\n[tools.experimental_request_user_input]') + if (name === 'mcp-form-url' || options.startupCancellation) { context.mcpTrace = path.join(context.stateRoot, 'mcp-traffic.jsonl') await fsp.writeFile(context.mcpTrace, '', { mode: 0o600 }) - extraConfig += `\n[mcp_servers.contract]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([path.join(repoRoot, 'test/integration/providers/codex-local-provider.mjs'), '--mcp-fixture', context.mcpTrace, fixture.baseUrl.replace(/\/v1$/, '')])}\nenabled = true\nrequired = true\nstartup_readiness = "connection"\nstartup_timeout_sec = 15\ntool_timeout_sec = 120\n` + extraConfig += `\n[mcp_servers.contract]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([path.join(repoRoot, 'test/integration/providers/codex-local-provider.mjs'), options.startupCancellation ? '--mcp-startup-fixture' : '--mcp-fixture', context.mcpTrace, fixture.baseUrl.replace(/\/v1$/, '')])}\nenabled = true\nrequired = ${!options.startupCancellation}\nstartup_readiness = "connection"\nstartup_timeout_sec = ${options.startupCancellation ? 90 : 15}\ntool_timeout_sec = 120\n` } } + if (options.approvalControl === 'network') { + context.network = await networkFixture(context) + extraConfig = `default_permissions = "fixture_network"\napproval_policy = "on-request"\napprovals_reviewer = "user"\n${extraConfig}\n[permissions.fixture_network.filesystem]\n":minimal" = "read"\n${JSON.stringify(context.workspace)} = "read"\n${JSON.stringify(context.artifact.path)} = "read"\n[permissions.fixture_network.network]\nenabled = true\nmode = "limited"\nallow_local_binding = true\n[features.network_proxy]\nenabled = true\nproxy_url = "http://127.0.0.1:${context.network.proxyPort}"\nenable_socks5 = false\nenable_socks5_udp = false\nallow_upstream_proxy = false\nallow_local_binding = true\nmode = "limited"\ncredential_broker = false\n` + } await configure(context, fixture.baseUrl, extraConfig) - const restricted = name === 'approval-policy-restricted-exec' + const restricted = name === 'approval-policy-restricted-exec' && !options.approvalControl + if (options.largeOutput) { + context.largeCommand = path.join(context.workspace, 'large-output.py') + await fsp.writeFile(context.largeCommand, "import sys\nhead=b'F1_COMMAND_HEAD\\n'\ntail=b'\\nF1_COMMAND_TAIL\\n'\nsys.stdout.buffer.write(head+b'x'*(128*1024-len(head)-len(tail))+tail)\nsys.stdout.buffer.flush()\n") + controlFlow.current = { kind: 'large-output', step: 0 } + } + if (options.approvalControl === 'stdin') await fsp.mkdir(path.join(context.workspace, 'allowed')) if (restricted) { await fsp.writeFile(path.join(context.workspace, 'contract-input.txt'), 'codex-restricted-command-ran\n') if (options.probePolicyEffects) { @@ -371,7 +779,7 @@ export async function captureNativeScenario(name, options = {}) { } client = new NativeClient(capture.binary.path, context, capture) client.onServerRequest = frame => { - if (frame.method === 'item/commandExecution/requestApproval') { + if (frame.method === 'item/commandExecution/requestApproval' && !options.approvalControl) { const decision = frame.params.command?.includes('denied-by-user') ? 'cancel' : 'accept' const approval = { id: frame.id, params: frame.params, decision } capture.observations.commandApproval ??= approval @@ -387,14 +795,40 @@ export async function captureNativeScenario(name, options = {}) { capture.observations.models = await client.request('model/list', { includeHidden: false, limit: 100 }) assert(capture.observations.models.data.length > 0, 'release model discovery must return actual metadata') } - const policy = options.probeApprovalPolicy ?? (restricted ? 'untrusted' : name === 'mcp-form-url' ? 'on-request' : 'never') + const policy = options.probeApprovalPolicy ?? (restricted ? 'untrusted' : name === 'mcp-form-url' || options.approvalControl ? 'on-request' : 'never') const sandboxMode = options.sandboxMode ?? 'read-only' - const started = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: policy, sandbox: sandboxMode, experimentalRawEvents: false, persistExtendedHistory: true, ephemeral: options.ephemeralParent ?? false, ...(options.historyMode ? { historyMode: options.historyMode } : {}) }) + const started = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: policy, ...(options.approvalControl === 'network' ? { permissions: 'fixture_network' } : { sandbox: sandboxMode }), experimentalRawEvents: false, persistExtendedHistory: true, ephemeral: options.ephemeralParent ?? false, ...(options.historyMode ? { historyMode: options.historyMode } : {}) }) capture.threadId = started.thread.id capture.observations.threadStart = started + if (options.persistenceMode) { + assert.equal(name, 'text-history') + context.persistenceBlocker = path.join(context.environment.CODEX_HOME, 'sessions') + assert(!fs.existsSync(context.persistenceBlocker), 'persistence fault requires the native first-materialization parent to be absent') + await fsp.writeFile(context.persistenceBlocker, 'owned native recorder materialization blocker', { flag: 'wx', mode: 0o600 }) + capture.observations.persistenceFault = { kind: 'owned-file-blocks-native-directory', path: context.persistenceBlocker, mode: options.persistenceMode } + } assert.equal(started.approvalPolicy, policy, 'typed native policy must remain effective') + if (options.startupCancellation) { + const starting = await client.wait(frame => frame.method === 'mcpServer/startupStatus/updated' && frame.params?.threadId === capture.threadId && frame.params.name === 'contract' && frame.params.status === 'starting') + const initialized = await deadline((async () => { + for (;;) { + const records = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + const held = records.find(record => record.event === 'startup_initialize_held') + if (held) return held + await new Promise(resolve => setTimeout(resolve, 10)) + } + })(), 15_000, 'actual held MCP initialize') + const ack = await client.request('turn/interrupt', { threadId: capture.threadId, turnId: '' }) + const settled = await client.wait(frame => frame.method === 'mcpServer/startupStatus/updated' && frame.params?.threadId === capture.threadId && frame.params.name === 'contract' && frame.params.status === 'cancelled') + assert.equal(fixture.traffic.length, 0, 'startup cancellation must precede any model work') + assert(!capture.requests.some(frame => frame.method === 'turn/start')) + assert(!capture.notifications.some(frame => frame.method === 'turn/completed'), 'startup has its own native result, never an invented turn terminal') + capture.observations.startupCancellation = { starting: starting.params, actualInitialize: initialized, acknowledgement: ack, nativeStartupResult: settled.params, modelRequestCount: fixture.traffic.length } + capture.passed = true + return capture + } const sandboxPolicy = sandboxMode === 'workspace-write' ? { type: 'workspaceWrite', writableRoots: [context.workspace], networkAccess: options.probeNetworkEnabled ?? false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly', networkAccess: options.probeNetworkEnabled ?? false } - let turn = name === 'plan-question-controls' ? await driveQuestions(client, capture, controlFlow) : name === 'mcp-form-url' ? await driveMcp(client, capture, controlFlow, context) : await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(restricted && !options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the controlled contract scenario.' }] }) + let turn = options.approvalControl === 'network' ? await driveNetwork(client, capture, controlFlow, context) : options.approvalControl === 'stdin' ? await driveStdin(client, capture, controlFlow, context) : name === 'plan-question-controls' ? await driveQuestions(client, capture, controlFlow) : name === 'mcp-form-url' && !options.largeOutput ? await driveMcp(client, capture, controlFlow, context) : await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(restricted && !options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the controlled contract scenario.' }] }) capture.observations.startedTurnId = turn.turn.id if (name === 'parent-child-read') { const spawned = await client.wait(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'collabAgentToolCall' && frame.params.item.receiverThreadIds?.length > 0) @@ -449,6 +883,7 @@ export async function captureNativeScenario(name, options = {}) { } const completed = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id, options.timeoutMs ?? 120_000) capture.observations.completedTurn = completed.params.turn + if (name === 'active-input-stop') turn = await driveNativeRaces(client, capture, controlFlow) if (name === 'parent-child-read' && options.ephemeralParent) { capture.observations.ephemeralParentReadRefusal = await nativeRefusal(client, 'thread/read', { threadId: capture.threadId, includeTurns: true }) capture.observations.childObserverMethods = capture.requests.filter(frame => frame.params.threadId === capture.observations.childId).map(frame => frame.method) @@ -456,6 +891,68 @@ export async function captureNativeScenario(name, options = {}) { capture.passed = true return capture } + if (options.persistenceMode) { + const event = await client.wait(frame => frame.method === 'item/completed' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'agentMessage') + assert.equal(event.params.item.text, 'First fragment and final fragment') + capture.observations.retainedCompletedText = event.params.item.text + capture.observations.boundedRefresh = [] + for (let attempt = 0; attempt < 3; attempt++) { + try { + const body = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }, 5000) + capture.reads.push({ label: `blocked-refresh-${attempt}`, body }) + capture.observations.boundedRefresh.push({ attempt, status: 'read', savedText: assistantText(body, turn.turn.id) }) + } catch (error) { + capture.observations.boundedRefresh.push({ attempt, status: error.nativeError ? 'native-error' : 'timeout-or-exit', error: error.nativeError ?? error.message }) + } + } + assert((await fsp.stat(context.persistenceBlocker)).isFile(), 'native recorder must still be unable to create its rollout directory') + capture.observations.persistenceState = 'retained-unsaved-refresh-stopped' + if (options.persistenceMode === 'missing') { + await client.close() + capture.observations.persistenceOwnedExit = capture.processExit + await fsp.unlink(context.persistenceBlocker) + capture.observations.coldMissingFaultRemoved = true + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_missing_persistence', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + capture.observations.coldMissingRead = await nativeRefusal(client, 'thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.observations.coldRecoveryState = 'saved-history-unavailable-no-invented-fragments' + capture.passed = true + return capture + } + await fsp.unlink(context.persistenceBlocker) + { + const flush = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Flush the actual pending native recorder after its owned parent becomes available.' }] }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === flush.turn.id) + capture.observations.persistenceFlushTurnId = flush.turn.id + } + try { + const recovered = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'released-blocker-warm-read', body: recovered }) + capture.observations.releasedWarmText = assistantText(recovered, turn.turn.id) + } catch (error) { + assert(error.nativeError, 'recovery availability failure must come from native') + capture.observations.releasedWarmReadError = error.nativeError + } + capture.observations.persistenceState = 'owned-blocker-released-native-recovery-pending' + await client.close() + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_delayed_persistence', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + try { + await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, persistExtendedHistory: true }) + const recovered = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'released-blocker-cold-read', body: recovered }) + assert.equal(assistantText(recovered, turn.turn.id), event.params.item.text) + capture.observations.persistenceState = 'saved-after-owned-blocker-release-and-cold-recovery' + } catch (error) { + assert(error.nativeError, 'recovery must either supply the exact native body or an actual native availability error') + capture.observations.releasedColdReadError = error.nativeError + capture.observations.persistenceState = 'retained-unsaved-provider-unavailable-refresh-stopped' + } + capture.passed = true + return capture + } const read = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) capture.reads.push({ label: 'completed', body: read }) capture.observations.finalAssistantText = assistantText(read, turn.turn.id) @@ -467,6 +964,7 @@ export async function captureNativeScenario(name, options = {}) { } const expectedAssistantText = parentCompletedMessages?.join('\n') ?? Array(options.duplicateBodies ? 2 : 1).fill('First fragment and final fragment').join('\n') assert.equal(capture.observations.finalAssistantText, expectedAssistantText) + if (options.largeOutput) await verifyLargeOutput(capture, read, context, turn.turn.id, 'warm', options.historyMode) capture.observations.finalItems = read.thread.turns.filter(nativeTurn => nativeTurn.id === turn.turn.id).flatMap(nativeTurn => nativeTurn.items) if (name === 'model-effort-usage') { const usage = capture.notifications.filter(frame => frame.method === 'thread/tokenUsage/updated' && frame.params?.turnId === turn.turn.id).at(-1)?.params.tokenUsage @@ -476,7 +974,7 @@ export async function captureNativeScenario(name, options = {}) { assert.equal(fixture.traffic[0].body.model, 'contract-model') assert.equal(fixture.traffic[0].body.reasoning.effort, 'low') } - if (name === 'approval-policy-restricted-exec') { + if (restricted) { const commandItems = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'commandExecution').map(frame => frame.params.item) capture.observations.commandItems = commandItems capture.observations.workspaceWritePresent = fs.existsSync(path.join(context.workspace, 'contract-output.txt')) @@ -507,18 +1005,19 @@ export async function captureNativeScenario(name, options = {}) { capture.observations.policyEffects.userDeniedWrite = fs.existsSync(path.join(context.workspace, 'denied-by-user.txt')) } } - } else if (!['text-history', 'active-input-stop', 'model-effort-usage', 'parent-child-read', 'plan-question-controls', 'mcp-form-url'].includes(name)) { + } else if (!['text-history', 'active-input-stop', 'model-effort-usage', 'parent-child-read', 'plan-question-controls', 'mcp-form-url'].includes(name) && !options.approvalControl) { throw new Error(`CODEX_CONTRACT_UNIMPLEMENTED: ${name}; text-only execution does not establish this scenario`) } await client.close() client = new NativeClient(capture.binary.path, context, capture) await client.request('initialize', { clientInfo: { name: 'freshell_codex_contract_resume', version: '1.0.0' }, capabilities }) client.send({ method: 'initialized', params: {} }) - const resumedThread = await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, persistExtendedHistory: true }) + const resumedThread = await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, ...(options.approvalControl === 'network' ? { permissions: 'fixture_network' } : { sandbox: sandboxMode }), persistExtendedHistory: true }) capture.observations.resumeApprovalPolicy = resumedThread.approvalPolicy assert.equal(resumedThread.approvalPolicy, policy) const resumed = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) capture.reads.push({ label: 'cold-resume', body: resumed }) + if (options.largeOutput) await verifyLargeOutput(capture, resumed, context, turn.turn.id, 'cold-resume', options.historyMode) capture.observations.resumedAssistantText = assistantText(resumed, turn.turn.id) assert.equal(capture.observations.resumedAssistantText, capture.observations.finalAssistantText) if (name === 'parent-child-read') { @@ -546,6 +1045,7 @@ export async function captureNativeScenario(name, options = {}) { if (policy === 'untrusted') assert(capture.observations.commandApprovals.some(approval => approval.decision === 'cancel'), 'denied command must receive an actual native cancellation') } } + if (options.approvalControl === 'network') assert.equal(capture.observations.networkCases?.length, 5, 'native network once/session/no-repeat/decline/cancel evidence is required') capture.passed = true } catch (error) { capture.passed = false @@ -557,12 +1057,16 @@ export async function captureNativeScenario(name, options = {}) { releaseStream?.() childStream.resolve() parentStream.resolve() + if (context.network) await context.network.close() if (fixture) { capture.fixtureTraffic = fixture.traffic; await fixture.close() } if (context.mcpTrace && fs.existsSync(context.mcpTrace)) capture.observations.mcpTraffic = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) capture.environment.ambientAfter = ambientCodex() assert.equal(capture.environment.ambientAfter, capture.environment.ambientBefore) - await fsp.writeFile(path.join(context.receiptRoot, `${name}-${randomUUID()}.json`), `${JSON.stringify(capture, null, 2)}\n`, { mode: 0o600 }) - if (options.emitReceipt) process.stdout.write(`${JSON.stringify({ nativeCapture: true, capture })}\n`) + const persisted = sanitizedCapture(capture, capture.secretValues) + const json = JSON.stringify(persisted) + await fsp.writeFile(path.join(context.receiptRoot, `${name}-${randomUUID()}.json`), `${json}\n`, { mode: 0o600 }) + if (options.emitReceipt) process.stdout.write(`${JSON.stringify({ nativeCapture: true, capture: persisted })}\n`) + if (process.env.FRESHELL_CODEX_CONTRACT_CAPTURE_STDOUT === '1') exportCloudCapture(persisted, json) } return capture } @@ -657,16 +1161,20 @@ async function main(args) { else if (args[index] === '--probe-uid') options.probeUid = args[++index] else if (args[index] === '--probe-bundled-bwrap') options.preferBundledBwrap = true else if (args[index] === '--history-mode') { options.historyMode = args[++index]; assert(['legacy', 'paginated'].includes(options.historyMode)) } + else if (args[index] === '--persistence-mode') { options.persistenceMode = args[++index]; assert(['delayed', 'missing'].includes(options.persistenceMode)) } else if (args[index] === '--duplicate-bodies') options.duplicateBodies = true + else if (args[index] === '--large-output') options.largeOutput = true + else if (args[index] === '--startup-cancellation') options.startupCancellation = true else if (args[index] === '--ephemeral-parent') options.ephemeralParent = true else if (args[index] === '--probe-network-enabled') options.probeNetworkEnabled = true + else if (args[index] === '--approval-control') { options.approvalControl = args[++index]; assert(['stdin', 'network'].includes(options.approvalControl)) } else throw new Error(`unknown argument ${args[index]}`) } if (options.probePolicyEffects || options.probeApprovalPolicy || options.probeDefaultPolicy || options.legacyLandlock) assert(options.probeMetadata || options.probeSeccomp || options.seccompProbeReceipt, 'probe options require an explicit candidate manifest or declared seccomp probe') if (managed) return managedVersion(options) - for (const name of scenario === 'all' ? scenarios : [scenario]) { - const capture = await captureNativeScenario(name, options) - console.log(JSON.stringify({ severity: 'info', event: 'codex_native_scenario_passed', scenario: name, version: capture.binary.version, threadId: capture.threadId })) + for (const [name, selected] of scenario === 'all' ? nativeContractCases() : [[scenario, {}]]) { + const capture = await captureNativeScenario(name, { ...options, ...selected }) + console.log(JSON.stringify({ severity: 'info', event: 'codex_native_scenario_passed', scenario: name, parameters: capture.parameters, version: capture.binary.version, threadId: capture.threadId })) } } diff --git a/test/integration/providers/codex-cli-contract.test.ts b/test/integration/providers/codex-cli-contract.test.ts index 173b6f74e..c5215f342 100644 --- a/test/integration/providers/codex-cli-contract.test.ts +++ b/test/integration/providers/codex-cli-contract.test.ts @@ -27,4 +27,47 @@ describe('Codex 0.164.0 release contract through an isolated Responses provider' expect(capture.fixtureTraffic.length).toBeGreaterThan(0) }, 180_000, ) + it.each(['read-only', 'workspace-write'])('executes restricted never policy with actual %s effects', async sandboxMode => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true, timeoutMs: 120_000 }) + expect(capture.observations.restrictedCommandExecuted).toBe(true) + expect(capture.observations.policyEffects).toEqual(expect.objectContaining({ outsideWrite: false, gitWrite: false, codexWrite: false, networkRequests: 0 })) + expect(capture.observations.workspaceWritePresent).toBe(sandboxMode === 'workspace-write') + }, 180_000) + + it.each(['stdin', 'network'])('executes actual %s approval effects and cancellation', async approvalControl => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { approvalControl, timeoutMs: 120_000 }) + expect(capture.passed).toBe(true) + expect(approvalControl === 'stdin' ? capture.observations.stdinCases.length : capture.observations.networkCases.length).toBe(approvalControl === 'stdin' ? 4 : 5) + }, 180_000) + + it('cancels genuine MCP startup without inventing a turn terminal', async () => { + const capture = await captureNativeScenario('active-input-stop', { startupCancellation: true, timeoutMs: 120_000 }) + expect(capture.observations.startupCancellation.nativeStartupResult.status).toBe('cancelled') + expect(capture.fixtureTraffic).toHaveLength(0) + }, 180_000) + + it('reads ephemeral child metadata without taking a writer role', async () => { + const capture = await captureNativeScenario('parent-child-read', { ephemeralParent: true, timeoutMs: 120_000 }) + expect(capture.observations.childAvailability).toBe('ephemeral_history_unavailable') + expect(capture.observations.childObserverMethods.every((method: string) => method === 'thread/read')).toBe(true) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures actual large command and MCP persistence in %s history', async historyMode => { + const capture = await captureNativeScenario('mcp-form-url', { historyMode, largeOutput: true, timeoutMs: 120_000 }) + expect(capture.observations.largeOutputReads.map((read: any) => read.label)).toEqual(['warm', 'cold-resume']) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures delayed persistence and finite failed refresh in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, persistenceMode: 'delayed', timeoutMs: 120_000 }) + expect(capture.observations.boundedRefresh).toHaveLength(3) + expect(capture.observations.retainedCompletedText).toBe('First fragment and final fragment') + expect(['saved-after-owned-blocker-release-and-cold-recovery', 'retained-unsaved-provider-unavailable-refresh-stopped']).toContain(capture.observations.persistenceState) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures genuinely missing persistence after owned exit in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, persistenceMode: 'missing', timeoutMs: 120_000 }) + expect(capture.observations.coldMissingFaultRemoved).toBe(true) + expect(capture.observations.coldRecoveryState).toBe('saved-history-unavailable-no-invented-fragments') + }, 180_000) + }) diff --git a/test/integration/providers/codex-local-provider.mjs b/test/integration/providers/codex-local-provider.mjs index 7ccafd936..8a27dfa39 100644 --- a/test/integration/providers/codex-local-provider.mjs +++ b/test/integration/providers/codex-local-provider.mjs @@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import assert from 'node:assert/strict' +export const MCP_LARGE_TEXT = 'F1_MCP_HEAD\n' + 'm'.repeat(128 * 1024) + '\nF1_MCP_TAIL' export const MCP_FORM_CONTENT = { name: 'fixture', count: 2.5, enabled: true, color: 'blue' } +export const CODEX_NETWORK_REQUIREMENTS = '[experimental_network]\nenabled = true\nallow_local_binding = true\nallow_upstream_proxy = false\n' const FORM_SCHEMA = { type: 'object', properties: { name: { type: 'string', title: 'Name' }, count: { type: 'number', title: 'Count' }, enabled: { type: 'boolean', title: 'Enabled' }, color: { type: 'string', title: 'Color', enum: ['blue', 'green'] } }, required: ['name', 'count', 'enabled', 'color'] } -function serveMcpFixture(tracePath, url) { +function serveMcpFixture(tracePath, url, holdStartup = false) { const pending = new Map() let nextId = 1 const trace = record => fs.appendFileSync(tracePath, `${JSON.stringify(record)}\n`, { mode: 0o600 }) @@ -31,9 +33,13 @@ function serveMcpFixture(tracePath, url) { return } if (frame.id === undefined) return + if (frame.method === 'initialize' && holdStartup) { trace({ event: 'startup_initialize_held', id: frame.id }); return } if (frame.method === 'initialize') send({ id: frame.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'freshell-contract', version: '1' } } }) else if (frame.method === 'ping') send({ id: frame.id, result: {} }) - else if (frame.method === 'tools/list') send({ id: frame.id, result: { tools: [{ name: 'elicit', description: 'Run the selected native elicitation fixture.', inputSchema: { type: 'object', properties: { route: { type: 'string', enum: ['form', 'openai/form', 'openaiForm', 'url'] }, sequence: { type: 'string', enum: ['matrix', 'hold'] } }, required: ['route', 'sequence'], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }] } }) + else if (frame.method === 'tools/list') send({ id: frame.id, result: { tools: [{ name: 'large_result', description: 'Return a bounded native persistence fixture.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }, { name: 'elicit', description: 'Run the selected native elicitation fixture.', inputSchema: { type: 'object', properties: { route: { type: 'string', enum: ['form', 'openai/form', 'openaiForm', 'url'] }, sequence: { type: 'string', enum: ['matrix', 'hold'] } }, required: ['route', 'sequence'], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }] } }) + else if (frame.method === 'tools/call' && frame.params.name === 'large_result') { + send({ id: frame.id, result: { content: [{ type: 'text', text: MCP_LARGE_TEXT }], structuredContent: { fixture: 'F1_MCP_STRUCTURED', count: 1 }, isError: false, _meta: { 'fixture/result': 'F1_MCP_META' } } }) + } else if (frame.method === 'tools/call') { const { route, sequence } = frame.params.arguments const results = [] @@ -41,7 +47,7 @@ function serveMcpFixture(tracePath, url) { const method = route === 'openai/form' ? 'openai/form' : route === 'openaiForm' ? 'openai/elicitation/create' : 'elicitation/create' const params = route === 'url' ? { mode: 'url', message: 'Open the fixture page, then choose an action.', url: `${url}/elicitation/example`, elicitationId: `fixture-url-${nextId}` } : { ...(route === 'openai/form' ? {} : { mode: 'form' }), message: 'Fixture form', requestedSchema: FORM_SCHEMA } const result = await request(method, params) - trace({ event: 'elicitation_result', route, expectedAction: action, result }) + trace({ event: 'elicitation_result', route, sequence, expectedAction: action, result }) if (result.action !== action) throw new Error(`MCP route ${route} returned the wrong action`) if (action === 'accept' && route !== 'url') assert.deepEqual(result.content, MCP_FORM_CONTENT, 'MCP form content must preserve typed native answers') results.push(result) @@ -53,7 +59,7 @@ function serveMcpFixture(tracePath, url) { let frame try { frame = JSON.parse(line) } catch { return } handle(frame).catch(error => { - trace({ event: 'fixture_error', message: error.message }) + trace({ event: frame.params?.arguments?.sequence === 'hold' ? 'fixture_request_cancelled' : 'fixture_error', message: error.message }) if (frame.id !== undefined) send({ id: frame.id, error: { code: -32603, message: error.message } }) }) }) @@ -118,4 +124,4 @@ export async function createCodexLocalProvider(handler) { } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--mcp-fixture') serveMcpFixture(process.argv[3], process.argv[4]) +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && ['--mcp-fixture', '--mcp-startup-fixture'].includes(process.argv[2])) serveMcpFixture(process.argv[3], process.argv[4], process.argv[2] === '--mcp-startup-fixture') From 37519918706e0391cb61560f09cfecc197c8a560 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:48:34 -0700 Subject: [PATCH 05/92] feat(codex): preserve approval intent through prepared launches --- .../freshell-agent-runtime/src/host_actor.rs | 16 + .../src/host_actor_tests.rs | 1 + crates/freshell-codex/Cargo.toml | 2 + crates/freshell-codex/src/app_server.rs | 296 +++++ crates/freshell-freshagent/src/codex.rs | 164 +++ crates/freshell-platform/src/codex_inputs.rs | 15 + crates/freshell-runtime-protocol/src/lib.rs | 73 ++ .../src/provider_inputs.rs | 2 + .../freshell-server/src/fresh_agent_proxy.rs | 34 +- .../src/fresh_agent_proxy_tests.rs | 153 ++- .../src/managed_mcp_capability.rs | 1003 +++++++++++++++-- .../managed_mcp_capability/amplifier_stage.rs | 1 + .../managed_mcp_capability/ordinary_stage.rs | 71 +- .../src/managed_provider_bootstrap.rs | 2 + .../src/managed_provider_references/codex.rs | 188 ++- crates/freshell-server/src/managed_runtime.rs | 5 +- crates/freshell-session-host/src/main.rs | 1 + .../src/provider_input_preparation.rs | 2 + .../src/providers/fresh_agent.rs | 52 +- crates/freshell-supervisor/src/backend.rs | 1 + .../src/provider_callback_budget_tests.rs | 66 +- crates/freshell-supervisor/src/service.rs | 35 + docker/cloud-run/Dockerfile | 15 +- scripts/sandbox-test.sh | 4 + scripts/testing/prepare-codex-contract.mjs | 51 +- .../codex-app-server/fake-app-server.mjs | 7 +- .../providers/codex-cli-contract.mjs | 136 ++- .../providers/codex-cli-contract.test.ts | 22 +- .../providers/codex-local-provider.mjs | 4 + .../FreshAgentView.reconcile.test.tsx | 29 + 30 files changed, 2289 insertions(+), 162 deletions(-) diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 7710abe79..0c21cfd9c 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -76,6 +76,22 @@ pub struct FreshAgentProfile { } impl FreshAgentProfile { + pub fn effective_permission_mode(&self) -> Option<&str> { + self.permission_mode.as_deref().or_else(|| { + if self.provider != FreshProvider::Codex { + return None; + } + match &self.provider_launch_context.as_ref()?.preparation { + freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default, .. + } => approval_default + .as_ref() + .map(|default| default.policy.as_str()), + _ => None, + } + }) + } + /// Folds per-turn settings into the durable launch profile: every /// provided field replaces the stored one, absent fields keep it. pub fn apply_turn_settings(&mut self, settings: &FreshAgentTurnSettings) { diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index f7f1ca000..1c6ac07fd 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -1063,6 +1063,7 @@ async fn current_provider_preparation_reopens_the_same_fresh_conversation() { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: Vec::new(), }, diff --git a/crates/freshell-codex/Cargo.toml b/crates/freshell-codex/Cargo.toml index 76dae4163..f48385220 100644 --- a/crates/freshell-codex/Cargo.toml +++ b/crates/freshell-codex/Cargo.toml @@ -31,6 +31,8 @@ publish.workspace = true # feature (verified to compile; wired live in the next step). Default-off keeps the shared # workspace build lean. default = [] +# Explicit connected preparation builds this probe; native lanes execute it offline. +native-policy-qualification = ["real-transport"] real-transport = ["dep:tokio-tungstenite", "dep:futures-util", "dep:libc", "dep:freshell-platform", "tokio/net", "tokio/io-util", "tokio/process"] [dependencies] diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index d7ff4ea8b..7dccc7582 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -317,11 +317,78 @@ impl CodexAppServerClient { self.codex_home.lock().await.clone() } + /// 0.164 may warn and fall back when managed requirements constrain the + /// requested policy. Check before submitting work; never turn that fallback + /// into a silent replacement of prepared untrusted intent. + async fn check_untrusted_requirements( + &self, + policy: Option<&str>, + ) -> Result<(), CodexAppServerError> { + if policy != Some("untrusted") { + return Ok(()); + } + let result = self.request("configRequirements/read", json!({})).await?; + let requirements = + result + .get("requirements") + .ok_or_else(|| CodexAppServerError::InvalidResponse { + method: "configRequirements/read".into(), + detail: + "missing native requirements while preserving approval_policy untrusted" + .into(), + })?; + let policies = requirements.get("allowedApprovalPolicies"); + if policies.is_some_and(|policies| { + !policies.is_null() + && !policies.as_array().is_some_and(|allowed| { + allowed + .iter() + .any(|policy| policy.as_str() == Some("untrusted")) + }) + }) { + tracing::warn!( + event = "codex.approval_policy_incompatible", + setting = "approval_policy", + requested = "untrusted", + stage = "requirements" + ); + return Err(CodexAppServerError::InvalidResponse { + method: "configRequirements/read".into(), detail: "Codex approval_policy untrusted is disallowed by native requirements; prepared approval intent was preserved and no work was submitted".into(), + }); + } + Ok(()) + } + + pub fn check_effective_approval_policy( + result: &Value, + requested: Option<&str>, + method: &str, + ) -> Result<(), CodexAppServerError> { + if requested == Some("untrusted") + && result.get("approvalPolicy").and_then(Value::as_str) != requested + { + tracing::warn!( + event = "codex.approval_policy_incompatible", + setting = "approval_policy", + requested = "untrusted", + method, + stage = "effective_response" + ); + return Err(CodexAppServerError::InvalidResponse { + method: method.into(), detail: "native response did not preserve requested approval_policy untrusted; refusing to submit work under a different policy".into(), + }); + } + Ok(()) + } + /// `thread/start` (`client.ts:168-186`) — the stable-from-create codex thread. pub async fn start_thread( &self, params: StartThreadParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; + let requested_policy = params.approval_policy.clone(); let mut wire = Map::new(); insert_opt_str(&mut wire, "cwd", params.cwd); insert_opt_str(&mut wire, "model", params.model); @@ -337,6 +404,11 @@ impl CodexAppServerClient { wire.insert("persistExtendedHistory".to_string(), json!(true)); let result = self.request("thread/start", Value::Object(wire)).await?; + Self::check_effective_approval_policy( + &result, + requested_policy.as_deref(), + "thread/start", + )?; let thread_id = result .get("thread") .and_then(|t| t.get("id")) @@ -368,6 +440,9 @@ impl CodexAppServerClient { thread_id: &str, params: StartThreadParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; + let requested_policy = params.approval_policy.clone(); let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(thread_id)); insert_opt_str(&mut wire, "cwd", params.cwd); @@ -384,6 +459,11 @@ impl CodexAppServerClient { let result = self .request_with_timeout("thread/resume", Value::Object(wire), self.read_timeout) .await?; + Self::check_effective_approval_policy( + &result, + requested_policy.as_deref(), + "thread/resume", + )?; let resumed_thread_id = result .get("thread") .and_then(|t| t.get("id")) @@ -413,6 +493,8 @@ impl CodexAppServerClient { &self, params: ThreadForkParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(params.thread_id)); insert_opt_str(&mut wire, "lastTurnId", params.last_turn_id); @@ -472,6 +554,8 @@ impl CodexAppServerClient { &self, params: StartTurnParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_ref().and_then(Value::as_str)) + .await?; let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(params.thread_id)); wire.insert("input".to_string(), Value::Array(params.input)); @@ -921,10 +1005,222 @@ pub fn new_channel_transport() -> (Arc, ChannelPeer) { mod tests { use super::*; + #[cfg(feature = "native-policy-qualification")] + #[tokio::test] + async fn native_requirements_refuse_untrusted_before_work() { + struct RecordingTransport { + native: crate::transport::TungsteniteTransport, + frames: Arc>>, + } + impl WsTransport for RecordingTransport { + fn send(&self, text: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.frames.lock().unwrap().push(json!({"direction":"controller", "frame":serde_json::from_str::(&text).unwrap()})); + self.native.send(text).await + }) + } + fn recv(&self) -> BoxFuture<'_, Option> { + Box::pin(async move { + let text = self.native.recv().await?; + self.frames.lock().unwrap().push(json!({"direction":"native", "frame":serde_json::from_str::(&text).unwrap()})); + Some(text) + }) + } + fn close(&self) -> BoxFuture<'_, ()> { + self.native.close() + } + } + let endpoint = std::env::var("FRESHELL_CODEX_POLICY_PROBE_URL") + .expect("explicit owned native endpoint required"); + assert!(endpoint.starts_with("ws://127.0.0.1:")); + let receipt_path = std::env::var("FRESHELL_CODEX_POLICY_PROBE_RECEIPT") + .expect("explicit owned receipt required"); + let frames = Arc::new(StdMutex::new(Vec::::new())); + let transport = RecordingTransport { + native: crate::transport::TungsteniteTransport::connect(&endpoint) + .await + .unwrap(), + frames: frames.clone(), + }; + let (client, _notifications) = CodexAppServerClient::connect(Arc::new(transport)); + let mut refused = Vec::new(); + for route in ["create", "resume", "fork", "send"] { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + let parent = "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e"; + let result = match route { + "create" => client.start_thread(params).await.map(|_| ()), + "resume" => client.resume_thread(parent, params).await.map(|_| ()), + "fork" => client + .fork_thread(ThreadForkParams { + thread_id: parent.into(), + approval_policy: Some("untrusted".into()), + ..Default::default() + }) + .await + .map(|_| ()), + _ => client + .start_turn(StartTurnParams { + thread_id: parent.into(), + approval_policy: Some(json!("untrusted")), + input: vec![json!({"type":"text","text":"must remain unsubmitted"})], + ..Default::default() + }) + .await + .map(|_| ()), + }; + let error = result.expect_err("native requirements must refuse before work"); + assert!( + matches!(&error, CodexAppServerError::InvalidResponse { method, .. } if method == "configRequirements/read"), + "{route}: {error}" + ); + assert!(error.to_string().contains("approval_policy untrusted")); + refused.push(route); + } + client.close().await; + let frames = frames.lock().unwrap(); + let requests: Vec<_> = frames + .iter() + .filter(|entry| entry["direction"] == "controller") + .map(|entry| &entry["frame"]) + .collect(); + assert_eq!( + requests + .iter() + .filter(|frame| frame["method"] == "configRequirements/read") + .count(), + 4 + ); + assert!(requests.iter().all(|frame| matches!( + frame["method"].as_str(), + Some("initialize" | "initialized" | "configRequirements/read") + ))); + let replies: Vec<_> = frames + .iter() + .filter(|entry| { + entry["direction"] == "native" + && entry["frame"]["result"]["requirements"]["allowedApprovalPolicies"] + .is_array() + }) + .collect(); + assert_eq!(replies.len(), 4); + assert!(replies + .iter() + .all( + |entry| entry["frame"]["result"]["requirements"]["allowedApprovalPolicies"] + == json!(["never"]) + )); + let receipt = json!({"event":"codex_native_policy_client_proof", "severity":"info", "refusedRoutes":refused, "frames":*frames, "workRequests":0}); + std::fs::write(&receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + println!( + "{}", + json!({"event":"codex_native_policy_client_proof", "severity":"info", "refusedRoutes":refused, "workRequests":0}) + ); + } + fn started_thread_result() -> Value { json!({ "thread": { "id": "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e" }, "reasoningEffort": "none" }) } + #[tokio::test] + async fn untrusted_requirements_refuse_before_native_work_on_every_typed_route() { + for route in ["create", "resume", "fork", "send"] { + let (transport, peer) = new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + let task = tokio::spawn(async move { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + match route { + "create" => client.start_thread(params).await.map(|_| ()), + "resume" => client.resume_thread("parent", params).await.map(|_| ()), + "fork" => client + .fork_thread(ThreadForkParams { + thread_id: "parent".into(), + approval_policy: Some("untrusted".into()), + ..Default::default() + }) + .await + .map(|_| ()), + _ => client + .start_turn(StartTurnParams { + thread_id: "parent".into(), + approval_policy: Some(json!("untrusted")), + input: vec![json!({"type":"text","text":"must remain unsubmitted"})], + ..Default::default() + }) + .await + .map(|_| ()), + } + }); + let (init, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&init, json!({"userAgent":"fixture", "codexHome":"/fixture", "platformFamily":"unix", "platformOs":"linux"})); + peer.expect_notification().await; + let (id, method, _) = peer.expect_request().await; + assert_eq!( + method, "configRequirements/read", + "{route} must check native requirements before work" + ); + peer.respond( + &id, + json!({"requirements":{"allowedApprovalPolicies":["never"]}}), + ); + let error = task + .await + .unwrap() + .expect_err("native requirement fallback must be refused"); + assert!(error.to_string().contains("approval_policy")); + assert!(error.to_string().contains("untrusted")); + } + } + + #[tokio::test] + async fn untrusted_native_effective_policy_mismatch_is_not_accepted() { + for route in ["create", "resume"] { + let (transport, peer) = new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + let task = tokio::spawn(async move { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + if route == "create" { + client.start_thread(params).await + } else { + client.resume_thread("parent", params).await + } + }); + let (init, _, _) = peer.expect_request().await; + peer.respond(&init, json!({"userAgent":"fixture", "codexHome":"/fixture", "platformFamily":"unix", "platformOs":"linux"})); + peer.expect_notification().await; + let (mut id, mut method, _) = peer.expect_request().await; + if method == "configRequirements/read" { + peer.respond(&id, json!({"requirements":null})); + (id, method, _) = peer.expect_request().await; + } + assert_eq!( + method, + if route == "create" { + "thread/start" + } else { + "thread/resume" + } + ); + peer.respond( + &id, + json!({"thread":{"id":"minted-or-resumed"}, "approvalPolicy":"never"}), + ); + assert!( + task.await.unwrap().is_err(), + "returned native effective policy must not silently replace untrusted intent" + ); + } + } + #[tokio::test] async fn initialize_handshake_sends_request_then_initialized_notification() { let (transport, peer) = new_channel_transport(); diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 2e0bbc63c..8f69a6db0 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -5027,6 +5027,26 @@ impl FreshCodexState { None, ); + // The minted child identity and its existing ownership/cancellation + // guards are now armed. Refuse a changed policy through the same + // post-archive containment as every later startup failure, before spawn. + if let Err(error) = CodexAppServerClient::check_effective_approval_policy( + &fork_result, + parent_permission_mode.as_deref(), + "thread/fork", + ) { + tracing::warn!(event="freshagent.codex.fork_policy_refused", parent_session_id=%parent_id, session_id=%child_id, error=%error); + self.fail_fork_after_archive( + &parent_client, + &child_id, + &parent_id, + &reply_sink, + &error.to_string(), + ) + .await; + return; + } + // ── post-archive (failure containment applies to every step below) ── let fork_spawn_slot = Arc::clone(&fork_start_pid_slot); let fork_spawn_state = self.clone(); @@ -16707,6 +16727,150 @@ pub(crate) mod tests { ); } + #[tokio::test] + async fn handle_fork_policy_mismatch_refuses_before_child_spawn_and_settles_cleanup() { + // Existing ENV_LOCK/configure_fake_codex_cmd make accidental RED-path + // spawns hermetic; this test does not need to launch a parent app-server. + let _env = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + + let (mut st, _rx, fake) = state_with_sink(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + + const PARENT: &str = "parent-untrusted-fork"; + const CHILD: &str = "child-policy-mismatch"; + let mut ticket = match st.begin_lane_claim_at(PARENT, "seed-parent", None, "test") { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("parent seed must grant"), + }; + assert!(st + .commit_lane_claim_at(&mut ticket, PARENT, PARENT, None) + .is_ok()); + + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _notifs) = CodexAppServerClient::connect(transport); + let parent_client = Arc::new(client); + insert_fake_session( + &st, + PARENT, + parent_client.clone(), + Arc::new(StdMutex::new(None)), + spawn_sleeper(), + "codex-policy-fork-parent", + ) + .await; + { + let mut sessions = st.sessions.lock().await; + sessions.get_mut(PARENT).unwrap().permission_mode = Some("untrusted".into()); + } + let parent_before = registry.observe(PROVIDER, PARENT); + let capture = tracing_capture::capture_by_session("fork-policy-test-unused"); + let (sink, captured) = capturing_sink(); + let driver = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_fork(fork_msg(PARENT, "fork-policy", None), None, sink) + .await; + } + }); + + answer_initialize(&peer).await; + let (requirements_id, method, _) = peer.expect_request().await; + assert_eq!(method, "configRequirements/read"); + peer.respond(&requirements_id, json!({"requirements": null})); + + let (fork_id, method, params) = peer.expect_request().await; + assert_eq!(method, "thread/fork"); + assert_eq!(params["threadId"], PARENT); + assert_eq!(params["approvalPolicy"], "untrusted"); + peer.respond( + &fork_id, + json!({ + "thread": {"id": CHILD}, + "approvalPolicy": "never" + }), + ); + + let (archive_id, method, params) = peer.expect_request().await; + assert_eq!(method, "thread/archive"); + assert_eq!(params["threadId"], CHILD); + peer.respond(&archive_id, json!({})); + + // The established post-archive containment must now run. Keep its + // response pending so guard ordering is observable without a sleep. + let (cleanup_id, method, params) = + tokio::time::timeout(std::time::Duration::from_secs(2), peer.expect_request()) + .await + .expect("mismatched fork must enter guarded post-archive cleanup"); + assert_eq!(method, "thread/unarchive"); + assert_eq!(params["threadId"], CHILD); + let child_during_cleanup = registry.observe(PROVIDER, CHILD); + assert!(child_during_cleanup.generation > 0); + assert!(matches!( + child_during_cleanup.state, + freshell_ownership::OwnershipState::Starting { .. } + )); + + // A witnessed, still-running cleanup must survive the normal stale + // start sweep; this fails if cancellation/settle was armed too late. + let recovered = registry.recover_stale_starts(0, 0); + assert!(!recovered.iter().any(|entry| entry.session_id == CHILD)); + assert!(matches!( + registry.observe(PROVIDER, CHILD).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + + peer.respond(&cleanup_id, json!({})); + tokio::time::timeout(std::time::Duration::from_secs(2), driver) + .await + .expect("fork refusal settles") + .expect("fork task"); + + assert_single_fork_error_frame( + &captured_frames(&captured), + PARENT, + "INTERNAL_ERROR", + "approval_policy untrusted", + ); + let child_after = registry.observe(PROVIDER, CHILD); + assert_eq!(child_after.generation, child_during_cleanup.generation); + assert!(matches!( + child_after.state, + freshell_ownership::OwnershipState::Vacant + )); + + let parent_after = registry.observe(PROVIDER, PARENT); + assert_eq!(parent_after.epoch, parent_before.epoch); + assert_eq!(parent_after.generation, parent_before.generation); + assert_eq!(parent_after.state, parent_before.state); + { + let sessions = st.sessions.lock().await; + assert_eq!(sessions.len(), 1); + let parent = sessions.get(PARENT).unwrap(); + assert!(Arc::ptr_eq(&parent.client, &parent_client)); + assert_eq!(parent.permission_mode.as_deref(), Some("untrusted")); + assert!(!parent.exited.load(Ordering::SeqCst)); + assert!(!sessions.contains_key(CHILD)); + } + assert_eq!(spawn_count(&capture), 0); + assert!(st.fork_in_flight.try_acquire(PARENT).is_some()); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == CHILD)); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), peer.next_frame()) + .await + .is_err() + ); + + peer.disconnect(); + st.shutdown().await; + } + #[tokio::test] async fn handle_fork_rpc_error_replies_on_the_sink_without_archiving_or_spawning() { let (st, _rx_boot) = state_with_bus(); diff --git a/crates/freshell-platform/src/codex_inputs.rs b/crates/freshell-platform/src/codex_inputs.rs index e5bea88ed..4c27f6a42 100644 --- a/crates/freshell-platform/src/codex_inputs.rs +++ b/crates/freshell-platform/src/codex_inputs.rs @@ -3,6 +3,19 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NativeSourceRuntime { + #[default] + Tui, + AppServer, +} +impl NativeSourceRuntime { + fn is_tui(&self) -> bool { + *self == Self::Tui + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CodexInputManifest { @@ -11,6 +24,8 @@ pub struct CodexInputManifest { pub cwd: PathBuf, pub codex_home: PathBuf, pub named_profile: Option, + #[serde(default, skip_serializing_if = "NativeSourceRuntime::is_tui")] + pub native_source_runtime: NativeSourceRuntime, } impl CodexInputManifest { pub fn validate(&self) -> Result<(), &'static str> { diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 2fe93202b..bd66e705e 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1022,6 +1022,35 @@ pub struct ProviderLaunchContext { pub config: Vec, } +/// Effective approval intent from the original active Codex config layers. +/// Only owned public copies omit the deprecated declaration; typed requests +/// retain this policy and its declaring source across preparation and replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexApprovalDefault { + pub policy: String, + pub logical_source: String, + pub canonical_source: String, + pub source_sha256: String, +} +impl CodexApprovalDefault { + pub fn validate(&self) -> bool { + matches!(self.policy.as_str(), "untrusted" | "on-request" | "never") + && [&self.logical_source, &self.canonical_source] + .iter() + .all(|source| { + source.len() <= 4096 + && std::path::Path::new(source).is_absolute() + && !source.chars().any(char::is_control) + }) + && self.source_sha256.len() == 64 + && self + .source_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum ProviderPreparation { @@ -1031,6 +1060,8 @@ pub enum ProviderPreparation { Codex { tui_args: Vec, sidecar_args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + approval_default: Option, }, Opencode { project_config: Vec, @@ -1126,7 +1157,14 @@ impl ProviderLaunchContext { ProviderPreparation::Codex { tui_args, sidecar_args, + approval_default, } => { + if approval_default + .as_ref() + .is_some_and(|policy| !policy.validate()) + { + return Err(invalid()); + } let valid_recipe = |args: &[String]| { args.len() == 6 && args[0] == "-c" @@ -2033,6 +2071,24 @@ impl TerminalLaunchSpec { "managed terminal launch exceeds argv/env/bootstrap bounds", )); } + if self.mode == "codex" { + let projected = self + .provider_launch_context + .as_ref() + .is_some_and(|context| { + matches!( + &context.preparation, + ProviderPreparation::Codex { + approval_default: Some(_), + .. + } + ) + }); + if self.provider_permission_mode.as_deref() == Some("untrusted") || projected { + return Err(RuntimeError::new(RuntimeErrorCode::InvalidRequest, + "Codex providerPermissionMode/approval_policy untrusted is incompatible with the native0.164 terminal CLI; this prepared intent requires Freshcodex typed app-server requests")); + } + } validate_managed_terminal_args(&self.mode, &self.args)?; for name in self.env.keys() { let upper = name.to_ascii_uppercase(); @@ -4281,6 +4337,23 @@ mod tests { assert_eq!(reference.source_path, "/private/keys.env"); } + #[test] + fn codex_terminal_untrusted_request_is_refused_before_dispatch() { + let mut spec: TerminalLaunchSpec = serde_json::from_value(serde_json::json!({ + "terminalId":"terminal", "streamId":"stream", "mode":"codex", "program":"codex", "args":[], "env":{}, "cwd":"/workspace", "runAsUid":65534, "runAsGid":0, "cols":80, "rows":24, "projectKey":"project", "workspacePath":"/workspace", "providerPermissionMode":"untrusted" + })).unwrap(); + let error = spec + .validate() + .expect_err("the native terminal CLI cannot preserve typed untrusted request intent"); + assert!( + error.message.contains("providerPermissionMode") && error.message.contains("untrusted") + ); + for policy in ["on-request", "never"] { + spec.provider_permission_mode = Some(policy.into()); + spec.validate().unwrap(); + } + } + #[test] fn managed_provider_options_are_bounded_and_control_free() { let mut spec = TerminalLaunchSpec { diff --git a/crates/freshell-runtime-protocol/src/provider_inputs.rs b/crates/freshell-runtime-protocol/src/provider_inputs.rs index 53b934bbc..75e30594b 100644 --- a/crates/freshell-runtime-protocol/src/provider_inputs.rs +++ b/crates/freshell-runtime-protocol/src/provider_inputs.rs @@ -734,6 +734,7 @@ mod tests { provider_launch_context: crate::ProviderLaunchContext { prepared_inputs: Some(prepared()), preparation: crate::ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, @@ -944,6 +945,7 @@ mod tests { let context = crate::ProviderLaunchContext { prepared_inputs: Some(prepared()), preparation: crate::ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 5c8e25ede..60f22b446 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -6,7 +6,8 @@ use freshell_freshagent::naming::{admit_pending_projection, session_projection, SessionNaming}; use freshell_freshagent::rest_lane::{ FreshAgentRestLane, RestLaneCapture, RestLaneCaptureError, RestLaneCaptureResult, - RestLaneCreate, RestLaneCreated, RestLaneSend, RestLaneSendResult, + RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneErrorKind, RestLaneSend, + RestLaneSendResult, }; use freshell_protocol::session_names::NamedProvider; use freshell_protocol::{ @@ -193,14 +194,19 @@ impl HostedFreshAgentProxy { match command { HostedFreshAgentCommand::Create(message) => { let request_id = message.request_id.clone(); - if self.create(message).await.is_err() { - tracing::warn!(request_id, "fresh_agent.create.failed"); + if let Err(mut error) = self.create(message).await { + tracing::warn!(request_id, code=%error.code, "fresh_agent.create.failed"); + if error.code == "FRESH_AGENT_UNAVAILABLE" { + error.code = "FRESH_AGENT_CREATE_FAILED".into(); + error.message = "managed fresh-agent host could not be created".into(); + error.retryable = Some(true); + } self.send(ServerMessage::FreshAgentCreateFailed( FreshAgentCreateFailed { - code: "FRESH_AGENT_CREATE_FAILED".into(), - message: "managed fresh-agent host could not be created".into(), + code: error.code, + message: error.message, request_id, - retryable: Some(true), + retryable: error.retryable, owner_kind: None, owner_generation: None, owner_epoch: None, @@ -491,12 +497,12 @@ impl HostedFreshAgentProxy { async fn create( self: &Arc, message: freshell_protocol::FreshAgentCreate, - ) -> Result, ()> { + ) -> Result, RestLaneError> { 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() { if session_ref.provider != public_provider { - return Err(()); + return Err(().into()); } let inventory = self.client.inventory().await.map_err(|_| ())?; let view = inventory @@ -622,6 +628,8 @@ impl HostedFreshAgentProxy { let original = |name: &str| std::env::var(name).ok(); let context = crate::managed_provider_references::codex::prepare_managed( crate::managed_provider_references::codex::CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, client: &self.client, soul: &soul, creation_seed: &message.request_id, @@ -635,7 +643,15 @@ impl HostedFreshAgentProxy { ) .await .map_err(|error| { - tracing::warn!(event="fresh_agent.create.provider_inputs_failed",%error); + tracing::warn!(event="fresh_agent.create.provider_inputs_failed", provider="codex", request_id=%message.request_id, code=?error.code, message=%error.message); + let configuration = error.code == RuntimeErrorCode::InvalidRequest; + let mut refusal = RestLaneError::new( + if configuration { RestLaneErrorKind::BadRequest } else { RestLaneErrorKind::Unavailable }, + if configuration { "INVALID_REQUEST" } else { "CODEX_PREPARATION_UNAVAILABLE" }, + error.message, + ); + refusal.retryable = Some(!configuration); + refusal })?; (context.mcp_capability.clone().ok_or(())?, Some(context)) } else if matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy) { diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 98c172b6e..8f854c356 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -33,6 +33,7 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() let args = crate::managed_provider_bootstrap::managed_codex_mcp_args().unwrap(); let context = freshell_runtime_protocol::ProviderLaunchContext { preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, tui_args: args.clone(), sidecar_args: args.clone(), }, @@ -44,6 +45,7 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() let freshell_runtime_protocol::ProviderPreparation::Codex { tui_args, sidecar_args, + .. } = context.preparation else { panic!("Codex preparation missing") @@ -155,6 +157,149 @@ fn configure_proxy( (proxy, receiver) } +async fn hosted_codex_preparation_refusal_case(rest: bool, operational: bool) { + let mut environment = crate::test_env_lock::TestEnv::lock().await; + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&workspace).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write( + &source, + if operational { + "approval_policy = 'never'\n" + } else { + "profile = 'legacy'\n" + }, + ) + .unwrap(); + environment.set("HOME", &home); + environment.set("CODEX_HOME", home.join(".codex")); + let socket = owned.path().join("supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let observed = calls.clone(); + let (stop, mut stopping) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + loop { + let accepted = tokio::select! { _ = &mut stopping => break, accepted = listener.accept() => accepted }; + let (mut stream, _) = accepted.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + observed.lock().await.push(request.body.clone()); + let result = match request.body { + AdminCommand::Health => Ok(AdminResult::Health { control_epoch: 1, installation_id: InstallationId::new() }), + AdminCommand::ReserveProviderInputs(_input) if operational => { + Err(freshell_runtime_protocol::RuntimeError::new(RuntimeErrorCode::BackendUnavailable, "owned supervisor unavailable")) + } + AdminCommand::ReserveProviderInputs(input) => Ok(AdminResult::ProviderInputReservation(serde_json::from_value(serde_json::json!({ + "reservationId":"owned-config-reservation", "installationId":InstallationId::new(), + "soulId":input.soul_id,"provider":input.provider,"creationSeedRef":input.creation_seed_ref, + "intentRevision":0,"providerVolumeName":"owned-provider-volume","imageRef":"fixture-image", + "hostBinaryPath":"/opt/freshell-session-host","hostBinarySha256":"a".repeat(64), + "observation":{"kind":"absent","daemonId":"owned-test-daemon"} + })).unwrap())), + other => panic!("configuration refusal must not launch or mutate runtime ownership: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result, + }, + ) + .await + .unwrap(); + } + }); + let (proxy, mut receiver) = configure_proxy(&socket, HashMap::new()); + let request_id = "codex-configuration-refusal"; + if rest { + let error = proxy + .clone() + .create_agent(RestLaneCreate { + request_id: request_id.into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + cwd: Some(workspace.to_str().unwrap().into()), + model: Some("original-model".into()), + effort: Some("high".into()), + permission_mode: Some("untrusted".into()), + ..Default::default() + }) + .await + .expect_err("unsupported native source must refuse before launch"); + assert_eq!( + error.kind, + freshell_freshagent::rest_lane::RestLaneErrorKind::BadRequest + ); + assert_eq!(error.code, "INVALID_REQUEST"); + assert_eq!(error.retryable, Some(false)); + assert!( + error.message.contains("profile") && error.message.contains(source.to_str().unwrap()), + "{}", + error.message + ); + } else { + let message = serde_json::from_value(serde_json::json!({ + "requestId":request_id,"sessionType":"freshcodex","provider":"codex", "cwd":workspace, + "model":"original-model","effort":"high","permissionMode":"untrusted","sandbox":"read-only" + })).unwrap(); + proxy + .clone() + .handle(HostedFreshAgentCommand::Create(message)) + .await; + let frame: serde_json::Value = serde_json::from_str(&receiver.try_recv().unwrap()).unwrap(); + assert_eq!(frame["type"], "freshAgent.create.failed"); + assert_eq!(frame["requestId"], request_id); + if operational { + assert_ne!( + frame["code"], "INVALID_REQUEST", + "a supervisor outage is not invalid user configuration" + ); + assert_eq!(frame["retryable"], true); + assert!(frame["message"] + .as_str() + .unwrap() + .contains("owned supervisor unavailable")); + } else { + assert_eq!(frame["code"], "INVALID_REQUEST"); + assert_eq!(frame["retryable"], false); + let message = frame["message"].as_str().unwrap(); + assert!( + message.contains("profile") && message.contains(source.to_str().unwrap()), + "{message}" + ); + } + assert!( + receiver.try_recv().is_err(), + "one initiating-request failure, no created event" + ); + } + stop.send(()).unwrap(); + settled(server).await; + assert!(!calls + .lock() + .await + .iter() + .any(|command| matches!(command, AdminCommand::Launch(_)))); + assert!(proxy.aliases.lock().await.is_empty()); + assert!(proxy.pollers.lock().await.is_empty()); +} + +#[tokio::test] +async fn hosted_codex_preparation_refusal_reaches_the_initiating_request() { + hosted_codex_preparation_refusal_case(false, false).await; +} +#[tokio::test] +async fn hosted_codex_preparation_refusal_keeps_rest_configuration_diagnostics() { + hosted_codex_preparation_refusal_case(true, false).await; +} +#[tokio::test] +async fn hosted_codex_preparation_refusal_distinguishes_control_outage() { + hosted_codex_preparation_refusal_case(false, true).await; +} + /// The fake supervisor must have served every expected request by now; a /// proxy that never called it fails here instead of hanging the suite. async fn settled(server: tokio::task::JoinHandle) -> T { @@ -1446,7 +1591,8 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au ] { assert_eq!( snapshot_route_value(&app, kind, provider, id).await.0, - axum::http::StatusCode::SERVICE_UNAVAILABLE + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{kind}/{id} must have unavailable exact native source" ); } // Exact-looking names cannot substitute for a readable, matching native source. @@ -1454,7 +1600,7 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au std::fs::write(&wrong_codex, std::fs::read(&codex_path).unwrap()).unwrap(); let unreadable_claude = claude_path.with_file_name("55555555-5555-4555-8555-555555555555.jsonl"); - std::fs::write(&unreadable_claude, [0xff, 0xfe]).unwrap(); + std::fs::write(&unreadable_claude, [0xff, 0xfe, b'\n']).unwrap(); for (kind, provider, id) in [ ("freshcodex", "codex", "wrong-native"), ( @@ -1465,7 +1611,8 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au ] { assert_eq!( snapshot_route_value(&app, kind, provider, id).await.0, - axum::http::StatusCode::SERVICE_UNAVAILABLE + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{kind}/{id} must have unavailable exact native source" ); } for (path, (bytes, modified)) in paths.iter().zip(before) { diff --git a/crates/freshell-server/src/managed_mcp_capability.rs b/crates/freshell-server/src/managed_mcp_capability.rs index 61e83bfad..9f6d7d13c 100644 --- a/crates/freshell-server/src/managed_mcp_capability.rs +++ b/crates/freshell-server/src/managed_mcp_capability.rs @@ -319,6 +319,8 @@ struct CapabilityCallback { workspace: Option, auth_lookup_authority: Option, claude_recovery_source: Option, + #[serde(default)] + codex_source_runtime: Option, cwd: Option, provider_secret_references: Option>, prior_context: Option, @@ -590,15 +592,52 @@ async fn apply_prepared_callback( provider, callback.prior_context.as_ref(), )? { - if let Some(plan) = ¤t_claude_plan { - confirm_claude_callback_candidate(&callback, store, directory, client, plan, &reply) + let mut replay = true; + if provider == "codex" { + use freshell_platform::codex_inputs::{CodexInputManifest, NativeSourceRuntime}; + let manifest: CodexInputManifest = serde_json::from_slice(&read_prior_manifest_bytes( + provider, + store, + directory, + &callback.soul_id, + &reply.provider_launch_context, + )?) + .map_err(|_| "pending Codex source manifest metadata invalid")?; + manifest.validate().map_err(str::to_string)?; + let current = callback.codex_source_runtime.unwrap_or_default(); + if manifest.native_source_runtime != current { + if current != NativeSourceRuntime::AppServer { + return Err("Codex current consumer cannot use the pending app-server preparation; retain inputs and reconcile".into()); + } + // A legacy candidate may still contain a public untrusted + // declaration. Reprepare its accepted selectors, never relabel + // copied bytes. Existing pending ownership fences own cleanup. + store.abort_preparation( + &reply + .provider_launch_context + .mcp_capability + .as_ref() + .ok_or("pending Codex capability unavailable")? + .grant_id, + &callback.soul_id, + directory, + )?; + replay = false; + } + } + if replay { + if let Some(plan) = ¤t_claude_plan { + confirm_claude_callback_candidate( + &callback, store, directory, client, plan, &reply, + ) .await?; + } + return freshell_runtime_protocol::bounded_json_bytes( + &reply, + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, + ) + .map_err(|_| "replacement provider context exceeds control metadata profile".into()); } - return freshell_runtime_protocol::bounded_json_bytes( - &reply, - freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, - ) - .map_err(|_| "replacement provider context exceeds control metadata profile".into()); } if matches!(provider, "codex" | "claude" | "amplifier") { let mut context = callback @@ -625,10 +664,13 @@ async fn apply_prepared_callback( } let origins = match provider { "codex" => { - let manifest: freshell_platform::codex_inputs::CodexInputManifest = + let mut manifest: freshell_platform::codex_inputs::CodexInputManifest = serde_json::from_slice(&bytes) .map_err(|_| "prior Codex source manifest metadata invalid")?; manifest.validate().map_err(str::to_string)?; + // Only the authenticated current consumer supplies this role. + // Accepted selectors and the old generation remain unchanged. + manifest.native_source_runtime = callback.codex_source_runtime.unwrap_or_default(); AcceptedOrigins::Codex(manifest) } "claude" => { @@ -2224,6 +2266,9 @@ fn stage_provider_root( )?; } if let Some(resources) = &ordinary_resources { + if provider == "codex" { + ordinary_stage::stage_codex_approval(&temporary, resources)?; + } if provider == "claude" { claude_stage::stage_settings( &temporary, @@ -2806,6 +2851,32 @@ impl CapabilityStore { .map_err(|_| "ordinary prepared inputs unavailable")?, ); context.config.clear(); + if provider == "codex" { + let descriptor = root.join(".freshell/codex-approval.json"); + if descriptor.exists() { + let bytes = source_stage::read_source_view( + root, + ".freshell/codex-approval.json", + )?; + let default: freshell_runtime_protocol::CodexApprovalDefault = + serde_json::from_str(&bytes).map_err(|_| { + "Codex approval_policy prepared intent unavailable" + })?; + if !default.validate() { + return Err( + "Codex approval_policy prepared source metadata is invalid" + .into(), + ); + } + if let freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default, + .. + } = &mut context.preparation + { + *approval_default = Some(default); + } + } + } } let result = publish_prepared_context_metadata( root, @@ -3729,6 +3800,84 @@ mod tests { } } + // A file-specific kernel read event works on noatime artifact volumes too. + // Other Unix targets retain their existing access-time observation. + #[cfg(unix)] + struct PayloadReadWatch { + #[cfg(target_os = "linux")] + notification: std::fs::File, + #[cfg(not(target_os = "linux"))] + path: PathBuf, + } + #[cfg(unix)] + impl PayloadReadWatch { + fn new(path: &Path) -> Self { + #[cfg(target_os = "linux")] + { + use std::os::{fd::FromRawFd, unix::ffi::OsStrExt}; + let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!(fd >= 0, "{}", std::io::Error::last_os_error()); + let notification = unsafe { std::fs::File::from_raw_fd(fd) }; + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert!( + unsafe { libc::inotify_add_watch(fd, path.as_ptr(), libc::IN_ACCESS) } >= 0, + "{}", + std::io::Error::last_os_error() + ); + Self { notification } + } + #[cfg(not(target_os = "linux"))] + { + let result = Self { + path: path.to_owned(), + }; + result.reset_access_time(); + result + } + } + #[cfg(not(target_os = "linux"))] + fn reset_access_time(&self) { + std::fs::File::open(&self.path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_accessed(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + } + fn take_access(&self) -> bool { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + let mut events = [0u8; 4096]; + let count = unsafe { + libc::read( + self.notification.as_raw_fd(), + events.as_mut_ptr().cast(), + events.len(), + ) + }; + if count < 0 { + assert_eq!( + std::io::Error::last_os_error().kind(), + std::io::ErrorKind::WouldBlock + ); + false + } else { + assert!(count > 0); + true + } + } + #[cfg(not(target_os = "linux"))] + { + let accessed = std::fs::metadata(&self.path).unwrap().accessed().unwrap() + != std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); + self.reset_access_time(); + accessed + } + } + } + #[test] fn opencode_reissue_omits_a_removed_discovered_source() { let owned = tempfile::tempdir().unwrap(); @@ -3992,22 +4141,16 @@ mod tests { .open(&path) .unwrap(); file.set_len(128 * 1024 + 1).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); file.read_exact(&mut [0]).unwrap(); - assert_ne!( - file.metadata().unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must observe payload reads" + assert!( + watch.take_access(), + "fixture must observe actual payload reads" ); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); drop(file); assert!(protected_preparation_ids(owned.path()).is_err()); - assert_eq!( - std::fs::metadata(path).unwrap().accessed().unwrap(), - old_access, + assert!( + !watch.take_access(), "oversized preparation inventory payload was read" ); } @@ -4283,19 +4426,12 @@ mod tests { .open(&path) .unwrap(); sparse.set_len(64 * 1024 * 1024 + 1).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); - sparse - .set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); std::io::Read::read_exact(&mut sparse, &mut [0u8; 1]).unwrap(); - assert_ne!( - std::fs::metadata(&path).unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must report actual payload reads" + assert!( + watch.take_access(), + "fixture must report actual payload reads" ); - sparse - .set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); drop(sparse); let pages: u64 = std::fs::read_to_string("/proc/self/statm") .unwrap() @@ -4312,9 +4448,8 @@ mod tests { println!("recovery_sparse_manifest_reader_ready"); assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_AS, &limit) }, 0); assert!(read_prior_manifest_bytes("codex", &store, &directory, &soul, &accepted).is_err()); - assert_eq!( - std::fs::metadata(&path).unwrap().accessed().unwrap(), - old_access, + assert!( + !watch.take_access(), "oversized manifest payload was read before its physical bound was rejected" ); assert!(store.grants.lock().unwrap().contains_key(&grant.grant_id)); @@ -6598,7 +6733,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ std::fs::write(stage.join("usable-resource"), b"pending-input-nonce").unwrap(); let path = grant_path(&directory, &pending.grant_id).unwrap(); let before = std::fs::read(&path).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); + let mut payload_watch = None; match case { "missing" => std::fs::remove_file(&path).unwrap(), "malformed" => std::fs::write(&path, b"{\"preparation\":").unwrap(), @@ -6609,16 +6744,13 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ .open(&path) .unwrap(); file.set_len(128 * 1024 + 1).unwrap(); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); file.read_exact(&mut [0]).unwrap(); - assert_ne!( - file.metadata().unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must observe ownership payload reads" + assert!( + watch.take_access(), + "fixture must observe ownership payload reads" ); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + payload_watch = Some(watch); } "assigned_pending" | "unrecorded" => { let mut stored: serde_json::Value = serde_json::from_slice(&before).unwrap(); @@ -6661,7 +6793,8 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ std::fs::read(&path).is_ok_and(|bytes| bytes == b"{\"preparation\":") } "oversize" => std::fs::metadata(&path).is_ok_and(|metadata| { - metadata.len() == 128 * 1024 + 1 && metadata.accessed().unwrap() == old_access + metadata.len() == 128 * 1024 + 1 + && !payload_watch.as_ref().unwrap().take_access() }), "assigned_pending" | "unrecorded" => { std::fs::read(&path).is_ok_and(|bytes| Some(bytes) == modified) @@ -7800,6 +7933,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ mcp_capability: None, prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -8130,6 +8264,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ mcp_capability: None, prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -8410,97 +8545,522 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ } #[test] - fn managed_provider_codex_selected_inputs_reach_private_generation() { + fn prepared_codex_nonprojected_policy_stays_under_native_authority() { let owned = tempfile::tempdir().unwrap(); let home = owned.path().join("home"); - let workspace = owned.path().join("workspace"); - std::fs::create_dir_all(home.join(".codex/agents/nested")).unwrap(); - std::fs::create_dir_all(workspace.join(".codex/skills/fixture/scripts")).unwrap(); - std::fs::create_dir(workspace.join(".git")).unwrap(); - let raw = format!("model_instructions_file = 'instructions.md'\n[projects.{}]\ntrust_level = 'trusted'\n[agents.reader]\nconfig_file = 'agents/nested/reader.toml'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap()); - std::fs::write(home.join(".codex/config.toml"), &raw).unwrap(); - std::fs::write(home.join(".codex/instructions.md"), "home-input").unwrap(); - std::fs::write( - home.join(".codex/agents/nested/reader.toml"), - "model_instructions_file = 'instructions.md'\n", - ) - .unwrap(); - std::fs::write( - home.join(".codex/agents/nested/instructions.md"), - "role-input", - ) - .unwrap(); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write(home.join(".codex/config.toml"), "approval_policy = { granular = { sandbox_approval = true, rules = true, mcp_elicitations = true } }\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let prepared = ordinary_stage::prepare(&home, &cwd, Some(&manifest)) + .expect("untrusted compatibility must not reject a policy it does not need to project"); + assert!(prepared.codex_approval.is_none()); + } + + #[test] + fn prepared_codex_named_selection_refuses_for_managed_sidecar() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); std::fs::write( - workspace.join(".codex/config.toml"), - "model_instructions_file = 'instructions.md'\n", + home.join(".codex/config.toml"), + "approval_policy = 'never'\n", ) .unwrap(); - std::fs::write(workspace.join(".codex/instructions.md"), "project-input").unwrap(); + let selected = home.join(".codex/selected.config.toml"); + std::fs::write(&selected, "model_reasoning_effort = 'low'\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: Some("selected".into()), + }; + let error = ordinary_stage::prepare(&home, &cwd, Some(&manifest)).err() + .expect("managed TUI also has an app-server owner; selecting only the TUI config would lose intent"); + assert!(error.contains("--profile") && error.contains(selected.to_str().unwrap())); + } + + #[test] + fn prepared_codex_runtime_purpose_survives_replay() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); std::fs::write( - workspace.join(".codex/skills/fixture/SKILL.md"), - "skill-resource", + home.join(".codex/config.toml"), + "approval_policy = 'untrusted'\n", ) .unwrap(); - let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( - "codex", &workspace, &home, None, + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let mut metadata = serde_json::to_value(&manifest).unwrap(); + let accepted = serde_json::from_value(metadata.clone()).unwrap(); + assert!(ordinary_stage::prepare(&home, &cwd, Some(&accepted)) + .unwrap() + .codex_approval + .is_some()); + metadata + .as_object_mut() + .unwrap() + .remove("nativeSourceRuntime"); + let legacy = serde_json::from_value(metadata).unwrap(); + assert!( + ordinary_stage::prepare(&home, &cwd, Some(&legacy)).is_err(), + "missing old purpose must not authorize public-policy rewriting" + ); + } + + #[test] + fn prepared_codex_tui_refuses_before_policy_is_lost() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write(&source, "approval_policy = 'untrusted'\n").unwrap(); + let error = ordinary_stage::prepare(&home, &cwd, None).err() + .expect("a terminal CLI cannot silently lose policy that only app-server typed requests can preserve"); + assert!(error.contains("approval_policy") && error.contains(source.to_str().unwrap())); + } + + #[test] + fn prepared_codex_source_change_refuses_before_publishing_policy() { + use freshell_platform::provider_environment::{copy_environment, CopyLimits}; + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write(&source, "approval_policy = 'untrusted'\nmodel = 'a'\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let prepared = ordinary_stage::prepare(&home, &cwd, Some(&manifest)).unwrap(); + // Same-length unrelated content must still invalidate the accepted + // snapshot; the later policy declaration itself remains untrusted. + std::fs::write(&source, "approval_policy = 'untrusted'\nmodel = 'b'\n").unwrap(); + let stage = owned.path().join("stage"); + copy_environment(&prepared.selections, &stage, &[], CopyLimits::default()).unwrap(); + let error = ordinary_stage::stage_codex_approval(&stage, &prepared).unwrap_err(); + assert!(error.contains("approval_policy") && error.contains("source changed")); + assert!(!stage.join(".freshell/codex-approval.json").exists()); + } + + #[cfg(feature = "managed-provider-qualification")] + #[tokio::test] + async fn prepared_codex_native_config_projection_preserves_effective_source() { + use freshell_platform::codex_inputs::CodexInputManifest; + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let repository = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let metadata: serde_json::Value = serde_json::from_slice( + &std::fs::read(repository.join("test/fixtures/coding-cli/codex-native-artifact.json")) + .unwrap(), ) - .unwrap() .unwrap(); - let directory = owned.path().join("control/mcp-capabilities"); - std::fs::create_dir_all(&directory).unwrap(); - let store = CapabilityStore::default(); - let capability = store - .issue( - SoulId::new(), - &directory, - "http://host.docker.internal:4000".into(), - Some(("codex", &home, &context.config)), - OpencodeEphemeralInput { - cwd: Some(&workspace), - workspace: Some(&workspace), - launch_context: Some(&context), - ..Default::default() - }, + let binary = + if std::env::var("FRESHELL_CODEX_QUALIFY_MANAGED_WRAPPER").as_deref() == Ok("1") { + let resource: serde_json::Value = serde_json::from_slice( + &std::fs::read(repository.join("docker/runtime/codex-native-sandbox.json")) + .unwrap(), + ) + .unwrap(); + PathBuf::from(resource["native"]["launchWrapper"].as_str().unwrap()) + } else { + repository + .join(".runtime-build/codex-contract/artifact") + .join(metadata["executable"].as_str().unwrap()) + }; + let version = std::process::Command::new(&binary) + .arg("--version") + .output() + .expect("connected verified native preparation must precede qualification"); + assert!(version.status.success()); + assert_eq!( + String::from_utf8(version.stdout).unwrap().trim(), + "codex-cli 0.164.0" + ); + async fn rpc( + stdin: &mut tokio::process::ChildStdin, + lines: &mut tokio::io::Lines>, + id: u64, + method: &str, + params: serde_json::Value, + ) -> serde_json::Value { + stdin + .write_all( + format!( + "{}\n", + serde_json::json!({"id":id,"method":method,"params":params}) + ) + .as_bytes(), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(25), async { + loop { + let line = lines + .next_line() + .await + .unwrap() + .expect("native config parsing must survive the owned projection"); + let frame: serde_json::Value = serde_json::from_str(&line).unwrap(); + if frame.get("id").and_then(serde_json::Value::as_u64) == Some(id) { + assert!( + frame.get("error").is_none(), + "native {method} refused: {frame}" + ); + return frame["result"].clone(); + } + } + }) + .await + .expect("bounded actual native qualification response") + } + for (case, named_policy, project_policy, trusted, expected) in [ + ("inherited", None, None, true, "untrusted"), + ( + "trusted-project-override", None, + Some("never"), + true, + "never", + ), + ("disabled-project", None, Some("never"), false, "untrusted"), + ] { + let owned = executable_fixture_directory(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(workspace.join(".codex")).unwrap(); + std::fs::create_dir(workspace.join(".git")).unwrap(); + let base = format!("approval_policy = 'untrusted'\nmodel_reasoning_effort = 'low'\nmodel = 'contract-model'\nmodel_provider = 'fixture'\n[model_providers.fixture]\nname = 'Owned fixture'\nbase_url = 'http://127.0.0.1:1/v1'\nwire_api = 'responses'\nrequires_openai_auth = false\n[projects.{}]\ntrust_level = '{}'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap(), if trusted { "trusted" } else { "untrusted" }); + let named_policy: Option<&str> = named_policy; + let named = format!( + "model_reasoning_effort = 'low'\n{}", + named_policy + .map(|policy| format!("approval_policy = '{policy}'\n")) + .unwrap_or_default() + ); + std::fs::write(home.join(".codex/config.toml"), &base).unwrap(); + std::fs::write(home.join(".codex/selected.config.toml"), &named).unwrap(); + std::fs::write( + workspace.join(".codex/config.toml"), + project_policy + .map(|policy| format!("approval_policy = '{policy}'\n")) + .unwrap_or_default(), ) .unwrap(); - let accepted = read_prepared_context(&directory, &capability).unwrap(); - let inputs = accepted - .prepared_inputs - .expect("Codex selected layers and typed resources must be accepted as one generation"); - let stage = staged_provider_root_path(&directory, &capability.grant_id).unwrap(); - for (logical, expected) in [ - (home.join(".codex/instructions.md"), "home-input"), - ( + let original = std::fs::read(home.join(".codex/config.toml")).unwrap(); + let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &workspace, &home, None, + ) + .unwrap() + .unwrap(); + let manifest = CodexInputManifest { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: workspace.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let grant = CapabilityStore::default() + .issue( + SoulId::new(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &context.config)), + OpencodeEphemeralInput { + cwd: Some(&workspace), + workspace: Some(&workspace), + codex_manifest: Some(&manifest), + launch_context: Some(&context), + ..Default::default() + }, + None, + ) + .unwrap(); + let accepted = read_prepared_context(&directory, &grant).unwrap(); + let intent = match &accepted.preparation { + freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: Some(intent), + .. + } => intent, + _ => panic!("actual prepared policy intent must survive complete publication"), + }; + assert_eq!(intent.policy, expected, "{case}"); + let source = if project_policy.is_some() && trusted { + workspace.join(".codex/config.toml") + } else if named_policy.is_some() { + home.join(".codex/selected.config.toml") + } else { + home.join(".codex/config.toml") + }; + assert_eq!(Path::new(&intent.logical_source), source); + assert_eq!( + std::fs::read(home.join(".codex/config.toml")).unwrap(), + original, + "preparation cannot edit its source" + ); + let stage = staged_provider_root_path(&directory, &grant.grant_id).unwrap(); + // Simulate the exact logical file mounts within this owned fixture; + // archive originals first, never modify a user's source HOME. + for (index, bind) in accepted + .prepared_inputs + .as_ref() + .unwrap() + .config_binds + .iter() + .enumerate() + { + let logical = Path::new(&bind.logical_destination); + if !logical.starts_with(owned.path()) { + continue; + } + std::fs::rename(logical, owned.path().join(format!("original-{index}"))).unwrap(); + std::os::unix::fs::symlink(stage.join(&bind.source_relative_path), logical) + .unwrap(); + } + let mut command = tokio::process::Command::new(&binary); + let mut child = command + .args(["app-server"]) + .process_group(0) + .current_dir(&workspace) + .env("HOME", &home) + .env("CODEX_HOME", home.join(".codex")) + .env_remove("OPENAI_API_KEY") + .env_remove("CODEX_API_KEY") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut lines = tokio::io::BufReader::new(child.stdout.take().unwrap()).lines(); + rpc(&mut stdin, &mut lines, 1, "initialize", serde_json::json!({"clientInfo":{"name":"freshell_prepared_codex", "version":"1"},"capabilities":{"experimentalApi":true}})).await; + stdin + .write_all(b"{\"method\":\"initialized\",\"params\":{}}\n") + .await + .unwrap(); + let config = rpc( + &mut stdin, + &mut lines, + 2, + "config/read", + serde_json::json!({"includeLayers":true}), + ) + .await; + assert_eq!( + config["config"]["model_reasoning_effort"], "low", + "unrelated setting survives projection" + ); + let started = rpc(&mut stdin, &mut lines, 3, "thread/start", serde_json::json!({"cwd":workspace,"model":"contract-model","approvalPolicy":intent.policy,"sandbox":"read-only"})).await; + assert_eq!( + started["approvalPolicy"], expected, + "actual native effective policy {case}" + ); + stop_codex_source_child(&mut child).await; + println!( + "provider_source_consumer {}", + serde_json::json!({"provider":"codex","case":case,"nativeVersion":"0.164.0","effectivePolicy":started["approvalPolicy"],"declaringSource":intent.logical_source,"modelWorkSubmitted":false}) + ); + } + } + + #[test] + fn managed_provider_codex_selected_inputs_reach_private_generation() { + for untrusted_policy in [false, true] { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex/agents/nested")).unwrap(); + std::fs::create_dir_all(workspace.join(".codex/skills/fixture/scripts")).unwrap(); + std::fs::create_dir(workspace.join(".git")).unwrap(); + let raw = format!("model_instructions_file = 'instructions.md'\n[projects.{}]\ntrust_level = 'trusted'\n[agents.reader]\nconfig_file = 'agents/nested/reader.toml'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap()); + let raw = if untrusted_policy { + format!("approval_policy = 'untrusted'\nmodel = 'synthetic'\n{raw}") + } else { + raw + }; + std::fs::write(home.join(".codex/config.toml"), &raw).unwrap(); + std::fs::write(home.join(".codex/instructions.md"), "home-input").unwrap(); + std::fs::write( + home.join(".codex/agents/nested/reader.toml"), + "model_instructions_file = 'instructions.md'\n", + ) + .unwrap(); + std::fs::write( home.join(".codex/agents/nested/instructions.md"), "role-input", - ), - (workspace.join(".codex/instructions.md"), "project-input"), - ] { - let mount = inputs + ) + .unwrap(); + std::fs::write( + workspace.join(".codex/config.toml"), + "model_instructions_file = 'instructions.md'\n", + ) + .unwrap(); + std::fs::write(workspace.join(".codex/instructions.md"), "project-input").unwrap(); + std::fs::write( + workspace.join(".codex/skills/fixture/SKILL.md"), + "skill-resource", + ) + .unwrap(); + let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &workspace, &home, None, + ) + .unwrap() + .unwrap(); + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + version: 1, + original_home: home.clone(), + cwd: workspace.clone(), + codex_home: home.join(".codex"), + named_profile: None, + native_source_runtime: if untrusted_policy { + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer + } else { + freshell_platform::codex_inputs::NativeSourceRuntime::Tui + }, + }; + let capability = store + .issue( + SoulId::new(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &context.config)), + OpencodeEphemeralInput { + cwd: Some(&workspace), + workspace: Some(&workspace), + launch_context: Some(&context), + codex_manifest: Some(&manifest), + ..Default::default() + }, + None, + ) + .unwrap(); + let accepted = read_prepared_context(&directory, &capability).unwrap(); + if untrusted_policy { + let freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: Some(default), + .. + } = &accepted.preparation + else { + panic!("relative resource preparation lost untrusted intent") + }; + assert_eq!(default.policy, "untrusted"); + assert_eq!( + default.canonical_source, + home.join(".codex/config.toml").to_str().unwrap() + ); + assert_eq!( + default.source_sha256, + format!("{:x}", Sha256::digest(raw.as_bytes())) + ); + } + let inputs = accepted.prepared_inputs.expect( + "Codex selected layers and typed resources must be accepted as one generation", + ); + let stage = staged_provider_root_path(&directory, &capability.grant_id).unwrap(); + if untrusted_policy { + let bind = inputs + .config_binds + .iter() + .find(|bind| { + bind.logical_destination + == home.join(".codex/config.toml").to_str().unwrap() + }) + .unwrap(); + let config: toml::Value = toml::from_str( + &std::fs::read_to_string(stage.join(&bind.source_relative_path)).unwrap(), + ) + .unwrap(); + assert!(config.get("approval_policy").is_none()); + assert_eq!(config["model"].as_str(), Some("synthetic")); + assert_eq!( + config["model_instructions_file"].as_str(), + Some("instructions.md") + ); + assert_eq!( + config["agents"]["reader"]["config_file"].as_str(), + Some("agents/nested/reader.toml") + ); + assert_eq!( + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + raw + ); + } + + for (logical, expected) in [ + (home.join(".codex/instructions.md"), "home-input"), + ( + home.join(".codex/agents/nested/instructions.md"), + "role-input", + ), + (workspace.join(".codex/instructions.md"), "project-input"), + ] { + let mount = inputs + .resource_mounts + .iter() + .find(|mount| mount.logical_destination == logical.to_str().unwrap()) + .expect("ordinary selected resource must retain its original absolute alias"); + assert_eq!( + std::fs::read_to_string(stage.join(&mount.stage_relative_path)).unwrap(), + expected + ); + } + assert!(inputs .resource_mounts .iter() - .find(|mount| mount.logical_destination == logical.to_str().unwrap()) - .expect("ordinary selected resource must retain its original absolute alias"); + .any(|mount| mount.logical_destination + == workspace.join(".codex/skills").to_str().unwrap())); + assert!(inputs + .config_binds + .iter() + .any(|bind| bind.logical_destination + == workspace.join(".codex/config.toml").to_str().unwrap())); assert_eq!( - std::fs::read_to_string(stage.join(&mount.stage_relative_path)).unwrap(), - expected + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + raw ); } - assert!(inputs - .resource_mounts - .iter() - .any(|mount| mount.logical_destination - == workspace.join(".codex/skills").to_str().unwrap())); - assert!(inputs - .config_binds - .iter() - .any(|bind| bind.logical_destination - == workspace.join(".codex/config.toml").to_str().unwrap())); - assert_eq!( - std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), - raw - ); } #[tokio::test] @@ -8546,6 +9106,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ std::fs::read(grant_path(&directory, &prior.grant_id).unwrap()).unwrap(); let context = freshell_runtime_protocol::ProviderLaunchContext { preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, @@ -9661,6 +10222,218 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ ); } + #[tokio::test] + async fn managed_provider_legacy_codex_reissue_requires_current_consumer_role() { + use freshell_platform::codex_inputs::{CodexInputManifest, NativeSourceRuntime}; + use freshell_runtime_protocol::{PreparedProviderContext, ProviderPreparation}; + for retire_legacy_pending in [false, true] { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("original-home"); + let selected = owned.path().join("selected-codex"); + let cwd = owned.path().join("original-workspace"); + let wrong_home = owned.path().join("wrong-current-home"); + for path in [&home, &selected, &cwd, &wrong_home] { + std::fs::create_dir_all(path).unwrap(); + } + let source = selected.join("config.toml"); + std::fs::write(&source, "model = 'synthetic'\napproval_policy = 'never'\n").unwrap(); + let manifest = CodexInputManifest { + version: 1, + original_home: home.clone(), + codex_home: selected.clone(), + cwd: cwd.clone(), + named_profile: None, + native_source_runtime: NativeSourceRuntime::Tui, + }; + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let soul = SoulId::new(); + let initial = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &cwd, &home, None, + ) + .unwrap() + .unwrap(); + let prior = store + .issue( + soul.clone(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &initial.config)), + OpencodeEphemeralInput { + cwd: Some(&cwd), + workspace: Some(&cwd), + launch_context: Some(&initial), + codex_manifest: Some(&manifest), + ..Default::default() + }, + None, + ) + .unwrap(); + let context = read_prepared_context(&directory, &prior).unwrap(); + let prior_bytes = + read_prior_manifest_bytes("codex", &store, &directory, &soul, &context).unwrap(); + let accepted: CodexInputManifest = serde_json::from_slice(&prior_bytes).unwrap(); + assert_eq!(accepted.native_source_runtime, NativeSourceRuntime::Tui); + assert!(!serde_json::from_slice::(&prior_bytes) + .unwrap() + .as_object() + .unwrap() + .contains_key("nativeSourceRuntime")); + let original = + "model = 'synthetic'\napproval_policy = 'untrusted'\nmodel_reasoning_effort = 'low'\n"; + std::fs::write(&source, original).unwrap(); + let mut callback = serde_json::json!({"action":"issue", "controlSecret":"fixture-control", + "soulId":soul, "provider":"codex", "cwd":wrong_home, "workspace":cwd, + "priorContext":context, "providerSecretReferences":[], + "authLookupAuthority":"fixture-owned-attempt", "codexSourceRuntime":"app_server"}); + let client = freshell_runtime_client::RuntimeClient::new( + owned.path().join("unused.sock"), + "fixture-control", + ); + let invoke = |callback: &serde_json::Value| serde_json::to_vec(callback).unwrap(); + let legacy_pending = if retire_legacy_pending { + std::fs::write(&source, "model = 'synthetic'\napproval_policy = 'never'\n") + .unwrap(); + callback["codexSourceRuntime"] = "tui".into(); + let bytes = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap(); + let old: PreparedProviderContext = serde_json::from_slice(&bytes).unwrap(); + let stage = staged_provider_root_path( + &directory, + &old.provider_launch_context.mcp_capability.unwrap().grant_id, + ) + .unwrap(); + std::fs::write(&source, original).unwrap(); + callback["codexSourceRuntime"] = "app_server".into(); + Some(stage) + } else { + None + }; + let first = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .expect("known Freshcodex consumer must reprepare accepted legacy selectors"); + let ready: PreparedProviderContext = serde_json::from_slice(&first).unwrap(); + if let Some(old) = legacy_pending { + assert!( + !old.exists(), + "retire only the incompatible unactivated candidate" + ); + } + assert!( + staged_provider_root_path(&directory, &prior.grant_id) + .unwrap() + .exists(), + "retain the accepted prior generation" + ); + let replacement = &ready.provider_launch_context; + let ProviderPreparation::Codex { + approval_default: Some(default), + .. + } = &replacement.preparation + else { + panic!("replacement lost untrusted intent") + }; + assert_eq!(default.policy, "untrusted"); + assert_eq!(default.canonical_source, source.to_str().unwrap()); + let next_bytes = + read_prior_manifest_bytes("codex", &store, &directory, &soul, replacement).unwrap(); + let next: CodexInputManifest = serde_json::from_slice(&next_bytes).unwrap(); + assert_eq!(next.native_source_runtime, NativeSourceRuntime::AppServer); + assert_eq!( + ( + next.original_home, + next.codex_home, + next.cwd, + next.named_profile + ), + (home.clone(), selected.clone(), cwd.clone(), None) + ); + let stage = staged_provider_root_path( + &directory, + &replacement.mcp_capability.as_ref().unwrap().grant_id, + ) + .unwrap(); + let selected_copy = replacement + .prepared_inputs + .as_ref() + .unwrap() + .config_binds + .iter() + .find(|input| input.logical_destination == source.to_str().unwrap()) + .expect("selected original config remains bound"); + let prepared: toml::Value = toml::from_str( + &std::fs::read_to_string(stage.join(&selected_copy.source_relative_path)).unwrap(), + ) + .unwrap(); + assert!(prepared.get("approval_policy").is_none()); + assert_eq!(prepared["model_reasoning_effort"].as_str(), Some("low")); + assert_eq!(std::fs::read_to_string(&source).unwrap(), original); + assert_eq!( + read_prior_manifest_bytes("codex", &store, &directory, &soul, &context).unwrap(), + prior_bytes + ); + std::fs::remove_file(&source).unwrap(); + let repeated = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&repeated).unwrap(), + serde_json::from_slice::(&first).unwrap() + ); + for role in [Some("tui"), None] { + callback + .as_object_mut() + .unwrap() + .remove("codexSourceRuntime"); + if let Some(role) = role { + callback["codexSourceRuntime"] = role.into(); + } + let error = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap_err(); + assert!(error.contains("consumer"), "{error}"); + assert!( + stage.exists(), + "a refused consumer cannot retire a compatible pending generation" + ); + } + } + } + #[tokio::test] async fn managed_provider_replacement_complete_codex_metadata_replays_before_rescanning_roots() { diff --git a/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs b/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs index 4bdaaa8c0..e895088f5 100644 --- a/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs +++ b/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs @@ -77,6 +77,7 @@ pub(super) fn prepare_retained( }); } Ok(Preparation { + codex_approval: None, manifest_bytes, selections, binds: inputs.config_binds, diff --git a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs index abfdf8116..c254e597c 100644 --- a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs +++ b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs @@ -14,6 +14,7 @@ pub(super) struct Preparation { pub selections: Vec, pub binds: Vec, pub mounts: Vec, + pub codex_approval: Option, } impl Preparation { pub(super) fn manifest_bytes(&self) -> &[u8] { @@ -26,13 +27,17 @@ pub(super) fn prepare( accepted: Option<&CodexInputManifest>, ) -> Result { let manifest = accepted.cloned().unwrap_or_else(|| CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.into(), cwd: cwd.into(), codex_home: home.join(".codex"), named_profile: None, }); - let discovery = discover(manifest).map_err(str::to_string)?; + let source = manifest.codex_home.join("config.toml"); + let discovery = + discover(manifest).map_err(|error| format!("{error}; source {}", source.display()))?; + let approval = crate::managed_provider_references::codex::approval_projection(&discovery)?; let configurations = discovery .inputs .iter() @@ -49,14 +54,16 @@ pub(super) fn prepare( ordinary.push(input.logical_path.clone()); } } - prepare_selected( + let mut preparation = prepare_selected( "codex", &discovery.manifest.cwd, &discovery.manifest, configurations, ordinary, |logical| discovery.manifest.runtime_path(logical), - ) + )?; + preparation.codex_approval = approval; + Ok(preparation) } pub(super) fn prepare_selected( @@ -73,6 +80,7 @@ pub(super) fn prepare_selected( selections: Vec::new(), binds: Vec::new(), mounts: Vec::new(), + codex_approval: None, }; let mut files = BTreeMap::::new(); for (logical_path, canonical_source) in configurations { @@ -151,6 +159,63 @@ pub(super) fn prepare_selected( } Ok(result) } +pub(super) fn stage_codex_approval(root: &Path, preparation: &Preparation) -> Result<(), String> { + use sha2::{Digest, Sha256}; + let Some(projection) = &preparation.codex_approval else { + return Ok(()); + }; + for source in &projection.sources { + let selected = preparation + .selections + .iter() + .find(|selection| selection.source == source.canonical) + .ok_or("Codex approval_policy prepared source is unavailable")?; + let destination = root.join(&selected.destination_relative); + let bytes = std::fs::read(&destination).map_err(|_| { + format!( + "Codex approval_policy prepared source unreadable: {}", + source.canonical.display() + ) + })?; + if bytes.len() as u64 > freshell_platform::provider_input_profiles::MAX_SOURCE_VIEW_BYTES + || format!("{:x}", Sha256::digest(&bytes)) != source.sha256 + { + return Err(format!( + "Codex approval_policy source changed during preparation: {}", + source.canonical.display() + )); + } + if !source.strip_untrusted { + continue; + } + let mut value: toml::Value = toml::from_str( + std::str::from_utf8(&bytes).map_err(|_| "Codex approval_policy source is not UTF-8")?, + ) + .map_err(|_| "Codex approval_policy source TOML is invalid")?; + let table = value + .as_table_mut() + .ok_or("Codex approval_policy source must be a TOML table")?; + if table.get("approval_policy").and_then(toml::Value::as_str) != Some("untrusted") { + return Err(format!( + "Codex approval_policy source no longer declares the accepted policy: {}", + source.canonical.display() + )); + } + table.remove("approval_policy"); + let rewritten = toml::to_string(&value) + .map_err(|_| "Codex approval_policy prepared TOML could not be encoded")?; + super::replace_staged_private_bytes(&destination, rewritten.as_bytes())?; + } + write_private_bytes( + &root.join(".freshell/codex-approval.json"), + &bounded_json_bytes(&projection.default, 16 * 1024) + .map_err(|_| "Codex approval source metadata exceeds control budget")?, + )?; + tracing::info!(event="provider.codex.approval_projected", policy=%projection.default.policy, + source=%projection.default.logical_source, transformed_sources=projection.sources.len()); + Ok(()) +} + fn destinations(runtime_path: &impl Fn(&Path) -> PathBuf, logical: &Path) -> Vec { let runtime = runtime_path(logical); if runtime == logical { diff --git a/crates/freshell-server/src/managed_provider_bootstrap.rs b/crates/freshell-server/src/managed_provider_bootstrap.rs index deffad5ec..3c8483bc7 100644 --- a/crates/freshell-server/src/managed_provider_bootstrap.rs +++ b/crates/freshell-server/src/managed_provider_bootstrap.rs @@ -104,6 +104,7 @@ pub(crate) fn provider_launch_context_from_home_at_with_xdg( ".codex", &["config.toml", "AGENTS.md", "skills", "rules"][..], ProviderPreparation::Codex { + approval_default: None, tui_args: if mcp_enabled { managed_codex_mcp_args().ok_or("managed Codex MCP preparation unavailable")? } else { @@ -512,6 +513,7 @@ mod tests { let ProviderPreparation::Codex { tui_args, sidecar_args, + .. } = context.preparation else { panic!("Codex preparation missing"); diff --git a/crates/freshell-server/src/managed_provider_references/codex.rs b/crates/freshell-server/src/managed_provider_references/codex.rs index e04117d41..f13052f7c 100644 --- a/crates/freshell-server/src/managed_provider_references/codex.rs +++ b/crates/freshell-server/src/managed_provider_references/codex.rs @@ -317,6 +317,7 @@ fn normalize(path: &Path) -> PathBuf { } pub(crate) struct CodexPreparation<'a> { + pub native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime, pub client: &'a freshell_runtime_client::RuntimeClient, pub soul: &'a freshell_runtime_protocol::SoulId, pub creation_seed: &'a str, @@ -330,8 +331,10 @@ pub(crate) struct CodexPreparation<'a> { pub(crate) async fn prepare_managed( request: CodexPreparation<'_>, -) -> Result { +) -> Result +{ let CodexPreparation { + native_source_runtime, client, soul, creation_seed, @@ -342,6 +345,19 @@ pub(crate) async fn prepare_managed( args, references, } = request; + let invalid = |message: String| { + freshell_runtime_protocol::RuntimeError::new( + freshell_runtime_protocol::RuntimeErrorCode::InvalidRequest, + message, + ) + }; + let unavailable = |message: String| { + freshell_runtime_protocol::RuntimeError::new( + freshell_runtime_protocol::RuntimeErrorCode::BackendUnavailable, + message, + ) + }; + let manifest = (|| -> Result { let codex_home = if let Some(selected) = original("CODEX_HOME").filter(|value| !value.is_empty()) { let selected = PathBuf::from(selected); @@ -366,16 +382,34 @@ pub(crate) async fn prepare_managed( .map(|pair| pair[1].clone()) .next_back(); let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime, version: 1, original_home: home.into(), cwd: cwd.into(), codex_home, named_profile, }; + manifest.validate().map_err(str::to_string)?; + if let Some(name) = &manifest.named_profile { + return Err(format!("Codex setting --profile {name} in {} is incompatible with managed Codex0.164: its app-server cannot select that named user configuration", manifest.codex_home.join(format!("{name}.config.toml")).display())); + } + Ok(manifest) + })().map_err(invalid)?; + // Classify known source refusals before control provisioning. The actual + // staging operation still rechecks bytes and owns generation cleanup. + let discovery = discover(manifest.clone()).map_err(|message| { + invalid(format!( + "{message}; source {}", + manifest.codex_home.join("config.toml").display() + )) + })?; + approval_projection(&discovery).map_err(invalid)?; + drop(discovery); let mcp = crate::managed_provider_bootstrap::managed_codex_mcp_args() - .ok_or("managed Codex MCP preparation unavailable")?; + .ok_or_else(|| unavailable("managed Codex MCP preparation unavailable".into()))?; let context = freshell_runtime_protocol::ProviderLaunchContext { preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -388,12 +422,14 @@ pub(crate) async fn prepare_managed( let reservation = client .reserve_provider_inputs(soul.clone(), "codex".into(), creation_seed.into()) .await - .map_err(|error| error.to_string())?; + .map_err(|error| unavailable(error.to_string()))?; if reservation.provider != "codex" || &reservation.soul_id != soul || reservation.creation_seed_ref != creation_seed { - return Err("Codex selected resource reservation does not match this creation".into()); + return Err(unavailable( + "Codex selected resource reservation does not match this creation".into(), + )); } crate::managed_mcp_capability::issue_prepared_provider_context( "codex", @@ -409,12 +445,14 @@ pub(crate) async fn prepare_managed( ..Default::default() }, ) + .map_err(unavailable) } pub struct Discovery { pub manifest: freshell_platform::codex_inputs::CodexInputManifest, pub inputs: Vec, pub resource_roots: Vec, + pub layers: Vec, } pub fn discover( @@ -586,6 +624,9 @@ pub fn discover( } } } + if preceding.get("profile").is_some() { + return Err("Codex legacy profile config is no longer supported; use --profile NAME with NAME.config.toml instead"); + } let legacy = if manifest.named_profile.is_none() { preceding.get("profile").and_then(toml::Value::as_str) } else { @@ -598,9 +639,104 @@ pub fn discover( manifest, inputs, resource_roots: resources, + layers, }) } +/// A bounded recipe for transforming only owned public config copies. The +/// original effective root policy is resolved before any declaration is omitted. +pub struct ApprovalProjection { + pub default: freshell_runtime_protocol::CodexApprovalDefault, + pub sources: Vec, +} +pub struct ApprovalSource { + pub canonical: PathBuf, + pub sha256: String, + pub strip_untrusted: bool, +} +pub fn approval_projection(discovery: &Discovery) -> Result, String> { + if let Some(name) = &discovery.manifest.named_profile { + return Err(format!("Codex setting --profile {name} in {} is incompatible: the managed native0.164 app-server owner cannot select that named user configuration", discovery.manifest.codex_home.join(format!("{name}.config.toml")).display())); + } + use sha2::{Digest, Sha256}; + let mut effective = None; + let mut sources = Vec::new(); + let mut transformed_logical = std::collections::BTreeSet::new(); + for layer in discovery.layers.iter().filter(|layer| layer.enabled) { + let canonical = std::fs::canonicalize(&layer.logical_path).map_err(|_| { + format!( + "Codex approval_policy source unavailable: {}", + layer.logical_path.display() + ) + })?; + let (value, bytes) = read_config_source(&canonical) + .map_err(|message| format!("{message}: {}", layer.logical_path.display()))?; + if value.get("profile").is_some() { + return Err(format!("Codex setting profile in {} is incompatible: legacy profile config is no longer supported", layer.logical_path.display())); + } + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + let declared = value.get("approval_policy"); + let policy = declared.and_then(toml::Value::as_str); + if !sources + .iter() + .any(|source: &ApprovalSource| source.canonical == canonical) + { + sources.push(ApprovalSource { + canonical: canonical.clone(), + sha256: sha256.clone(), + strip_untrusted: policy == Some("untrusted"), + }); + } + if declared.is_none() { + continue; + } + effective = Some(freshell_runtime_protocol::CodexApprovalDefault { + policy: policy.unwrap_or_default().into(), + logical_source: layer + .logical_path + .to_str() + .ok_or("Codex approval source is not UTF-8")? + .into(), + canonical_source: canonical + .to_str() + .ok_or("Codex approval source is not UTF-8")? + .into(), + source_sha256: sha256.clone(), + }); + if policy == Some("untrusted") { + transformed_logical.insert(normalize(&layer.logical_path)); + } + } + if !sources.iter().any(|source| source.strip_untrusted) { + return Ok(None); + } + // One physical template can have several logical aliases. Refuse if a + // disabled or role-only alias would require different bytes. + for input in &discovery.inputs { + if input.kind == InputKind::Configuration + && sources + .iter() + .any(|source| source.strip_untrusted && source.canonical == input.canonical_source) + && !transformed_logical.contains(&normalize(&input.logical_path)) + { + return Err(format!("Codex approval_policy in {} cannot be faithfully projected: an inactive or role alias shares its source", input.logical_path.display())); + } + } + let default = effective.ok_or("Codex approval_policy declaring source unavailable")?; + if discovery.manifest.native_source_runtime + != freshell_platform::codex_inputs::NativeSourceRuntime::AppServer + { + return Err(format!("Codex setting approval_policy in {} is incompatible with the terminal CLI: native0.164 can preserve untrusted only through typed app-server requests", default.logical_source)); + } + if !default.validate() { + return Err(format!( + "Codex approval_policy source metadata is incompatible: {}", + default.logical_source + )); + } + Ok(Some(ApprovalProjection { default, sources })) +} + fn add_rule_resources(root: &Path, resources: &mut Vec) -> Result<(), &'static str> { let entries = match std::fs::read_dir(root) { Ok(entries) => entries, @@ -801,6 +937,9 @@ fn exists(path: &Path) -> Result { } } fn read_config(path: &Path) -> Result { + read_config_source(path).map(|(value, _)| value) +} +fn read_config_source(path: &Path) -> Result<(toml::Value, Vec), &'static str> { use std::io::Read; let metadata = std::fs::metadata(path).map_err(|_| "Codex selected configuration is unavailable")?; @@ -821,7 +960,8 @@ fn read_config(path: &Path) -> Result { } let raw = std::str::from_utf8(&bytes).map_err(|_| "Codex selected configuration is not UTF-8")?; - toml::from_str(raw).map_err(|_| "Codex selected configuration TOML is invalid") + let value = toml::from_str(raw).map_err(|_| "Codex selected configuration TOML is invalid")?; + Ok((value, bytes)) } fn merge(target: &mut toml::Value, value: toml::Value) { if let (Some(target), Some(value)) = (target.as_table_mut(), value.as_table()) { @@ -944,6 +1084,32 @@ mod tests { } } + #[test] + fn prepared_codex_legacy_profile_is_refused_before_approval_projection() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + home.join(".codex/config.toml"), + "profile = 'legacy'\n[profiles.legacy]\napproval_policy = 'untrusted'\n", + ) + .unwrap(); + let result = discover(freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, + version: 1, + original_home: home.clone(), + cwd, + codex_home: home.join(".codex"), + named_profile: None, + }); + assert!( + matches!(result, Err(error) if error.contains("legacy profile config is no longer supported")), + "unsupported native selector must not be projected into a different policy" + ); + } + #[test] fn managed_provider_codex_actual_source_read_limits_reject_before_reading() { use std::io::Write; @@ -1170,6 +1336,7 @@ mod tests { let rule = canonical.join(".codex/rules/probe.rules"); std::fs::write(&rule, case).unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd: logical.join("nested"), @@ -1239,6 +1406,7 @@ mod tests { ) .unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1302,6 +1470,7 @@ mod tests { .unwrap(); } let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1370,6 +1539,7 @@ mod tests { .unwrap(); } let manifest = CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1455,6 +1625,7 @@ mod tests { ) .unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1496,6 +1667,7 @@ mod tests { ) .unwrap(); let disabled = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, @@ -1544,6 +1716,7 @@ mod tests { .unwrap(); for (index, expected) in ["local", "zzz", "1.10.0"].into_iter().enumerate() { let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1625,6 +1798,7 @@ mod tests { .unwrap(); } let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1727,6 +1901,7 @@ mod tests { { std::fs::write(plugin.join("plugin.json"), unrelated).unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1767,6 +1942,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1799,6 +1975,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, @@ -1825,6 +2002,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, diff --git a/crates/freshell-server/src/managed_runtime.rs b/crates/freshell-server/src/managed_runtime.rs index 2d02737e1..73511a6f1 100644 --- a/crates/freshell-server/src/managed_runtime.rs +++ b/crates/freshell-server/src/managed_runtime.rs @@ -639,6 +639,8 @@ impl ManagedTerminalController for ServerManagedRuntimeController { }; let context = crate::managed_provider_references::codex::prepare_managed( crate::managed_provider_references::codex::CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::Tui, client: &self.client, soul: &soul_id, creation_seed: create_key, @@ -650,7 +652,8 @@ impl ManagedTerminalController for ServerManagedRuntimeController { references: &provider_secret_references, }, ) - .await?; + .await + .map_err(|error| error.message)?; (context.mcp_capability.clone(), Some(context)) } else if request.mode == "claude" { let home = original_provider_home(&request.env)?; diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 08b359d7c..bfeb86df3 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -9672,6 +9672,7 @@ mod tests { let context = ProviderLaunchContext { prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-session-host/src/provider_input_preparation.rs b/crates/freshell-session-host/src/provider_input_preparation.rs index 2574d125d..78c1249f7 100644 --- a/crates/freshell-session-host/src/provider_input_preparation.rs +++ b/crates/freshell-session-host/src/provider_input_preparation.rs @@ -1695,6 +1695,7 @@ mod tests { let owned = tempfile::tempdir().unwrap(); std::fs::create_dir(owned.path().join(".freshell")).unwrap(); let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: "/absent-original-home".into(), cwd: "/absent-original-workspace".into(), @@ -1711,6 +1712,7 @@ mod tests { }; let context = ProviderLaunchContext { preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 342cc1e98..a7212a668 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -530,6 +530,7 @@ impl FreshAgentTransport for HostedTransport { acceptance_ambiguous: false, })?; *self.profile.lock().expect("profile lock") = Some(profile.clone()); + let permission_mode = profile.effective_permission_mode().map(str::to_owned); let profile = profile.clone(); let mut outcomes = self.send_outcomes.subscribe(); let send = FreshAgentSend { @@ -546,7 +547,7 @@ impl FreshAgentTransport for HostedTransport { cwd: Some(profile.cwd), effort: profile.effort, model: profile.model, - permission_mode: profile.permission_mode, + permission_mode, sandbox: profile.sandbox.as_deref().and_then(parse_sandbox), }), checkpoint_intent: logical_attempt_id.map(|logical_attempt_id| { @@ -1124,7 +1125,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { model_id: selection.model_id.clone(), }) }), - permission_mode: profile.permission_mode.clone(), + permission_mode: profile.effective_permission_mode().map(str::to_owned), plugins: profile.plugins.clone(), provider: Some(provider), resume_session_id: None, @@ -2829,6 +2830,7 @@ mod tests { ProviderPreparation::Codex { tui_args, sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { tui_config, @@ -3034,12 +3036,54 @@ mod tests { } } + #[test] + fn prepared_codex_approval_intent_reaches_create_without_becoming_explicit() { + let mut profile = parity_profile(FreshProvider::Codex); + profile.permission_mode = None; + let mut context = + serde_json::to_value(profile.provider_launch_context.as_ref().unwrap()).unwrap(); + context["preparation"]["codex"]["approval_default"] = json!({ + "policy":"untrusted", "logicalSource":"/original/.codex/config.toml", + "canonicalSource":"/original/.codex/config.toml", "sourceSha256":"a".repeat(64) + }); + profile.provider_launch_context = Some( + serde_json::from_value(context) + .expect("prepared native policy descriptor must survive the protocol boundary"), + ); + assert_eq!( + create_request_for_profile(&profile) + .permission_mode + .as_deref(), + Some("untrusted") + ); + assert!( + profile.permission_mode.is_none(), + "inherited policy must remain distinguishable from explicit settings" + ); + let replayed: FreshAgentProfile = + serde_json::from_slice(&serde_json::to_vec(&profile).unwrap()).unwrap(); + assert_eq!( + create_request_for_profile(&replayed) + .permission_mode + .as_deref(), + Some("untrusted") + ); + profile.permission_mode = Some("never".into()); + assert_eq!( + create_request_for_profile(&profile) + .permission_mode + .as_deref(), + Some("never") + ); + } + fn parity_profile(provider: FreshProvider) -> FreshAgentProfile { let preparation = match provider { FreshProvider::Claude => ProviderPreparation::Claude { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: Vec::new(), }, @@ -3543,6 +3587,7 @@ mod tests { ProviderPreparation::Codex { ref tui_args, ref sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { ref tui_config, @@ -3582,6 +3627,7 @@ mod tests { ProviderPreparation::Codex { ref tui_args, ref sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { ref tui_config, @@ -3651,6 +3697,7 @@ mod tests { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: vec!["-c".into(), "fixture_option=true".into()], }, @@ -3769,6 +3816,7 @@ mod tests { ProviderPreparation::Codex { tui_args, sidecar_args, + .. }, ) => { assert!( diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 1d3f3667d..7fa0d2e79 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -2331,6 +2331,7 @@ mod tests { let context = ProviderLaunchContext { prepared_inputs: Some(inputs), preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-supervisor/src/provider_callback_budget_tests.rs b/crates/freshell-supervisor/src/provider_callback_budget_tests.rs index 79ebda62d..5d46cda99 100644 --- a/crates/freshell-supervisor/src/provider_callback_budget_tests.rs +++ b/crates/freshell-supervisor/src/provider_callback_budget_tests.rs @@ -17,6 +17,14 @@ struct RecoveryFixture { /// A retained Claude terminal with one accepted prepared-input generation, /// and a recovery attempt that begins from it. async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { + recovery_fixture(budgets, "claude", false).await +} + +async fn recovery_fixture( + budgets: ProviderCallbackBudgets, + provider: &str, + fresh: bool, +) -> RecoveryFixture { let root = tempfile::tempdir().unwrap(); let binary = root.path().join("host"); std::fs::write(&binary, b"synthetic helper artifact").unwrap(); @@ -29,16 +37,28 @@ async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { "preparedInputs":{"generationId":generation,"manifest":{"relativePath":".freshell/input-manifest.json","sha256":"a".repeat(64),"sourceCount":0,"aliasCount":0,"recipeCount":0}} })) .unwrap(); + let context = if provider == "codex" { + let mut value = serde_json::to_value(&context).unwrap(); + value["preparation"] = serde_json::json!({"codex":{"tui_args":[],"sidecar_args":[]}}); + serde_json::from_value(value).unwrap() + } else { + context + }; let terminal: TerminalLaunchSpec = serde_json::from_value(serde_json::json!({ - "terminalId":"callback-budget","streamId":"callback-budget-stream","mode":"claude","program":"claude","args":[], + "terminalId":"callback-budget","streamId":"callback-budget-stream","mode":provider,"program":provider,"args":[], "cwd":root.path(),"workspacePath":root.path(),"runAsUid":65534,"runAsGid":0,"cols":80,"rows":24, "projectKey":"callback-budget-workspace","providerLaunchContext":&context })) .unwrap(); + let fresh_agent: FreshAgentLaunchSpec = serde_json::from_value(serde_json::json!({ + "sessionId":"callback-codex", "provider":"codex", "sessionType":"freshcodex", + "runtimeVariant":"codex", "providerStoreId":"synthetic-store", "cwd":root.path(), + "workspacePath":root.path(), "runAsUid":65534, "runAsGid":0, "providerLaunchContext":context, + })).unwrap(); let prepared = registry .prepare_launch(LaunchPreparation { soul_id: soul.clone(), - provider: "claude".into(), + provider: provider.into(), provider_store_id: "synthetic-store".into(), native_session_id: None, creation_seed_ref: "synthetic-seed".into(), @@ -53,8 +73,8 @@ async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { profile: RuntimeProfile::Custom, project_key: "callback-budget-workspace".into(), fixture: None, - terminal: Some(terminal), - fresh_agent: None, + terminal: (!fresh).then_some(terminal), + fresh_agent: fresh.then_some(fresh_agent), view_intent: None, admission: crate::admission::AdmissionPolicy::default(), }) @@ -236,3 +256,41 @@ async fn ordinary_callback_keeps_the_ordinary_wait() { ); server.abort(); } + +#[tokio::test] +async fn codex_replacement_callback_uses_validated_current_consumer() { + for fresh in [false, true] { + let fixture = recovery_fixture(budgets(1000, 1000), "codex", fresh).await; + let server = slow_callback_server( + &fixture.supervisor, + Duration::ZERO, + b"not a prepared context", + move |request, _| { + assert_eq!(request["provider"], "codex"); + assert_eq!( + request["codexSourceRuntime"], + if fresh { "app_server" } else { "tui" }, + "current owned workload must supply role independently of saved configuration" + ); + }, + ); + let error = fixture + .supervisor + .issue_replacement_mcp_capability( + &fixture.start, + "codex", + &fixture.context, + &[], + "/fixture/instance", + "/fixture/workspace", + ) + .await + .unwrap_err(); + server.await.unwrap(); + assert!( + error.message.contains("invalid scoped grant reference"), + "{}", + error.message + ); + } +} diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 449c2f955..e5e79d3e3 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -62,6 +62,7 @@ struct McpCapabilityRequest<'a> { workspace: Option<&'a str>, auth_lookup_authority: Option<&'a str>, claude_recovery_source: Option<&'a freshell_runtime_protocol::ClaudeRecoverySourceBinding>, + codex_source_runtime: Option, cwd: Option<&'a str>, prior_context: Option<&'a freshell_runtime_protocol::ProviderLaunchContext>, provider_secret_references: Option<&'a [freshell_runtime_protocol::ProviderSecretReference]>, @@ -2328,6 +2329,7 @@ impl Supervisor { workspace: None, auth_lookup_authority: None, claude_recovery_source: None, + codex_source_runtime: None, cwd: None, prior_context: None, provider_secret_references: None, @@ -2551,6 +2553,37 @@ impl Supervisor { cwd: &str, workspace: &str, ) -> Result { + let codex_source_runtime = if provider == "codex" { + use freshell_platform::codex_inputs::NativeSourceRuntime; + let incompatible = + || { + RuntimeError::new(RuntimeErrorCode::InvalidRequest, + "Codex replacement current consumer is invalid; retain inputs and reconcile") + }; + if start.context.provider != "codex" { + return Err(incompatible()); + } + Some( + match ( + start.context.prior_handle.fresh_agent(), + start.context.prior_handle.terminal(), + ) { + (Some(spec), None) + if spec.provider == freshell_runtime_protocol::FreshProvider::Codex => + { + spec.validate()?; + NativeSourceRuntime::AppServer + } + (None, Some(spec)) if spec.mode == "codex" => { + spec.validate()?; + NativeSourceRuntime::Tui + } + _ => return Err(incompatible()), + }, + ) + } else { + None + }; let tui_source = match &context.preparation { freshell_runtime_protocol::ProviderPreparation::Opencode { tui_source, .. } => { tui_source.as_ref() @@ -2586,6 +2619,7 @@ impl Supervisor { workspace: Some(workspace), auth_lookup_authority: Some(&scope.key), claude_recovery_source: claude_recovery_source.as_ref(), + codex_source_runtime, cwd: Some(cwd), prior_context: Some(context), provider_secret_references: Some(references), @@ -2625,6 +2659,7 @@ impl Supervisor { "workspace": request.workspace, "authLookupAuthority": request.auth_lookup_authority, "claudeRecoverySource": request.claude_recovery_source, + "codexSourceRuntime": request.codex_source_runtime, "cwd":request.cwd, "priorContext":request.prior_context, "providerSecretReferences":request.provider_secret_references, diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index e4fcc314a..de5becb29 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -40,6 +40,16 @@ COPY extensions/ ./extensions/ COPY docker/runtime/provider-versions.json ./docker/runtime/provider-versions.json COPY docker/runtime/codex-native-sandbox.json ./docker/runtime/codex-native-sandbox.json RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked +# Compile the real-client requirement guard before entering native test lanes. +RUN set -eu; cargo test --release -p freshell-codex --features native-policy-qualification --lib --no-run --locked \ + ; set -- target/release/deps/freshell_codex-* \ + ; count=0; for probe in "$@"; do \ + if [ -f "$probe" ] && [ -x "$probe" ]; then \ + cp "$probe" /build/codex-policy-client-probe; count=$((count + 1)); \ + fi; \ + done; test "$count" -eq 1 \ + && chmod 755 /build/codex-policy-client-probe \ + && sha256sum /build/codex-policy-client-probe > /build/codex-policy-client-probe.sha256 # --------------------------------------------------------------------------- # Stage 2: build retained Node tooling and static artifacts. @@ -107,6 +117,8 @@ RUN pnpm dlx playwright@1.58.2 install --with-deps chromium WORKDIR /app COPY --from=rust-builder /build/target/release/freshell-server /app/target/release/freshell-server +COPY --from=rust-builder /build/codex-policy-client-probe /opt/freshell-test-providers/codex/policy-client-probe +COPY --from=rust-builder /build/codex-policy-client-probe.sha256 /opt/freshell-test-providers/codex/policy-client-probe.sha256 COPY --from=rust-builder /usr/local/cargo /usr/local/cargo COPY --from=rust-builder /usr/local/rustup /usr/local/rustup COPY --from=node-builder /app/dist/client ./dist/client @@ -140,6 +152,7 @@ RUN scripts/verify-container-layout.sh --fixture /app --runtime-root ENV FRESHELL_E2E_RUST_SERVER_BIN=/app/target/release/freshell-server ENV FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64 ENV FRESHELL_CODEX_CONTRACT_CAPTURE_STDOUT=1 +ENV FRESHELL_CODEX_POLICY_CLIENT_PROBE=/opt/freshell-test-providers/codex/policy-client-probe # COPY's requested mode can still be narrowed by rootless build/ACL metadata. # Normalize the copied artifact in the image layer so the final unprivileged @@ -154,7 +167,7 @@ USER node # Verify the complete copied native tree as the final runtime identity, # including /opt ancestors outside /app's ownership adjustment. -RUN node --input-type=module -e 'import { verifyArtifact } from "./scripts/testing/prepare-codex-contract.mjs"; await verifyArtifact(process.env.FRESHELL_CODEX_CONTRACT_ROOT)' +RUN node --input-type=module -e 'import { verifyArtifact, verifyPolicyClientProbe } from "./scripts/testing/prepare-codex-contract.mjs"; await verifyArtifact(process.env.FRESHELL_CODEX_CONTRACT_ROOT); await verifyPolicyClientProbe(process.env.FRESHELL_CODEX_POLICY_CLIENT_PROBE)' # Verify the final runtime user can read, syntax-check, and execute the exact # entrypoint artifact. `--dry-run` exits before Playwright and exercises the diff --git a/scripts/sandbox-test.sh b/scripts/sandbox-test.sh index a779ad62a..3394f7da1 100755 --- a/scripts/sandbox-test.sh +++ b/scripts/sandbox-test.sh @@ -140,10 +140,14 @@ else if [ "${RUNTIME_SUITE}" -eq 1 ]; then case "${CMD}" in 'node test/integration/providers/codex-cli-contract.mjs --scenario all' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --missing-policy-client-probe' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario parent-child-read --child-approval' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url --history-mode paginated --large-output' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url --history-mode legacy --large-output' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario active-input-stop' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario active-input-stop --startup-cancellation' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec --requirements-guard' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec --approval-control stdin' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario approval-policy-restricted-exec --approval-control network' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --history-mode paginated --persistence-mode delayed' | \ diff --git a/scripts/testing/prepare-codex-contract.mjs b/scripts/testing/prepare-codex-contract.mjs index def55e770..bc561413c 100644 --- a/scripts/testing/prepare-codex-contract.mjs +++ b/scripts/testing/prepare-codex-contract.mjs @@ -157,10 +157,49 @@ async function stagePrepared() { const preparedPolicy = await fsp.readFile(path.join(stageRoot, 'codex-policy.json'), 'utf8') if (`sha256:${digest(preparedPolicy)}` !== policy.profileSha256) fail('connected prepared policy is stale or corrupt') await fsp.writeFile(path.join(owned, 'codex-policy.json'), preparedPolicy, { mode: 0o644 }) - const manifest = { complete: true, subdir, root: `/freshell-test-artifacts/${subdir}`, artifactRoot: `/freshell-test-artifacts/${subdir}/artifact`, stateRoot: `/freshell-test-artifacts/${subdir}/state`, receiptRoot: `/freshell-test-artifacts/${subdir}/receipts`, nativeSandboxPolicy: { id: policy.id, profileSha256: policy.profileSha256, path: `/freshell-test-artifacts/${subdir}/codex-policy.json` }, fingerprint, uid: process.getuid(), gid: process.getgid(), image: null } + const client = JSON.parse(await fsp.readFile(path.join(stageRoot, 'policy-client-prepared.json'), 'utf8')) + if (client.source !== 'freshell-codex-native-policy-client-test' || !client.cargoFeatures?.includes('native-policy-qualification') || !/^\/freshell-test-artifacts\/targets\/[a-f0-9]{12}\/debug\/deps\/freshell_codex-[a-f0-9]{16}$/.test(client.path)) fail('completed connected policy client is unavailable') + await verifyPolicyClientProbe(client.path.replace('/freshell-test-artifacts/', '/workspace/target/'), client.sha256) + const probe = path.join(owned, 'policy-client-probe') + await fsp.copyFile(client.path.replace('/freshell-test-artifacts/', '/workspace/target/'), probe) + await fsp.chmod(probe, 0o755) + await fsp.writeFile(`${probe}.sha256`, `${client.sha256}\n`, { mode: 0o644 }) + await verifyPolicyClientProbe(probe, client.sha256) + const manifest = { complete: true, subdir, root: `/freshell-test-artifacts/${subdir}`, artifactRoot: `/freshell-test-artifacts/${subdir}/artifact`, stateRoot: `/freshell-test-artifacts/${subdir}/state`, receiptRoot: `/freshell-test-artifacts/${subdir}/receipts`, nativeSandboxPolicy: { id: policy.id, profileSha256: policy.profileSha256, path: `/freshell-test-artifacts/${subdir}/codex-policy.json` }, policyClientProbe: { path: `/freshell-test-artifacts/${subdir}/policy-client-probe`, sha256: client.sha256 }, fingerprint, uid: process.getuid(), gid: process.getgid(), image: null } return manifest } +export async function verifyPolicyClientProbe(file, expectedSha256) { + if (!path.isAbsolute(file)) fail('native policy client probe requires an explicit absolute path') + const ancestors = checkAncestors(file) + const stat = await fsp.stat(file) + if (!stat.isFile()) fail('native policy client probe is not a regular executable') + const sha256 = digest(await fsp.readFile(file)) + const expected = expectedSha256 ?? (await fsp.readFile(`${file}.sha256`, 'utf8')).split(/\s+/)[0] + if (!/^[a-f0-9]{64}$/.test(expected) || sha256 !== expected) fail('native policy client probe bytes changed') + return { path: file, sha256, bytes: stat.size, ancestors } +} + +async function preparePolicyClientProbe(targetKey) { + if (repoRoot !== '/workspace' || !/^[a-f0-9]{12}$/.test(targetKey)) fail('policy client compilation requires the connected owned wrapper') + const target = `/workspace/target/targets/${targetKey}` + await fsp.mkdir(target, { recursive: true }) + const temporary = await fsp.mkdtemp(path.join(target, '.codex-policy-build-')) + console.error(JSON.stringify({ severity: 'info', event: 'codex_policy_client_build_started', target })) + try { + // This connected step compiles a prerequisite; it runs no tests/native work. + const output = run('cargo', ['test', '--locked', '-p', 'freshell-codex', '--features', 'native-policy-qualification', '--lib', '--no-run', '--message-format=json'], { + env: { ...process.env, CARGO_TARGET_DIR: target, CARGO_BUILD_JOBS: '3', TMPDIR: temporary }, + }) + const artifacts = output.trim().split('\n').map(line => JSON.parse(line)).filter(item => item.reason === 'compiler-artifact' && item.target?.name === 'freshell_codex' && item.profile?.test === true && item.executable && item.features?.includes('native-policy-qualification')) + if (artifacts.length !== 1) fail('connected compilation did not produce exactly one real-client test executable') + const artifact = artifacts[0] + const file = await verifyPolicyClientProbe(artifact.executable, digest(await fsp.readFile(artifact.executable))) + console.error(JSON.stringify({ severity: 'info', event: 'codex_policy_client_build_finished', sha256: file.sha256, bytes: file.bytes })) + return { path: artifact.executable.replace('/workspace/target/', '/freshell-test-artifacts/'), sha256: file.sha256, cargoFeatures: artifact.features, source: 'freshell-codex-native-policy-client-test' } + } finally { await fsp.rm(temporary, { recursive: true, force: true }) } +} + async function verifyPrepared() { const manifest = JSON.parse(await fsp.readFile(stageManifest, 'utf8')) if (!manifest.complete || !/^freshcodex-contract-[a-f0-9-]{36}$/.test(manifest.subdir) || manifest.root !== `/freshell-test-artifacts/${manifest.subdir}`) fail('invalid offline stage ownership') @@ -168,6 +207,8 @@ async function verifyPrepared() { const state = await fsp.readFile('/workspace/node_modules/.sandbox-deps-state', 'utf8') if (!state.includes(`fingerprint=${manifest.fingerprint}\n`) || !state.includes('manager_version=10.34.5\n')) fail('sandbox dependency cache is absent or stale') const artifact = await verifyArtifact(manifest.artifactRoot) + if (manifest.policyClientProbe?.path !== `${manifest.root}/policy-client-probe`) fail('required real-client policy probe is absent or escaped its owned stage') + await verifyPolicyClientProbe(manifest.policyClientProbe.path, manifest.policyClientProbe.sha256) const policy = nativeSandboxPolicy() if (manifest.nativeSandboxPolicy?.id !== policy.id || manifest.nativeSandboxPolicy?.profileSha256 !== policy.profileSha256 || manifest.nativeSandboxPolicy.path !== `${manifest.root}/codex-policy.json`) fail('offline native sandbox policy is absent, incompatible or stale') const policyBytes = await fsp.readFile(manifest.nativeSandboxPolicy.path) @@ -214,12 +255,16 @@ async function main(args) { if (metadata.probeOnly !== true) fail('candidate artifact requires an explicit probe-only manifest') result = await prepareArtifact(args[1], metadata) } + else if (args.length === 2 && args[0] === '--prepare-policy-client-probe') result = await preparePolicyClientProbe(args[1]) else if (args.length === 1 && args[0] === '--stage-prepared') result = await stagePrepared() else if (args.length === 1 && args[0] === '--verify-prepared') result = await verifyPrepared() else if (args.length === 1 && args[0] === '--verify-sandbox') result = await verifySandbox() else if (args.length === 1 && args[0] === '--sandbox') { await prepareArtifact(path.join(stageRoot, 'artifact')) await preparePolicy() + const targetKey = digest(repoRoot).slice(0, 12) + const client = JSON.parse(run('bash', ['scripts/sandbox-test.sh', `node scripts/testing/prepare-codex-contract.mjs --prepare-policy-client-probe ${targetKey}`], { stdio: ['ignore', 'pipe', 'inherit'] })) + await fsp.writeFile(path.join(stageRoot, 'policy-client-prepared.json'), `${JSON.stringify(client)}\n`, { mode: 0o644 }) result = JSON.parse(run('bash', ['scripts/sandbox-test.sh', 'node scripts/testing/prepare-codex-contract.mjs --stage-prepared'], { stdio: ['ignore', 'pipe', 'inherit'] })) result.baseImage = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() const context = path.join(stageRoot, 'image-context', result.subdir) @@ -238,7 +283,9 @@ async function main(args) { } result.image = run('docker', ['image', 'inspect', tag, '--format', '{{.Id}}']).trim() result.contractImage = { tag, requirementsSha256: digest(CODEX_NETWORK_REQUIREMENTS) } - await fsp.writeFile(stageManifest, `${JSON.stringify(result, null, 2)}\n`) + const manifestTemporary = `${stageManifest}.${randomUUID()}.preparing` + await fsp.writeFile(manifestTemporary, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o644, flag: 'wx' }) + await fsp.rename(manifestTemporary, stageManifest) await verifySandbox() } else fail('usage: --output ABS | --sandbox | --stage-prepared | --verify-sandbox') process.stdout.write(`${JSON.stringify(result)}\n`) diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index f51f24333..f0f9c752b 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -330,6 +330,7 @@ function successResult(method, params) { platformOs: 'linux', } } + if (method === 'configRequirements/read') return { requirements: behavior.configRequirements ?? null } if (method === 'thread/start') { const threadId = behavior.threadStartThreadId || (behavior.uniqueThreadIds === true @@ -347,7 +348,7 @@ function successResult(method, params) { model: 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', } @@ -366,7 +367,7 @@ function successResult(method, params) { model: 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', } @@ -414,7 +415,7 @@ function successResult(method, params) { model: params?.model ?? 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', } diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs index f58211b19..bd79c2770 100644 --- a/test/integration/providers/codex-cli-contract.mjs +++ b/test/integration/providers/codex-cli-contract.mjs @@ -9,7 +9,7 @@ import { spawn, spawnSync } from 'node:child_process' import { createInterface } from 'node:readline' import { createHash, randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' -import { artifactMetadata, repoRoot, verifyArtifact, ambientCodex, verifySandbox, nativeSandboxPolicy } from '../../../scripts/testing/prepare-codex-contract.mjs' +import { artifactMetadata, repoRoot, verifyArtifact, ambientCodex, verifySandbox, nativeSandboxPolicy, verifyPolicyClientProbe } from '../../../scripts/testing/prepare-codex-contract.mjs' import { createCodexLocalProvider, MCP_FORM_CONTENT, MCP_LARGE_TEXT, CODEX_NETWORK_REQUIREMENTS } from './codex-local-provider.mjs' const scenarios = ['text-history', 'active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'] @@ -20,8 +20,9 @@ export function nativeContractCases() { } for (const name of scenarios.filter(name => name !== 'text-history')) cases.push([name, {}]) for (const sandboxMode of ['read-only', 'workspace-write']) cases.push(['approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true }]) + cases.push(['approval-policy-restricted-exec', { requirementsGuard: true }]) for (const approvalControl of ['stdin', 'network']) cases.push(['approval-policy-restricted-exec', { approvalControl }]) - cases.push(['active-input-stop', { startupCancellation: true }], ['parent-child-read', { ephemeralParent: true }]) + cases.push(['active-input-stop', { startupCancellation: true }], ['parent-child-read', { ephemeralParent: true }], ['parent-child-read', { childApproval: true }]) for (const historyMode of ['legacy', 'paginated']) { cases.push(['mcp-form-url', { historyMode, largeOutput: true }]) for (const persistenceMode of ['delayed', 'missing']) cases.push(['text-history', { historyMode, persistenceMode }]) @@ -64,6 +65,12 @@ async function roots(options) { const artifactRoot = options.artifactRoot ?? process.env.FRESHELL_CODEX_CONTRACT_ROOT ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.artifactRoot : undefined) if (!artifactRoot) throw new Error('CODEX_CONTRACT_PREREQUISITE: explicit prepared artifact root is missing; no PATH fallback') const artifact = await verifyArtifact(artifactRoot, metadata) + let policyClientProbe + if (!options.probeMetadata && !options.seccompProbeReceipt) { + const file = options.policyClientProbe ?? process.env.FRESHELL_CODEX_POLICY_CLIENT_PROBE ?? staged?.policyClientProbe?.path + assert(file, 'CODEX_CONTRACT_PREREQUISITE: required real-client policy probe is missing') + policyClientProbe = await verifyPolicyClientProbe(file, staged?.policyClientProbe?.path === file ? staged.policyClientProbe.sha256 : undefined) + } const stateBase = options.stateRoot ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.stateRoot : undefined) ?? os.tmpdir() const stateRoot = await fsp.mkdtemp(path.join(stateBase, 'codex-contract-')) const receiptRoot = path.resolve(options.receiptDir ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.receiptRoot : undefined) ?? path.join(stateRoot, 'receipts')) @@ -77,19 +84,19 @@ async function roots(options) { assert.equal(bundled.length, 1, 'fixture PATH selection needs the single verified bundled bubblewrap resource') environment.PATH = `${path.dirname(path.join(artifact.path, bundled[0].path))}:${environment.PATH}` } - return { artifact, metadata, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged } + return { artifact, metadata, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged, policyClientProbe } } class NativeClient { - constructor(binary, context, capture) { + constructor(binary, context, capture, args = ['app-server']) { this.capture = capture this.pending = new Map() this.waiters = new Set() this.nextId = 1 this.secretControls = new Map() - this.child = spawn(binary, ['app-server'], { cwd: context.workspace, env: context.environment, stdio: ['pipe', 'pipe', 'pipe'] }) + this.child = spawn(binary, args, { cwd: context.workspace, env: context.environment, stdio: ['pipe', 'pipe', 'pipe'] }) this.connectionId = (capture.connections ??= []).length - capture.connections.push({ id: this.connectionId, pid: this.child.pid, binary }) + capture.connections.push({ id: this.connectionId, pid: this.child.pid, binary, args }) if (!capture.probeOnly || capture.environment.seccompProbe || capture.environment.nativeSandboxPolicy) { const observed = new Map((capture.observations.executedSandboxHelpers ?? []).map(helper => [helper.pid, helper])) this.helperWatcher = setInterval(() => { @@ -172,6 +179,66 @@ class NativeClient { } } +async function nativeRequirementsGuard(context, capture) { + const { path: file } = context.policyClientProbe + capture.observations.policyClientProbe = context.policyClientProbe + const requirementFile = path.join(context.stateRoot, 'strict-requirements.toml') + await fsp.writeFile(requirementFile, `allowed_approval_policies = ["never"]\n${CODEX_NETWORK_REQUIREMENTS}`, { mode: 0o600 }) + const reservation = http.createServer() + await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve)) + const port = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const readiness = () => new Promise(resolve => { + const request = http.get(`http://127.0.0.1:${port}/readyz`, response => { response.resume(); resolve(response.statusCode === 200) }) + request.on('error', () => resolve(false)); request.setTimeout(1000, () => request.destroy()) + }) + const helper = path.join(context.artifact.path, 'vendor/x86_64-unknown-linux-musl/codex-resources/bwrap') + // This matches native's proc-preflight fallback: retain the fresh user/PID/ + // IPC namespaces and inherited recursive root procfs, omit only --proc. + const args = ['--unshare-user', '--unshare-pid', '--unshare-ipc', '--die-with-parent', '--new-session', + '--ro-bind', '/', '/', '--bind', context.stateRoot, context.stateRoot, + '--ro-bind', requirementFile, '/etc/codex/requirements.toml', '--dev', '/dev', + '--chdir', context.workspace, '--cap-drop', 'ALL', '--uid', String(process.getuid()), '--gid', String(process.getgid()), + '--', capture.binary.path, 'app-server', '--listen', `ws://127.0.0.1:${port}`] + const native = new NativeClient(helper, context, capture, args) + try { + await deadline((async () => { + while (!await readiness()) { + assert.equal(native.child.exitCode, null, capture.stderr) + await new Promise(resolve => setTimeout(resolve, 50)) + } + })(), 15_000, 'isolated native requirements listener') + const receipt = path.join(context.stateRoot, 'policy-client-proof.json') + const probe = spawn(file, ['--exact', 'app_server::tests::native_requirements_refuse_untrusted_before_work', '--nocapture'], { + cwd: context.workspace, env: { ...context.environment, FRESHELL_CODEX_POLICY_PROBE_URL: `ws://127.0.0.1:${port}`, FRESHELL_CODEX_POLICY_PROBE_RECEIPT: receipt }, stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '', stderr = '' + probe.stdout.on('data', bytes => { stdout += bytes }); probe.stderr.on('data', bytes => { stderr += bytes }) + const exited = new Promise((resolve, reject) => { probe.once('exit', (code, signal) => resolve({ code, signal })); probe.once('error', reject) }) + let result + try { result = await deadline(exited, 30_000, 'real production policy client') } + finally { if (probe.exitCode === null && probe.signalCode === null) { probe.kill('SIGKILL'); await exited } } + assert.equal(result.code, 0, `${stdout}\n${stderr}`) + assert.match(stdout, /1 passed; 0 failed/) + const proof = JSON.parse(await fsp.readFile(receipt, 'utf8')) + assert.deepEqual(proof.refusedRoutes, ['create', 'resume', 'fork', 'send']) + assert.equal(proof.workRequests, 0) + for (const entry of proof.frames) { + const direction = entry.direction === 'controller' ? 'client' : 'native' + const frame = entry.frame + capture.frames.push({ index: capture.frames.length, connectionId: native.connectionId, direction, frame }) + if (direction === 'client' && frame.method && frame.id !== undefined) capture.requests.push(frame) + else if (direction === 'native' && frame.id !== undefined) capture.responses.push(frame) + else if (direction === 'native' && frame.method) capture.notifications.push(frame) + } + capture.observations.requirementsGuard = { ...proof, frames: undefined, result, stdout, stderr, requirementsSha256: createHash('sha256').update(await fsp.readFile(requirementFile)).digest('hex') } + assert(capture.requests.every(frame => ['initialize', 'configRequirements/read'].includes(frame.method))) + assert.equal(capture.requests.filter(frame => frame.method === 'configRequirements/read').length, 4) + } finally { await native.close() } + assert.equal(await readiness(), false, 'owned PID namespace and native listener must settle after launcher exit') + capture.observations.requirementsGuard.ownedListenerSettled = true +} + function assistantText(read, nativeTurnId) { return read.thread.turns.filter(turn => !nativeTurnId || turn.id === nativeTurnId).flatMap(turn => turn.items.filter(item => item.type === 'agentMessage').map(item => item.text)).join('\n') } @@ -344,6 +411,7 @@ async function verifyLargeOutput(capture, read, context, turnId, label, historyM async function driveMcp(client, capture, flow, context) { capture.observations.elicitationCases = [] + capture.observations.urlOpenCases = [] let last for (const route of ['form', 'openai/form', 'openaiForm', 'url']) { const current = { id: route.replaceAll('/', '_'), route, step: 0 } @@ -353,6 +421,17 @@ async function driveMcp(client, capture, flow, context) { const already = new Set(capture.observations.elicitationCases.filter(record => record.turnId === last.turn.id).map(record => record.requestId)) const request = await client.wait(frame => frame.method === 'mcpServer/elicitation/request' && frame.params?.turnId === last.turn.id && !already.has(frame.id)) assert.equal(request.params.mode, route) + if (route === 'url') { + const opened = await fetch(request.params.url) + assert.equal(opened.status, 200, 'the owned URL must actually open before a separate native decision') + await opened.text() + const pending = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: `url-opened-awaiting-${action}`, body: pending }) + const turn = pending.thread.turns.find(turn => turn.id === last.turn.id) + assert.equal(turn.status, 'inProgress', 'opening the URL must not complete the native pending control') + assert(!capture.notifications.some(frame => frame.method === 'serverRequest/resolved' && frame.params?.requestId === request.id && frame.params.threadId === capture.threadId)) + capture.observations.urlOpenCases.push({ requestId: request.id, elicitationId: request.params.elicitationId, turnId: last.turn.id, openedStatus: opened.status, pendingTurnStatus: turn.status, explicitActionStillRequired: action }) + } const result = { action, ...(action === 'accept' && route !== 'url' ? { content: MCP_FORM_CONTENT, _meta: { 'fixture/response': 'accepted' } } : {}) } client.send({ id: request.id, result }) capture.observations.elicitationCases.push({ route, action, requestId: request.id, turnId: last.turn.id, elicitationId: request.params.elicitationId ?? null }) @@ -602,7 +681,9 @@ async function driveNetwork(client, capture, flow, context) { let last for (const [index, id, decision] of [[0, 'once', 'accept'], [0, 'session', 'acceptForSession'], [0, 'cached', null], [1, 'declined', 'decline'], [2, 'cancelled', 'cancel']]) { const beforeHits = context.network.hits.length - const current = { id, step: 0, url: context.network.urls[index] } + const target = new URL(context.network.urls[index]) + target.pathname = `/canary/${id}/${randomUUID()}` + const current = { id, step: 0, url: target.href } flow.current = current const turn = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', permissions: 'fixture_network', input: [{ type: 'text', text: `Run the controlled ${id} native network decision.` }] }) let approval @@ -633,7 +714,7 @@ async function driveNetwork(client, capture, flow, context) { export async function captureNativeScenario(name, options = {}) { assert(scenarios.includes(name), `unknown native scenario ${name}`) const context = await roots(options) - const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'childApproval', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode', 'requirementsGuard'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, policyClientProbe: context.policyClientProbe, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } Object.defineProperty(capture, 'secretValues', { value: new Set(), enumerable: false }) capture.environment.cloud = Object.fromEntries(['CLOUD_RUN_JOB', 'CLOUD_RUN_EXECUTION', 'CLOUD_RUN_TASK_INDEX', 'CLOUD_RUN_TASK_ATTEMPT'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])) if (options.seccompProbeReceipt) capture.environment.seccompProbe = JSON.parse(await fsp.readFile(options.seccompProbeReceipt, 'utf8')) @@ -663,6 +744,7 @@ export async function captureNativeScenario(name, options = {}) { let client let fixture let modelStep = 0 + let childModelStep = 0 let releaseStream const childRequested = deferred() const childStream = deferred() @@ -684,6 +766,18 @@ export async function captureNativeScenario(name, options = {}) { const childInput = record.body.input.some(item => item.role === 'user' && JSON.stringify(item.content).includes('freshell-child-native-probe')) if (childInput) { childRequested.resolve() + if (options.childApproval) { + if (childModelStep++ === 0) { + const names = record.body.tools.flatMap(tool => tool.type === 'namespace' ? tool.tools.map(member => member.name) : [tool.name]) + const name = ['exec_command', 'shell_command', 'shell'].find(name => names.includes(name)) + assert(name, 'actual native child must advertise an execution tool') + const args = name === 'exec_command' ? { cmd: 'cat contract-input.txt', shell: '/bin/sh', login: false, max_output_tokens: 1000 } : name === 'shell' ? { command: ['sh', '-c', 'cat contract-input.txt'] } : { command: 'cat contract-input.txt' } + return advertisedCall(record, name, args, 'child_contract_command') + } + const output = toolOutput(record, 'call_child_contract_command') + assert.match(output, /codex-child-untrusted-command-ran/) + capture.observations.childCommandOutput = output + } return { text: 'Exact child final answer.', pauseAfterDelta: () => childStream.promise } } if (step === 0) { @@ -708,7 +802,7 @@ export async function captureNativeScenario(name, options = {}) { const quote = value => `'${value.replaceAll("'", "'\\''")}'` const networkScript = `import socket; s=socket.create_connection(('127.0.0.1', ${new URL(fixture.baseUrl).port}), timeout=2); s.sendall(b'GET /native-network-canary HTTP/1.0\\r\\n\\r\\n'); print(s.recv(128))` commands.push(`printf forbidden-outside > ${quote(path.join(context.stateRoot, 'outside/forbidden.txt'))}`, 'printf forbidden-git > .git/freshell-contract-denied', 'printf forbidden-codex > .codex/freshell-contract-denied', `python3 -c ${quote(networkScript)}`) - if (options.seccompProbeReceipt) commands.push('printf forbidden-agents > .agents/freshell-contract-denied', `printf forbidden-home > ${quote(path.join(context.environment.HOME, 'forbidden.txt'))}`, `printf forbidden-state > ${quote(path.join(context.environment.CODEX_HOME, 'forbidden.txt'))}`, `printf forbidden-tmpdir > ${quote(path.join(context.environment.TMPDIR, 'forbidden.txt'))}`, 'printf forbidden-global-temp > /tmp/freshell-codex-excluded-temp', ...(options.sandboxMode === 'read-only' ? ['printf forbidden-workspace > contract-readonly-forbidden.txt'] : [])) + commands.push('printf forbidden-agents > .agents/freshell-contract-denied', `printf forbidden-home > ${quote(path.join(context.environment.HOME, 'forbidden.txt'))}`, `printf forbidden-state > ${quote(path.join(context.environment.CODEX_HOME, 'forbidden.txt'))}`, `printf forbidden-tmpdir > ${quote(path.join(context.environment.TMPDIR, 'forbidden.txt'))}`, 'printf forbidden-global-temp > /tmp/freshell-codex-excluded-temp', ...(options.sandboxMode === 'read-only' ? ['printf forbidden-workspace > contract-readonly-forbidden.txt'] : [])) } const deniedStep = options.probePolicyEffects && step === commands.length + 1 if (name === 'approval-policy-restricted-exec' && (step < commands.length || deniedStep)) { @@ -741,6 +835,7 @@ export async function captureNativeScenario(name, options = {}) { extraConfig = `default_permissions = "fixture_network"\napproval_policy = "on-request"\napprovals_reviewer = "user"\n${extraConfig}\n[permissions.fixture_network.filesystem]\n":minimal" = "read"\n${JSON.stringify(context.workspace)} = "read"\n${JSON.stringify(context.artifact.path)} = "read"\n[permissions.fixture_network.network]\nenabled = true\nmode = "limited"\nallow_local_binding = true\n[features.network_proxy]\nenabled = true\nproxy_url = "http://127.0.0.1:${context.network.proxyPort}"\nenable_socks5 = false\nenable_socks5_udp = false\nallow_upstream_proxy = false\nallow_local_binding = true\nmode = "limited"\ncredential_broker = false\n` } await configure(context, fixture.baseUrl, extraConfig) + if (options.childApproval) await fsp.writeFile(path.join(context.workspace, 'contract-input.txt'), 'codex-child-untrusted-command-ran\n') const restricted = name === 'approval-policy-restricted-exec' && !options.approvalControl if (options.largeOutput) { context.largeCommand = path.join(context.workspace, 'large-output.py') @@ -757,7 +852,7 @@ export async function captureNativeScenario(name, options = {}) { await fsp.mkdir(path.join(context.workspace, '.agents')) await fsp.mkdir(path.join(context.stateRoot, 'outside'), { mode: 0o700 }) capture.observations.gitCommonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: context.workspace, env: context.environment, encoding: 'utf8', timeout: 15_000 }).stdout.trim() - if (options.seccompProbeReceipt) { + { const mountRoot = path.join(context.stateRoot, 'raw-mount-attempt') await fsp.mkdir(mountRoot) const mounted = spawnSync('mount', ['-t', 'tmpfs', 'tmpfs', mountRoot], { env: context.environment, encoding: 'utf8', timeout: 15_000 }) @@ -774,9 +869,15 @@ export async function captureNativeScenario(name, options = {}) { assert.equal(fixture.traffic.length, 0, 'public config rejection must precede model work') } // Only this owned fixture configuration is rewritten. Production source - // projection is a later gate, and is never claimed by this native probe. + // copying is qualified separately through the production source-precedence path. await configure(context, fixture.baseUrl, extraConfig) } + if (options.requirementsGuard) { + await nativeRequirementsGuard(context, capture) + assert.equal(fixture.traffic.length, 0, 'a refused policy cannot reach model work') + capture.passed = true + return capture + } client = new NativeClient(capture.binary.path, context, capture) client.onServerRequest = frame => { if (frame.method === 'item/commandExecution/requestApproval' && !options.approvalControl) { @@ -795,7 +896,7 @@ export async function captureNativeScenario(name, options = {}) { capture.observations.models = await client.request('model/list', { includeHidden: false, limit: 100 }) assert(capture.observations.models.data.length > 0, 'release model discovery must return actual metadata') } - const policy = options.probeApprovalPolicy ?? (restricted ? 'untrusted' : name === 'mcp-form-url' || options.approvalControl ? 'on-request' : 'never') + const policy = options.probeApprovalPolicy ?? (restricted || options.childApproval ? 'untrusted' : name === 'mcp-form-url' || options.approvalControl ? 'on-request' : 'never') const sandboxMode = options.sandboxMode ?? 'read-only' const started = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: policy, ...(options.approvalControl === 'network' ? { permissions: 'fixture_network' } : { sandbox: sandboxMode }), experimentalRawEvents: false, persistExtendedHistory: true, ephemeral: options.ephemeralParent ?? false, ...(options.historyMode ? { historyMode: options.historyMode } : {}) }) capture.threadId = started.thread.id @@ -857,6 +958,12 @@ export async function captureNativeScenario(name, options = {}) { })(), 30_000, 'child native completion/saving') capture.reads.push({ label: 'child-settled', body: childSettled }) capture.observations.childFinalText = options.ephemeralParent ? null : assistantText(childSettled) + if (options.childApproval) { + const request = capture.serverRequests.find(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.itemId === 'call_child_contract_command') + assert(request, 'spawned child must actually retain the parent untrusted approval policy') + assert.equal(request.params.threadId, childId, 'approval must identify the actual native child') + capture.observations.childApprovalPolicyProof = { parentPolicy: policy, childId, requestId: request.id, approvalId: request.params.approvalId ?? null, request: request.params, actualToolOutput: capture.observations.childCommandOutput } + } parentStream.resolve() } if (name === 'active-input-stop') { @@ -991,7 +1098,7 @@ export async function captureNativeScenario(name, options = {}) { codexWrite: fs.existsSync(path.join(context.workspace, '.codex/freshell-contract-denied')), networkRequests: fixture.traffic.filter(request => request.path === '/native-network-canary').length, } - if (options.seccompProbeReceipt) Object.assign(capture.observations.policyEffects, { + Object.assign(capture.observations.policyEffects, { agentsWrite: fs.existsSync(path.join(context.workspace, '.agents/freshell-contract-denied')), homeWrite: fs.existsSync(path.join(context.environment.HOME, 'forbidden.txt')), stateWrite: fs.existsSync(path.join(context.environment.CODEX_HOME, 'forbidden.txt')), @@ -1165,6 +1272,9 @@ async function main(args) { else if (args[index] === '--duplicate-bodies') options.duplicateBodies = true else if (args[index] === '--large-output') options.largeOutput = true else if (args[index] === '--startup-cancellation') options.startupCancellation = true + else if (args[index] === '--missing-policy-client-probe') options.policyClientProbe = '/freshell-missing-policy-client-probe' + else if (args[index] === '--requirements-guard') options.requirementsGuard = true + else if (args[index] === '--child-approval') options.childApproval = true else if (args[index] === '--ephemeral-parent') options.ephemeralParent = true else if (args[index] === '--probe-network-enabled') options.probeNetworkEnabled = true else if (args[index] === '--approval-control') { options.approvalControl = args[++index]; assert(['stdin', 'network'].includes(options.approvalControl)) } diff --git a/test/integration/providers/codex-cli-contract.test.ts b/test/integration/providers/codex-cli-contract.test.ts index c5215f342..537de5a0a 100644 --- a/test/integration/providers/codex-cli-contract.test.ts +++ b/test/integration/providers/codex-cli-contract.test.ts @@ -4,6 +4,11 @@ import { describe, expect, it } from 'vitest' import { captureNativeScenario } from './codex-cli-contract.mjs' describe('Codex 0.164.0 release contract through an isolated Responses provider', () => { + it('refuses a missing real-client prerequisite before opening a native thread', async () => { + await expect(captureNativeScenario('text-history', { policyClientProbe: '/freshell-missing-policy-client-probe' })) + .rejects.toThrow(/freshell-missing-policy-client-probe/) + }, 180_000) + it.each(['legacy', 'paginated'])('retains the actual final answer through %s history and a cold resume', async historyMode => { const capture = await captureNativeScenario('text-history', { historyMode, timeoutMs: 120_000 }) expect(capture.binary.version).toBe('0.164.0') @@ -30,10 +35,18 @@ describe('Codex 0.164.0 release contract through an isolated Responses provider' it.each(['read-only', 'workspace-write'])('executes restricted never policy with actual %s effects', async sandboxMode => { const capture = await captureNativeScenario('approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true, timeoutMs: 120_000 }) expect(capture.observations.restrictedCommandExecuted).toBe(true) - expect(capture.observations.policyEffects).toEqual(expect.objectContaining({ outsideWrite: false, gitWrite: false, codexWrite: false, networkRequests: 0 })) + expect(capture.observations.policyEffects).toEqual(expect.objectContaining({ outsideWrite: false, gitWrite: false, codexWrite: false, agentsWrite: false, homeWrite: false, stateWrite: false, excludedTmpdirWrite: false, excludedGlobalTempWrite: false, networkRequests: 0 })) expect(capture.observations.workspaceWritePresent).toBe(sandboxMode === 'workspace-write') }, 180_000) + it('refuses untrusted work through the real Rust client under actual native requirements', async () => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { requirementsGuard: true, timeoutMs: 120_000 }) + expect(capture.observations.requirementsGuard.refusedRoutes).toEqual(['create', 'resume', 'fork', 'send']) + expect(capture.observations.requirementsGuard.workRequests).toBe(0) + expect(capture.observations.requirementsGuard.ownedListenerSettled).toBe(true) + expect(capture.fixtureTraffic).toHaveLength(0) + }, 180_000) + it.each(['stdin', 'network'])('executes actual %s approval effects and cancellation', async approvalControl => { const capture = await captureNativeScenario('approval-policy-restricted-exec', { approvalControl, timeoutMs: 120_000 }) expect(capture.passed).toBe(true) @@ -70,4 +83,11 @@ describe('Codex 0.164.0 release contract through an isolated Responses provider' expect(capture.observations.coldRecoveryState).toBe('saved-history-unavailable-no-invented-fragments') }, 180_000) + it('preserves native untrusted intent in the spawned child without a writer takeover', async () => { + const capture = await captureNativeScenario('parent-child-read', { childApproval: true, timeoutMs: 120_000 }) + expect(capture.observations.childApprovalPolicyProof.parentPolicy).toBe('untrusted') + expect(capture.observations.childApprovalPolicyProof.request.threadId).toBe(capture.observations.childId) + expect(capture.observations.childObserverMethods.every((method: string) => method === 'thread/read')).toBe(true) + }, 180_000) + }) diff --git a/test/integration/providers/codex-local-provider.mjs b/test/integration/providers/codex-local-provider.mjs index 8a27dfa39..94fd682b8 100644 --- a/test/integration/providers/codex-local-provider.mjs +++ b/test/integration/providers/codex-local-provider.mjs @@ -77,6 +77,10 @@ export async function createCodexLocalProvider(handler) { const parsed = body ? JSON.parse(body) : null const record = { index: traffic.length, method: request.method, path: request.url, loopback: request.socket.remoteAddress === '127.0.0.1', body: parsed } traffic.push(record) + if (request.method === 'GET' && request.url === '/elicitation/example' && record.loopback) { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end('Owned elicitation fixture

The page is open. The native decision is still separate.

') + return + } if (request.method !== 'POST' || request.url !== '/v1/responses' || !record.loopback) { response.writeHead(404).end(); return } diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx index d93dcf991..b6740f42c 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx @@ -525,6 +525,35 @@ describe('FreshAgentView SESSION_RESERVED re-drive (Task 14)', () => { expect(document.querySelector('.fresh-agent-error-card')).toBeNull() }) + it('keeps the Freshcodex prompt and settings when preparation refuses the selected configuration', async () => { + const key = 'fresh-agent-draft:freshcodex:req-codex-config' + const draft = 'Keep this prompt until the selected configuration is repaired.' + const selected = { + sessionType: 'freshcodex' as const, provider: 'codex' as const, + status: 'creating' as const, createRequestId: 'req-codex-config', initialCwd: '/owned/project', + model: 'contract-model', modelSelection: { kind: 'exact' as const, modelId: 'contract-model' }, + effort: 'low', permissionMode: 'untrusted', sandbox: 'workspace-write' as const, + } + window.sessionStorage.setItem(key, draft) + try { + renderFreshAgentPane(selected) + await flush() + const creates = sentOfType('freshAgent.create').length + const message = 'Codex approval_policy in /owned/codex/config.toml cannot preserve the selected source.' + receiveWsBoth({ type: 'freshAgent.create.failed', requestId: selected.createRequestId, + code: 'INVALID_REQUEST', message, retryable: false }) + await flush() + expect(screen.getByText(message)).toBeVisible() + expect(leafContent(store.getState())).toEqual(expect.objectContaining({ ...selected, status: 'create-failed' })) + expect(store.getState().freshAgent.pendingCreateFailures[selected.createRequestId]).toEqual({ code: 'INVALID_REQUEST', message, retryable: false }) + expect(screen.getByRole('textbox')).toHaveValue(draft) + expect(window.sessionStorage.getItem(key)).toBe(draft) + await act(async () => { await vi.advanceTimersByTimeAsync(FRESH_AGENT_RESERVE_RETRY_FLOOR_MS + 20) }) + expect(sentOfType('freshAgent.create')).toHaveLength(creates) + expect(sentOfType('freshAgent.send')).toHaveLength(0) + } finally { window.sessionStorage.removeItem(key) } + }) + it('non-reserved create.failed still lands create-failed status (regression)', async () => { renderFreshAgentPane({ status: 'creating', createRequestId: 'req-1' }) await flush() From 89e46282aea575288736d525d8eb08f1c8521d86 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:20:18 -0700 Subject: [PATCH 06/92] test(clock): account for wall time during live advance --- crates/freshell-platform/src/clock.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/freshell-platform/src/clock.rs b/crates/freshell-platform/src/clock.rs index 7aae274fc..924236b4e 100644 --- a/crates/freshell-platform/src/clock.rs +++ b/crates/freshell-platform/src/clock.rs @@ -347,10 +347,17 @@ mod tests { #[test] fn advance_moves_live_time_forward_by_exactly_the_delta() { let (_lock, _guard) = OverrideGuard::locked(true); + let real_before = system_now_ms(); let before = snapshot(); + // Live time keeps moving during advance; exercise that movement rather + // than requiring both snapshots to land in the same millisecond. + std::thread::sleep(std::time::Duration::from_millis(2)); advance_ms(90_000).unwrap(); let after = snapshot(); - assert_eq!(after.now_ms - before.now_ms, 90_000); + let real_after = system_now_ms(); + let delta = after.now_ms - before.now_ms; + assert!(delta >= 90_000 && delta <= 90_000 + real_after - real_before); + assert_eq!(after.offset_ms - before.offset_ms, 90_000); assert_eq!(after.offset_ms, 90_000); assert_eq!(after.mode, ClockMode::Live); } From d309e4524928a74e2c0746f6e5f0ceec8d152528 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:28:38 -0700 Subject: [PATCH 07/92] feat(codex): qualify the installed 0.164 managed runtime --- AGENTS.md | 2 +- crates/freshell-codex/src/launch_lifecycle.rs | 8 +- crates/freshell-codex/src/lib.rs | 2 + crates/freshell-codex/src/managed_launch.rs | 286 ++++++++++++++++++ crates/freshell-freshagent/src/codex.rs | 8 +- crates/freshell-session-host/src/pty.rs | 36 ++- crates/freshell-supervisor/src/backend.rs | 17 +- .../src/backend/codex_sandbox.rs | 43 ++- .../freshell-supervisor/src/resume_catalog.rs | 15 +- docker/runtime/Dockerfile | 7 +- docker/runtime/codex-assemble.mjs | 48 +++ docker/runtime/codex-launch.mjs | 52 ++++ docker/runtime/codex-native-sandbox.json | 6 +- docker/runtime/provider-versions.json | 2 +- docs/development/codex-native-runtime.md | 15 + scripts/testing/prepare-codex-contract.mjs | 67 ++-- scripts/testing/runtime-sandbox.ts | 9 +- scripts/testing/runtime-test-broker.ts | 18 +- .../providers/codex-cli-contract.mjs | 27 +- .../providers/codex-managed-cleanup.mjs | 73 +++++ .../providers/codex-managed-contract.mjs | 127 ++++++++ .../providers/codex-managed-launch.mjs | 49 +++ .../testing/codex-managed-launch.test.ts | 14 + .../tooling/testing/runtime-sandbox.test.ts | 30 +- .../testing/runtime-test-broker.test.ts | 34 +++ 25 files changed, 913 insertions(+), 82 deletions(-) create mode 100644 crates/freshell-codex/src/managed_launch.rs create mode 100644 docker/runtime/codex-assemble.mjs create mode 100644 docker/runtime/codex-launch.mjs create mode 100644 docs/development/codex-native-runtime.md create mode 100644 test/integration/providers/codex-managed-cleanup.mjs create mode 100644 test/integration/providers/codex-managed-contract.mjs create mode 100644 test/integration/providers/codex-managed-launch.mjs create mode 100644 test/unit/tooling/testing/codex-managed-launch.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5ae4c6110..bb3939458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,6 +332,6 @@ All components **must** be accessible for browser-use automation and WCAG compli Managed provider environment and recovery repair: [implementation plan](docs/plans/2026-10-05-managed-provider-environment.md). -Freshcodex completion: [implementation plan](docs/plans/2026-10-25-freshcodex-complete.md). +Freshcodex completion: [implementation plan](docs/plans/2026-10-25-freshcodex-complete.md). Managed native runtime: [qualification and ownership](docs/development/codex-native-runtime.md). When idle-waiting on subagents, always call `wait_agent` with `timeout_ms=3600000` (the 1-hour maximum). `wait_agent` wakes immediately on any subagent message or completion, so long timeouts add no latency — but every wait return (including a no-op timeout) costs a full model turn that re-sends the entire context. Never poll on shorter timeouts. Have workers `send_message` to `/root` at milestones so the parent wakes exactly when there is news. diff --git a/crates/freshell-codex/src/launch_lifecycle.rs b/crates/freshell-codex/src/launch_lifecycle.rs index ab0e5be2f..25d7d99c0 100644 --- a/crates/freshell-codex/src/launch_lifecycle.rs +++ b/crates/freshell-codex/src/launch_lifecycle.rs @@ -1214,13 +1214,7 @@ impl CodexLaunchRuntime for SpawnedCodexAppServerRuntime { #[cfg(target_os = "linux")] if self.sidecar_context.provider_exec_profile { - let additional = if program == "/usr/bin/setpriv" { - &["codex"][..] - } else { - &[] - }; - freshell_platform::provider_exec::check_command(cmd.as_std(), true, additional) - .map_err(|error| error.to_string())?; + crate::managed_launch::check_command(cmd.as_std())?; } let mut child = cmd .spawn() diff --git a/crates/freshell-codex/src/lib.rs b/crates/freshell-codex/src/lib.rs index bc4fbd159..0abcd38c9 100644 --- a/crates/freshell-codex/src/lib.rs +++ b/crates/freshell-codex/src/lib.rs @@ -49,6 +49,8 @@ pub mod remote_proxy_side_effects; #[cfg(feature = "real-transport")] pub mod launch_lifecycle; +#[cfg(all(feature = "real-transport", target_os = "linux"))] +pub mod managed_launch; #[cfg(feature = "real-transport")] pub mod remote_proxy; #[cfg(feature = "real-transport")] diff --git a/crates/freshell-codex/src/managed_launch.rs b/crates/freshell-codex/src/managed_launch.rs new file mode 100644 index 000000000..178a21410 --- /dev/null +++ b/crates/freshell-codex/src/managed_launch.rs @@ -0,0 +1,286 @@ +//! Final exec accounting for the one immutable managed Codex launcher. +use freshell_platform::provider_exec::{check_final_exec, process_profile, Profile}; +use serde::Deserialize; +use std::{collections::BTreeMap, io::Read, path::Path, process::Command}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Identity { + version: String, + layout_file: String, + launch_wrapper: String, + node_executable: String, + npm_wrapper: String, + package_root: String, + helper_directory: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Layout { + version: String, + launch_wrapper: String, + node_executable: String, + npm_wrapper: String, + package_root: String, + helper_directory: String, + native_executable: String, +} + +fn identity() -> Result { + let resource: serde_json::Value = serde_json::from_str(include_str!( + "../../../docker/runtime/codex-native-sandbox.json" + )) + .map_err(|_| "Codex managed launch resource is invalid")?; + serde_json::from_value(resource["native"].clone()) + .map_err(|_| "Codex managed launch identity is incomplete".into()) +} + +/// Keep explicit custom executables; the managed default uses its owned launcher. +pub fn managed_program(program: &str) -> Result { + let identity = identity()?; + Ok(if program == "codex" || program == identity.npm_wrapper { + identity.launch_wrapper + } else { + program.to_owned() + }) +} + +fn read_layout(identity: &Identity) -> Result { + const LIMIT: u64 = 16 * 1024; + let file = std::fs::File::open(&identity.layout_file) + .map_err(|_| "Codex managed launch layout is missing; rebuild the pinned workload image")?; + let metadata = file + .metadata() + .map_err(|_| "Codex managed layout is unreadable")?; + if !metadata.is_file() || metadata.len() > LIMIT { + return Err("Codex managed layout exceeds its finite profile".into()); + } + let mut bytes = Vec::new(); + file.take(LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Codex managed layout is unreadable")?; + if bytes.len() as u64 > LIMIT { + return Err("Codex managed layout changed during its read".into()); + } + let layout: Layout = + serde_json::from_slice(&bytes).map_err(|_| "Codex managed layout is invalid")?; + if layout.version != identity.version + || layout.launch_wrapper != identity.launch_wrapper + || layout.node_executable != identity.node_executable + || layout.npm_wrapper != identity.npm_wrapper + || layout.package_root != identity.package_root + || layout.helper_directory != identity.helper_directory + || !Path::new(&layout.native_executable).is_absolute() + { + return Err("Codex managed layout is incompatible with this server".into()); + } + Ok(layout) +} + +fn managed_environment( + layout: &Layout, + environment: &BTreeMap, +) -> BTreeMap { + let mut child = environment.clone(); + let current = child.get("PATH").cloned().unwrap_or_default(); + let prefix = format!("{}:", layout.helper_directory); + if current != layout.helper_directory && !current.starts_with(&prefix) { + child.insert( + "PATH".into(), + if current.is_empty() { + layout.helper_directory.clone() + } else { + format!("{prefix}{current}") + }, + ); + } + for name in [ + "CODEX_MANAGED_BY_NPM", + "CODEX_MANAGED_BY_BUN", + "CODEX_MANAGED_BY_PNPM", + "CODEX_MANAGED_BY_VITE_PLUS", + "npm_config_user_agent", + "npm_execpath", + ] { + child.remove(name); + } + child.insert( + "CODEX_MANAGED_PACKAGE_ROOT".into(), + layout.package_root.clone(), + ); + child.insert("CODEX_MANAGED_BY_NPM".into(), "1".into()); + child +} + +fn check_managed_stages( + layout: &Layout, + args: &[String], + environment: &BTreeMap, + profile: Profile, +) -> Result<(), String> { + let environment = managed_environment(layout, environment); + let node_args = std::iter::once(layout.npm_wrapper.clone()) + .chain(args.iter().cloned()) + .collect::>(); + // The fixed Node executable reads the npm JS file as an argument; its + // shebang is not another exec. The npm child then execs native directly. + check_final_exec( + &layout.node_executable, + &node_args, + &environment, + &[], + profile, + ) + .map_err(|error| error.to_string())?; + check_final_exec(&layout.native_executable, args, &environment, &[], profile) + .map_err(|error| error.to_string()) +} + +/// Check the original context and the exact later Node/npm/native stages. +pub fn check_launch( + program: &str, + args: &[String], + environment: &BTreeMap, +) -> Result<(), String> { + let profile = process_profile().map_err(|error| error.to_string())?; + let boundary = (program == "/usr/bin/setpriv") + .then(|| args.iter().position(|arg| arg == "--")) + .flatten(); + let (target, provider_args) = if let Some(boundary) = boundary { + ( + args.get(boundary + 1) + .ok_or("Codex identity drop has no provider executable")? + .as_str(), + &args[boundary + 2..], + ) + } else { + (program, args) + }; + let additional = if boundary.is_some() { + vec![target] + } else { + vec![] + }; + check_final_exec(program, args, environment, &additional, profile) + .map_err(|error| error.to_string())?; + let identity = identity()?; + if target != identity.launch_wrapper { + return Ok(()); + } + check_managed_stages( + &read_layout(&identity)?, + provider_args, + environment, + profile, + ) +} + +pub fn check_command(command: &Command) -> Result<(), String> { + let mut environment = std::env::vars_os() + .map(|(name, value)| { + Ok(( + name.into_string() + .map_err(|_| "provider exec profile String")?, + value + .into_string() + .map_err(|_| "provider exec profile String")?, + )) + }) + .collect::, String>>()?; + for (name, value) in command.get_envs() { + let name = name.to_str().ok_or("provider exec profile String")?; + match value { + Some(value) => { + environment.insert( + name.into(), + value.to_str().ok_or("provider exec profile String")?.into(), + ); + } + None => { + environment.remove(name); + } + } + } + let args = command + .get_args() + .map(|arg| { + arg.to_str() + .map(str::to_owned) + .ok_or("provider exec profile String".to_owned()) + }) + .collect::, _>>()?; + check_launch( + command + .get_program() + .to_str() + .ok_or("provider exec profile Executable")?, + &args, + &environment, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn managed_helper_prefix_is_accounted_before_dispatch_at_actual_kernel_boundary() { + use std::os::unix::fs::PermissionsExt; + let owned = tempfile::tempdir().unwrap(); + let marker = owned.path().join("dispatched"); + let script = owned.path().join("entry.sh"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf dispatched > '{}'\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let layout = Layout { + version: "fixture".into(), + launch_wrapper: script.to_str().unwrap().into(), + node_executable: "/bin/sh".into(), + npm_wrapper: script.to_str().unwrap().into(), + package_root: owned.path().to_str().unwrap().into(), + helper_directory: owned.path().join("helpers").to_str().unwrap().into(), + native_executable: "/usr/bin/true".into(), + }; + let profile = process_profile().unwrap(); + let environment = BTreeMap::from([( + "PATH".into(), + "x".repeat(profile.page_bytes * 32 - "PATH=".len() - 1), + )]); + check_final_exec( + "/bin/sh", + &[layout.npm_wrapper.clone()], + &environment, + &[], + profile, + ) + .expect("initial environment must fit before the wrapper changes PATH"); + let checked = check_managed_stages(&layout, &[], &environment, profile); + assert!( + matches!(checked, Err(ref error) if error.contains("String")), + "final helper PATH must refuse before dispatch: {checked:?}" + ); + let expanded = format!("{}:{}", layout.helper_directory, environment["PATH"]); + let error = Command::new("/bin/sh") + .arg(&script) + .env_clear() + .env("PATH", expanded) + .spawn() + .err() + .expect("actual Linux kernel must reject the final oversized PATH"); + assert_eq!(error.raw_os_error(), Some(libc::E2BIG)); + assert!(!marker.exists()); + let small = BTreeMap::from([("PATH".into(), "/usr/bin:/bin".into())]); + check_managed_stages(&layout, &[], &small, profile).unwrap(); + assert!(Command::new("/bin/sh") + .arg(&script) + .env_clear() + .envs(small) + .status() + .unwrap() + .success()); + assert!(marker.is_file()); + } +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 8f69a6db0..b6096cf0a 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -7442,13 +7442,7 @@ impl FreshCodexState { #[cfg(target_os = "linux")] if self.sidecar_launch_context.provider_exec_profile { - let additional = if codex_program == "/usr/bin/setpriv" { - &["codex"][..] - } else { - &[] - }; - freshell_platform::provider_exec::check_command(cmd.as_std(), true, additional) - .map_err(|error| error.to_string())?; + freshell_codex::managed_launch::check_command(cmd.as_std())?; } let mut child = cmd .spawn() diff --git a/crates/freshell-session-host/src/pty.rs b/crates/freshell-session-host/src/pty.rs index 5a0c52c8c..e35742b1a 100644 --- a/crates/freshell-session-host/src/pty.rs +++ b/crates/freshell-session-host/src/pty.rs @@ -114,7 +114,15 @@ impl HostedPty { } }); let mut provider_args = crate::provider_identity_args(launch.run_as_uid, launch.run_as_gid); - provider_args.push(launch.program.clone()); + #[cfg(target_os = "linux")] + let provider_program = if launch.mode == "codex" { + freshell_codex::managed_launch::managed_program(&launch.program)? + } else { + launch.program.clone() + }; + #[cfg(not(target_os = "linux"))] + let provider_program = launch.program.clone(); + provider_args.push(provider_program); for (index, argument) in launch.args.iter().enumerate() { provider_args.push(if launch.mode == "claude" && index > 0 && launch.args[index - 1] == "--settings" && argument == freshell_platform::claude_inputs::MANAGED_SETTINGS_ARGUMENT { claude_settings.as_ref().ok_or("selected Claude settings origin is unavailable; retain inputs and reprepare before dispatch")?.clone() @@ -139,15 +147,23 @@ impl HostedPty { provider_env.insert("GIT_CONFIG_VALUE_0".into(), launch.workspace_path.clone()); let spawn = (|| { #[cfg(target_os = "linux")] - freshell_platform::provider_exec::check_final_exec( - &spec.program, - &spec.args, - &provider_env, - &[&launch.program], - freshell_platform::provider_exec::process_profile() - .map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; + if launch.mode == "codex" { + freshell_codex::managed_launch::check_launch( + &spec.program, + &spec.args, + &provider_env, + )?; + } else { + freshell_platform::provider_exec::check_final_exec( + &spec.program, + &spec.args, + &provider_env, + &[&launch.program], + freshell_platform::provider_exec::process_profile() + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + } PtyTerminal::spawn_with_sink( &spec, &provider_env, diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 7fa0d2e79..05af2834e 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -597,11 +597,16 @@ fn runtime_host_environment( ); } if agent.provider == freshell_runtime_protocol::FreshProvider::Codex { + let program = if agent.fixture_transport.is_none() { + codex_sandbox::managed_program("codex")? + } else { + "codex".into() + }; values.insert( "CODEX_CMD".into(), format!( - "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- codex", - agent.run_as_uid, agent.run_as_gid + "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- {}", + agent.run_as_uid, agent.run_as_gid, program ), ); } @@ -631,7 +636,9 @@ fn runtime_host_environment( "CODEX_CMD".into(), format!( "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- {}", - terminal.run_as_uid, terminal.run_as_gid, terminal.program + terminal.run_as_uid, + terminal.run_as_gid, + codex_sandbox::managed_program(&terminal.program)? ), ); } @@ -2069,7 +2076,7 @@ mod tests { assert_eq!(response.body, b"test"); } #[test] - fn codex_host_environment_drops_to_the_provider_uid_and_uses_soul_home() { + fn codex_host_environment_uses_soul_home_and_excludes_auth_token() { let terminal = TerminalLaunchSpec { terminal_id: "terminal-one".into(), stream_id: "stream-one".into(), @@ -2106,7 +2113,7 @@ mod tests { assert!(env .iter() .any(|value| value == "CODEX_HOME=/home/freshell/provider/.codex")); - assert!(env.iter().any(|value| value == "CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- codex")); + assert!(!env.iter().any(|value| value.contains("AUTH_TOKEN"))); } diff --git a/crates/freshell-supervisor/src/backend/codex_sandbox.rs b/crates/freshell-supervisor/src/backend/codex_sandbox.rs index 8843cd78a..ed3bf2a0c 100644 --- a/crates/freshell-supervisor/src/backend/codex_sandbox.rs +++ b/crates/freshell-supervisor/src/backend/codex_sandbox.rs @@ -17,6 +17,10 @@ pub(super) struct Identity { pub declared_managed_version: String, pub helper_sha256: String, pub launch_wrapper: String, + pub npm_wrapper: String, + pub layout_file: String, + pub helper_directory: String, + pub npm_entrypoint_sha256: String, } struct Policy { @@ -29,10 +33,18 @@ pub(super) fn selected( terminal: Option<&TerminalLaunchSpec>, agent: Option<&FreshAgentLaunchSpec>, ) -> bool { - terminal.is_some_and(|terminal| terminal.mode == "codex") - || agent.is_some_and(|agent| { - agent.provider == FreshProvider::Codex && agent.fixture_transport.is_none() - }) + terminal.is_some_and(|terminal| { + terminal.mode == "codex" + && (terminal.program == "codex" + || policy() + .map(|policy| { + terminal.program == policy.identity.npm_wrapper + || terminal.program == policy.identity.launch_wrapper + }) + .unwrap_or(true)) + }) || agent.is_some_and(|agent| { + agent.provider == FreshProvider::Codex && agent.fixture_transport.is_none() + }) } fn parse(raw: &str) -> Result { @@ -53,7 +65,10 @@ fn parse(raw: &str) -> Result { .map(str::to_owned) .ok_or_else(|| format!("missing native policy field {pointer}")) }; - let profile = resource["profile"].clone(); + let mut profile = resource["profile"].clone(); + // Match the canonical bytes used by the executed native fixture profile. + // serde_json preserves source key order in this workspace. + profile.sort_all_objects(); let profile_json = serde_json::to_string(&profile).map_err(|error| error.to_string())?; let identity = Identity { id: field("/id")?, @@ -64,7 +79,14 @@ fn parse(raw: &str) -> Result { .to_owned(), helper_sha256: field("/native/bwrapSha256")?, launch_wrapper: field("/native/launchWrapper")?, + npm_wrapper: field("/native/npmWrapper")?, + layout_file: field("/native/layoutFile")?, + helper_directory: field("/native/helperDirectory")?, + npm_entrypoint_sha256: field("/native/npmEntrypointSha256")?, }; + if identity.supported_native_version != identity.declared_managed_version { + return Err("managed Codex pin and supported native sandbox version differ; rebuild an aligned workload image".into()); + } Ok(Policy { identity, profile, @@ -102,6 +124,17 @@ pub(super) fn security_options( Ok(options) } +pub(super) fn managed_program(program: &str) -> Result { + let policy = policy()?; + Ok( + if program == "codex" || program == policy.identity.npm_wrapper { + policy.identity.launch_wrapper.clone() + } else { + program.to_owned() + }, + ) +} + pub(super) fn verify(host: &Value) -> Result<(), BackendError> { let expected = policy()?; let fail = || { diff --git a/crates/freshell-supervisor/src/resume_catalog.rs b/crates/freshell-supervisor/src/resume_catalog.rs index d714eb8f0..c0d00b6dd 100644 --- a/crates/freshell-supervisor/src/resume_catalog.rs +++ b/crates/freshell-supervisor/src/resume_catalog.rs @@ -459,20 +459,7 @@ mod tests { } #[test] - fn every_terminal_resume_adapter_uses_the_checked_in_provider_pin() { - assert_eq!( - pinned_provider_version("claude").as_deref(), - Some("2.1.263") - ); - assert_eq!(pinned_provider_version("codex").as_deref(), Some("0.147.0")); - assert_eq!( - pinned_provider_version("opencode").as_deref(), - Some("1.18.21") - ); - assert_eq!( - pinned_provider_version("amplifier").as_deref(), - Some("0.1.1") - ); + fn unknown_provider_has_no_resume_version() { assert_eq!(pinned_provider_version("not-a-provider"), None); } } diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 4a4d95814..d7ab97f57 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -34,7 +34,7 @@ RUN npm install --global pnpm@10.34.5 \ FROM ubuntu@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG CLAUDE_CODE_VERSION=2.1.263 -ARG CODEX_VERSION=0.147.0 +ARG CODEX_VERSION=0.164.0 ARG OPENCODE_VERSION=1.18.21 ARG BUN_VERSION=1.4.3 ARG AMPLIFIER_APP_CLI_COMMIT=1873aa980535c99a743b17172e4231833f6c8741 @@ -76,6 +76,11 @@ RUN apt-get update \ && /opt/amplifier-src/.venv/bin/python -c 'import importlib.metadata as m; assert m.version("openai") == "2.8.1"; assert m.version("openai-harmony") == "0.0.8"; assert m.version("amplifier-module-provider-vllm") == "1.0.0"' \ && /lib/x86_64-linux-gnu/libc.so.6 | grep -F '2.39' +COPY docker/runtime/codex-native-sandbox.json /opt/freshell-codex/codex-native-sandbox.json +COPY docker/runtime/codex-launch.mjs /opt/freshell-codex/launch.mjs +COPY docker/runtime/codex-assemble.mjs /opt/freshell-codex/codex-assemble.mjs +RUN FRESHELL_CODEX_BUILD_VERSION="${CODEX_VERSION}" node /opt/freshell-codex/codex-assemble.mjs + # FreshClaude/Kilroy run inside the soul host, so their JS SDK bridge and its # pinned dependency must be part of the workload image rather than resolved # through a web-server worktree path. diff --git a/docker/runtime/codex-assemble.mjs b/docker/runtime/codex-assemble.mjs new file mode 100644 index 000000000..999a2e13d --- /dev/null +++ b/docker/runtime/codex-assemble.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { validateManagedLayout } from './launch.mjs' + +const directory = path.dirname(fileURLToPath(import.meta.url)) +const { native: identity } = JSON.parse(fs.readFileSync(path.join(directory, 'codex-native-sandbox.json'), 'utf8')) +if (process.getuid() !== 0 || process.env.FRESHELL_CODEX_BUILD_VERSION !== identity.version) throw new Error('Codex assembly requires the exact declared version and owned root build context') +const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +const requireCodex = createRequire(path.join(identity.packageRoot, 'package.json')) +const platformPackageRoot = path.dirname(requireCodex.resolve('@openai/codex-linux-x64/package.json')) +const layout = { + version: identity.version, launchWrapper: identity.launchWrapper, nodeExecutable: identity.nodeExecutable, + npmWrapper: identity.npmWrapper, packageRoot: fs.realpathSync(identity.packageRoot), platformPackageRoot, + nativeExecutable: path.join(platformPackageRoot, identity.executable), bwrap: path.join(platformPackageRoot, identity.bwrap), helperDirectory: identity.helperDirectory, +} +function readableOwnedTree(root) { + fs.chmodSync(root, 0o755) + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const file = path.join(root, entry.name) + if (entry.isDirectory()) readableOwnedTree(file) + else if (entry.isFile()) fs.chmodSync(file, fs.statSync(file).mode & 0o111 ? 0o755 : 0o644) + else throw new Error('Unexpected native package resource type') + } +} +function protectedAncestors(file) { + for (let current = file; ; current = path.dirname(current)) { + const stat = fs.statSync(current) + if (stat.uid !== 0 || stat.mode & 0o022) throw new Error(`Codex managed resource is replaceable: ${current}`) + if (path.dirname(current) === current) break + } +} +readableOwnedTree(platformPackageRoot) +fs.chmodSync(identity.packageRoot, 0o755) +fs.chmodSync(path.dirname(fs.realpathSync(identity.npmWrapper)), 0o755) +fs.chmodSync(fs.realpathSync(identity.npmWrapper), 0o755) +if (hash(layout.nativeExecutable) !== identity.executableSha256) throw new Error('Installed Codex native bytes differ from the verified release') +if (hash(layout.bwrap) !== identity.bwrapSha256 || hash(fs.realpathSync(identity.npmWrapper)) !== identity.npmEntrypointSha256) throw new Error('Installed Codex helper or npm entrypoint bytes differ from the verified release') +fs.mkdirSync(identity.helperDirectory, { recursive: true, mode: 0o755 }) +fs.symlinkSync(layout.bwrap, path.join(identity.helperDirectory, 'bwrap')) +fs.symlinkSync('launch.mjs', identity.launchWrapper) +fs.chmodSync(path.join(directory, 'launch.mjs'), 0o755) +for (const file of [layout.nativeExecutable, layout.bwrap, fs.realpathSync(identity.npmWrapper), identity.launchWrapper, identity.helperDirectory]) protectedAncestors(file) +validateManagedLayout(layout, identity) +fs.writeFileSync(identity.layoutFile, `${JSON.stringify(layout, null, 2)}\n`, { mode: 0o644, flag: 'wx' }) +console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_layout_assembled', version: identity.version, nativeSha256: identity.executableSha256, helperSha256: identity.bwrapSha256, npmSha256: identity.npmEntrypointSha256, layout })) diff --git a/docker/runtime/codex-launch.mjs b/docker/runtime/codex-launch.mjs new file mode 100644 index 000000000..01cb023d0 --- /dev/null +++ b/docker/runtime/codex-launch.mjs @@ -0,0 +1,52 @@ +#!/usr/local/bin/node +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +const fail = reason => { throw new Error(`Codex managed launch is incompatible: ${reason}`) } + +export function validateManagedLayout(layout, identity) { + if (process.platform !== identity.platform || process.arch !== identity.arch || layout.version !== identity.version) fail('native platform or version changed') + for (const key of ['launchWrapper', 'nodeExecutable', 'npmWrapper', 'packageRoot', 'helperDirectory']) { + if (layout[key] !== identity[key] || !path.isAbsolute(layout[key])) fail(`declared ${key} changed`) + } + if (layout.nativeExecutable !== path.join(layout.platformPackageRoot, identity.executable) + || layout.bwrap !== path.join(layout.platformPackageRoot, identity.bwrap)) fail('installed native resource layout changed') + const main = JSON.parse(fs.readFileSync(path.join(layout.packageRoot, 'package.json'), 'utf8')) + const platform = JSON.parse(fs.readFileSync(path.join(layout.platformPackageRoot, 'package.json'), 'utf8')) + if (main.name !== '@openai/codex' || main.version !== identity.version || platform.version !== `${identity.version}-linux-x64`) fail('installed npm package version changed') + for (const file of [layout.nodeExecutable, layout.npmWrapper, layout.nativeExecutable, layout.bwrap]) fs.accessSync(file, fs.constants.R_OK | fs.constants.X_OK) + if (sha256(fs.realpathSync(layout.npmWrapper)) !== identity.npmEntrypointSha256) fail('published npm entrypoint bytes changed') + if (sha256(layout.bwrap) !== identity.bwrapSha256) fail('verified native helper bytes changed') + let helper + try { helper = fs.realpathSync(path.join(layout.helperDirectory, 'bwrap')) } catch { fail('owned bwrap helper is missing') } + if (helper !== fs.realpathSync(layout.bwrap)) fail('owned bwrap helper points to a different resource') +} + +export function launchManagedCodex(layout, identity, args, environment = process.env) { + validateManagedLayout(layout, identity) + if (typeof process.execve !== 'function') fail('the fixed Node runtime does not support execve') + const child = { ...environment } + const prefix = `${layout.helperDirectory}${path.delimiter}` + if (child.PATH !== layout.helperDirectory && !child.PATH?.startsWith(prefix)) child.PATH = child.PATH ? `${prefix}${child.PATH}` : layout.helperDirectory + for (const name of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_VITE_PLUS', 'npm_config_user_agent', 'npm_execpath']) delete child[name] + // This immutable workload installation belongs to npm; the web server's + // package-manager environment does not describe this CLI installation. + child.CODEX_MANAGED_PACKAGE_ROOT = layout.packageRoot + child.CODEX_MANAGED_BY_NPM = '1' + process.execve(layout.nodeExecutable, [layout.nodeExecutable, layout.npmWrapper, ...args], child) +} + +if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const resource = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'codex-native-sandbox.json'), 'utf8')) + const identity = resource.native + const layout = JSON.parse(fs.readFileSync(identity.layoutFile, 'utf8')) + launchManagedCodex(layout, identity, process.argv.slice(2)) + } catch (error) { + console.error(JSON.stringify({ severity: 'error', event: 'codex_managed_launch_refused', reason: error.message })) + process.exitCode = 1 + } +} diff --git a/docker/runtime/codex-native-sandbox.json b/docker/runtime/codex-native-sandbox.json index c4df5befb..5eb1eb0f4 100644 --- a/docker/runtime/codex-native-sandbox.json +++ b/docker/runtime/codex-native-sandbox.json @@ -11,7 +11,11 @@ "bwrapSha256": "3a81c19ad10a70436529c58e77898e2a447abe80167f813fc20ec34bd9eafdeb", "npmWrapper": "/usr/local/bin/codex", "launchWrapper": "/opt/freshell-codex/launch", - "nodeExecutable": "/usr/local/bin/node" + "nodeExecutable": "/usr/local/bin/node", + "layoutFile": "/opt/freshell-codex/layout.json", + "helperDirectory": "/opt/freshell-codex/bin", + "packageRoot": "/usr/local/lib/node_modules/@openai/codex", + "npmEntrypointSha256": "61b0194f3bb6534439c8d26a3ed57d0805f84b884588b761795323eeb92fcf70" }, "provenance": { "daemonVersion": "29.7.2", diff --git a/docker/runtime/provider-versions.json b/docker/runtime/provider-versions.json index 75c7ee2b3..729408b74 100644 --- a/docker/runtime/provider-versions.json +++ b/docker/runtime/provider-versions.json @@ -24,7 +24,7 @@ }, "codex": { "package": "@openai/codex", - "version": "0.147.0" + "version": "0.164.0" }, "opencode": { "package": "opencode-ai", diff --git a/docs/development/codex-native-runtime.md b/docs/development/codex-native-runtime.md new file mode 100644 index 000000000..61f841163 --- /dev/null +++ b/docs/development/codex-native-runtime.md @@ -0,0 +1,15 @@ +# Codex managed runtime + +Freshcodex's managed Linux x64 workload uses Codex 0.164.0. The provider inventory in `docker/runtime/provider-versions.json` and the Docker build argument must agree. Other provider versions are independent. + +`docker/runtime/codex-native-sandbox.json` declares the native package, helper, npm entrypoint and seccomp identity. Its provenance records the official default from the tested Docker daemon and the small set of namespace/mount rules required by Codex's bundled bubblewrap. The profile retains the default denials, architecture and capability conditions. It is selected only for the managed Codex executable; explicit custom terminal executables and other providers retain their existing policy. + +The additional syscall rules apply inside the workload container. They do not configure the host or Docker daemon. The workload still has a read-only root, bounded mounts and resources, no new privileges and dropped capabilities; the provider runs with UID 65534 and GID 0. Native `never` approval with read-only or workspace-write sandboxing must independently deny forbidden file and network effects. An accepted `untrusted` approval can authorize Codex's native retry without sandboxing and is tested separately from `never` confinement. + +The workload image installs the published npm package. Its build assembler verifies the npm entrypoint, native executable and bundled helper, normalizes permissions only in the owned package tree, and writes `/opt/freshell-codex/layout.json`. The managed launcher at `/opt/freshell-codex/launch` validates that layout and executes the stock npm entrypoint through the fixed image Node runtime. It prefixes the owned bundled-helper directory only in the child environment and supplies npm installation context. It does not change the installed entrypoint or global PATH. Missing helpers or an incompatible layout refuse launch. Rust checks final argument and environment sizes after this child environment expansion, before dispatch. + +The supervisor passes the declared seccomp JSON to Docker and checks the inspected policy before start or reuse. Native, helper, wrapper and policy identities participate in runtime ownership. Existing containers built with a different identity require the existing replacement path; they cannot be adopted on labels alone. + +Codex 0.164 rejects public `approval_policy = "untrusted"` before typed request overrides. Freshcodex preserves its declaring source through the owned prepared-config path, omits that rejected declaration only from prepared copies, then sends the intended native typed policy. Unsupported source selectors and native requirements that prohibit the policy produce an explicit configuration error before work. Original user files remain untouched. + +To qualify a changed version, helper, wrapper or policy, use the Task 1 native harness. Connected preparation is `node scripts/testing/prepare-codex-contract.mjs --sandbox`; `--verify-sandbox` verifies completed prerequisites before networkless entry. The managed proof is `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir ABSOLUTE_OWNED_PATH`. It builds an existing run-owned workload image, adds system requirements only in a disposable test derivative, and runs the native matrix through the installed npm wrapper as the provider identity. Artifact fixtures and version output alone do not qualify the managed runtime. Preserve raw scenario receipts, actual Docker inspection, image digest and denied-effect evidence. No production restart or deployment is part of qualification. diff --git a/scripts/testing/prepare-codex-contract.mjs b/scripts/testing/prepare-codex-contract.mjs index bc561413c..a3bf65edf 100644 --- a/scripts/testing/prepare-codex-contract.mjs +++ b/scripts/testing/prepare-codex-contract.mjs @@ -83,9 +83,9 @@ async function normalizeOwnedTree(root) { } } -export async function verifyArtifact(root, metadata = artifactMetadata) { +export async function verifyArtifact(root, metadata = artifactMetadata, referenceReceipt) { const absolute = await fsp.realpath(root) - const receipt = JSON.parse(await fsp.readFile(path.join(absolute, 'artifact-receipt.json'), 'utf8')) + const receipt = JSON.parse(await fsp.readFile(referenceReceipt ?? path.join(absolute, 'artifact-receipt.json'), 'utf8')) if (receipt.integrity !== metadata.integrity || receipt.packageVersion !== metadata.packageVersion || receipt.complete !== true) fail('artifact receipt does not match pinned metadata') const files = await inventory(absolute) if (JSON.stringify(files) !== JSON.stringify(receipt.files)) fail('artifact bytes/layout/modes changed after verified preparation') @@ -247,6 +247,50 @@ export async function verifySandbox() { } } +// Test-only system requirements are isolated to this owned image derivative. +export async function prepareContractImage(baseImage, owner, contextRoot = path.join(stageRoot, 'image-context')) { + if (!/^sha256:[a-f0-9]{64}$/.test(baseImage) || !/^[a-z0-9-]+$/.test(owner)) fail('invalid owned contract image identity') + const context = path.join(contextRoot, owner) + const baseTag = `freshell-codex-contract-base:${owner}` + const tag = `freshell-codex-contract:${owner}` + // Complete filesystem setup before acquiring any image tag. + await fsp.mkdir(context, { recursive: true, mode: 0o700 }) + await fsp.writeFile(path.join(context, 'requirements.toml'), CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 }) + await fsp.writeFile(path.join(context, 'Dockerfile'), `FROM ${baseTag}\nCOPY requirements.toml /etc/codex/requirements.toml\nRUN chmod 755 /etc/codex && chmod 644 /etc/codex/requirements.toml\n`) + const inspect = name => { + const result = spawnSync('docker', ['image', 'inspect', name], { encoding: 'utf8', timeout: 30_000 }) + if (result.status === 0) return JSON.parse(result.stdout)[0] + if (/No such image/.test(result.stderr)) return undefined + throw new Error(`cannot inspect owned image ${name}: ${result.error?.message ?? result.stderr}`) + } + try { + run('docker', ['image', 'tag', baseImage, baseTag]) + run('docker', ['build', '--pull=false', '--label', `org.freshell.codex-contract-owner=${owner}`, '-t', tag, context], { stdio: ['ignore', 'inherit', 'inherit'] }) + const image = inspect(tag) + if (!/^sha256:[a-f0-9]{64}$/.test(image?.Id ?? '') || image.Config?.Labels?.['org.freshell.codex-contract-owner'] !== owner) fail('built contract image ownership changed') + return { baseImage, subdir: owner, image: image.Id, contractImage: { tag, requirementsSha256: digest(CODEX_NETWORK_REQUIREMENTS) } } + } catch (error) { + // A failed CLI acknowledgement can still have published the image. + try { + const image = inspect(tag) + if (image) { + if (image.Config?.Labels?.['org.freshell.codex-contract-owner'] !== owner) fail('failed-build image is no longer owned') + run('docker', ['image', 'rm', tag]) + } + } catch (cleanup) { + console.error(JSON.stringify({ severity: 'error', event: 'codex_contract_image_cleanup_failed', owner, message: cleanup.message })) + throw new AggregateError([error, cleanup], 'Codex contract image preparation and cleanup failed') + } + throw error + } finally { + const base = inspect(baseTag) + if (base) { + if (base.Id !== baseImage) fail('owned temporary base tag changed during preparation') + run('docker', ['image', 'rm', baseTag]) + } + } +} + async function main(args) { let result if (args[0] === '--output' && args.length === 2) result = await prepareArtifact(args[1]) @@ -267,22 +311,9 @@ async function main(args) { await fsp.writeFile(path.join(stageRoot, 'policy-client-prepared.json'), `${JSON.stringify(client)}\n`, { mode: 0o644 }) result = JSON.parse(run('bash', ['scripts/sandbox-test.sh', 'node scripts/testing/prepare-codex-contract.mjs --stage-prepared'], { stdio: ['ignore', 'pipe', 'inherit'] })) result.baseImage = run('docker', ['image', 'inspect', imageTag, '--format', '{{.Id}}']).trim() - const context = path.join(stageRoot, 'image-context', result.subdir) - await fsp.mkdir(context, { recursive: true, mode: 0o700 }) - await fsp.writeFile(path.join(context, 'requirements.toml'), CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 }) - await fsp.writeFile(path.join(stageRoot, 'requirements.toml'), CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 }) - const baseTag = `freshell-codex-contract-base:${result.subdir}` - run('docker', ['image', 'tag', result.baseImage, baseTag]) - await fsp.writeFile(path.join(context, 'Dockerfile'), `FROM ${baseTag}\nCOPY requirements.toml /etc/codex/requirements.toml\nRUN chmod 755 /etc/codex && chmod 644 /etc/codex/requirements.toml\n`) - const tag = `freshell-codex-contract:${result.subdir}` - try { run('docker', ['build', '--pull=false', '--label', `org.freshell.codex-contract-owner=${result.subdir}`, '-t', tag, context], { stdio: ['ignore', 'inherit', 'inherit'] }) } - finally { - const observedBase = run('docker', ['image', 'inspect', baseTag, '--format', '{{.Id}}']).trim() - if (observedBase !== result.baseImage) fail('owned temporary base tag changed during preparation') - run('docker', ['image', 'rm', baseTag]) - } - result.image = run('docker', ['image', 'inspect', tag, '--format', '{{.Id}}']).trim() - result.contractImage = { tag, requirementsSha256: digest(CODEX_NETWORK_REQUIREMENTS) } + const derived = await prepareContractImage(result.baseImage, result.subdir) + result.image = derived.image + result.contractImage = derived.contractImage const manifestTemporary = `${stageManifest}.${randomUUID()}.preparing` await fsp.writeFile(manifestTemporary, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o644, flag: 'wx' }) await fsp.rename(manifestTemporary, stageManifest) diff --git a/scripts/testing/runtime-sandbox.ts b/scripts/testing/runtime-sandbox.ts index d5d7eebe3..cd8b6098e 100644 --- a/scripts/testing/runtime-sandbox.ts +++ b/scripts/testing/runtime-sandbox.ts @@ -254,6 +254,13 @@ export class RuntimeHarness { this.browserDir = path.join(this.evidenceDir, 'browser') } + private codexRuntimeVersion(): string { + const inventory = JSON.parse(fs.readFileSync(path.join(this.repoRoot, 'docker/runtime/provider-versions.json'), 'utf8')) + const version: unknown = inventory?.providers?.codex?.version + if (typeof version !== 'string' || version.length === 0) throw new Error('Managed Codex runtime inventory has no version') + return version + } + async prepare(options: { operatorHome?: string } = {}): Promise { fs.rmSync(this.testRoot, { recursive: true, force: true }) fs.mkdirSync(this.testRoot, { recursive: true, mode: 0o700 }) @@ -277,7 +284,7 @@ export class RuntimeHarness { providers: { claude: { status: 'pending-receipt', requiredModel: 'haiku', reasoning: 'lowest' }, opencode: { status: 'pending-receipt', version: '1.18.21', modelProvider: 'openai', model: 'openai/gpt-5.6-luna', freeTier: false }, - codex: { status: 'pending-receipt', version: '0.147.0', requiredModel: 'gpt-5.6-luna', reasoning: 'lowest' }, + codex: { status: 'pending-receipt', version: this.codexRuntimeVersion(), requiredModel: 'gpt-5.6-luna', reasoning: 'lowest' }, amplifier: { status: 'pending-receipt', version: '0.1.1', commit: '1873aa980535c99a743b17172e4231833f6c8741' }, }, } diff --git a/scripts/testing/runtime-test-broker.ts b/scripts/testing/runtime-test-broker.ts index 35f227b91..7e9f30352 100644 --- a/scripts/testing/runtime-test-broker.ts +++ b/scripts/testing/runtime-test-broker.ts @@ -3,6 +3,8 @@ import fs from 'node:fs' import http, { type IncomingMessage, type ServerResponse } from 'node:http' import os from 'node:os' import path from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import codexNativeResource from '../../docker/runtime/codex-native-sandbox.json' with { type: 'json' } const DOCKER_API_PREFIX = '/v1.47' const MAX_REQUEST_BYTES = 2 * 1024 * 1024 @@ -972,7 +974,21 @@ export class RestrictedDockerBroker { const capAdd = Array.isArray(host.CapAdd) ? [...host.CapAdd].sort() : [] const expectedCapAdd = terminalWorkload ? ['CHOWN', 'SETGID', 'SETUID'] : [] if (JSON.stringify(capAdd) !== JSON.stringify(expectedCapAdd)) return { ok: false, reason: `unexpected host capability additions: ${capAdd.join(',')}` } - if (!Array.isArray(host.SecurityOpt) || !host.SecurityOpt.includes('no-new-privileges:true')) return { ok: false, reason: 'no-new-privileges is required' } + const commands = Array.isArray(parsed.Env) ? parsed.Env.filter((value: unknown): value is string => typeof value === 'string' && value.startsWith('CODEX_CMD=')) : [] + const freshProviders = Array.isArray(parsed.Env) ? parsed.Env.filter((value: unknown): value is string => typeof value === 'string' && value.startsWith('FRESHELL_HOSTED_FRESH_AGENT=')) : [] + const managedCodex = commands.length === 1 && freshProviders.every((value: string) => value === 'FRESHELL_HOSTED_FRESH_AGENT=codex') + && commands[0].match(/^CODEX_CMD=\/usr\/bin\/setpriv --reuid [0-9]+ --regid [0-9]+ --clear-groups --no-new-privs -- (\S+)$/)?.[1] === codexNativeResource.native.launchWrapper + if (managedCodex) { + const options: unknown[] = host.SecurityOpt + if (!Array.isArray(options) || options.length !== 2 || options[0] !== 'no-new-privileges:true' + || typeof options[1] !== 'string' || !options[1].startsWith('seccomp=') + || !isDeepStrictEqual(host.CapDrop, ['ALL'])) return { ok: false, reason: 'managed Codex requires its exact native sandbox policy' } + try { + if (!isDeepStrictEqual(JSON.parse(options[1].slice('seccomp='.length)), codexNativeResource.profile)) return { ok: false, reason: 'managed Codex native sandbox policy differs' } + } catch { return { ok: false, reason: 'managed Codex native sandbox policy is invalid' } } + } else if (!isDeepStrictEqual(host.SecurityOpt, ['no-new-privileges:true'])) { + return { ok: false, reason: 'ordinary runtime requires its unchanged default sandbox policy' } + } if (host.RestartPolicy?.Name !== 'no') return { ok: false, reason: 'staging container must start with restart=no' } if (!(Number(host.NanoCpus) > 0) || !(Number(host.Memory) > 0) || !(Number(host.PidsLimit) > 0)) { return { ok: false, reason: 'CPU/memory/pid limits must be explicit and nonzero' } diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs index bd79c2770..5585b5735 100644 --- a/test/integration/providers/codex-cli-contract.mjs +++ b/test/integration/providers/codex-cli-contract.mjs @@ -62,9 +62,10 @@ async function roots(options) { const metadata = options.probeMetadata ?? artifactMetadata let staged if (fs.existsSync(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'))) staged = JSON.parse(await fsp.readFile(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'), 'utf8')) - const artifactRoot = options.artifactRoot ?? process.env.FRESHELL_CODEX_CONTRACT_ROOT ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.artifactRoot : undefined) + const managed = process.env.FRESHELL_CODEX_MANAGED_CONTRACT === '1' ? JSON.parse(await fsp.readFile(nativeSandboxPolicy().native.layoutFile, 'utf8')) : undefined + const artifactRoot = managed?.platformPackageRoot ?? options.artifactRoot ?? process.env.FRESHELL_CODEX_CONTRACT_ROOT ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.artifactRoot : undefined) if (!artifactRoot) throw new Error('CODEX_CONTRACT_PREREQUISITE: explicit prepared artifact root is missing; no PATH fallback') - const artifact = await verifyArtifact(artifactRoot, metadata) + const artifact = await verifyArtifact(artifactRoot, metadata, managed ? '/native-reference.json' : undefined) let policyClientProbe if (!options.probeMetadata && !options.seccompProbeReceipt) { const file = options.policyClientProbe ?? process.env.FRESHELL_CODEX_POLICY_CLIENT_PROBE ?? staged?.policyClientProbe?.path @@ -79,12 +80,12 @@ async function roots(options) { const systemConfig = ['/etc/codex/config.toml', '/etc/codex/requirements.toml'].map(file => ({ path: file, present: fs.existsSync(file) })) if (systemConfig.some(file => file.present && (file.path !== '/etc/codex/requirements.toml' || fs.readFileSync(file.path, 'utf8') !== CODEX_NETWORK_REQUIREMENTS))) throw new Error('CODEX_CONTRACT_PREREQUISITE: unexpected system Codex configuration in fixture environment') const environment = { PATH: process.env.PATH, HOME: path.join(stateRoot, 'home'), CODEX_HOME: await fsp.realpath(path.join(stateRoot, 'codex')), XDG_CONFIG_HOME: path.join(stateRoot, 'xdg-config'), XDG_CACHE_HOME: path.join(stateRoot, 'xdg-cache'), XDG_DATA_HOME: path.join(stateRoot, 'xdg-data'), TMPDIR: path.join(stateRoot, 'tmp'), LANG: 'C.UTF-8', RUST_LOG: 'warn' } - if (!options.probeMetadata || options.preferBundledBwrap) { + if (!managed && (!options.probeMetadata || options.preferBundledBwrap)) { const bundled = artifact.files.filter(file => file.path.endsWith('/codex-resources/bwrap')) assert.equal(bundled.length, 1, 'fixture PATH selection needs the single verified bundled bubblewrap resource') environment.PATH = `${path.dirname(path.join(artifact.path, bundled[0].path))}:${environment.PATH}` } - return { artifact, metadata, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged, policyClientProbe } + return { artifact, metadata, managed, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged, policyClientProbe } } class NativeClient { @@ -714,10 +715,16 @@ async function driveNetwork(client, capture, flow, context) { export async function captureNativeScenario(name, options = {}) { assert(scenarios.includes(name), `unknown native scenario ${name}`) const context = await roots(options) - const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'childApproval', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode', 'requirementsGuard'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, policyClientProbe: context.policyClientProbe, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'childApproval', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode', 'requirementsGuard'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: context.managed?.launchWrapper ?? path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, policyClientProbe: context.policyClientProbe, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } Object.defineProperty(capture, 'secretValues', { value: new Set(), enumerable: false }) capture.environment.cloud = Object.fromEntries(['CLOUD_RUN_JOB', 'CLOUD_RUN_EXECUTION', 'CLOUD_RUN_TASK_INDEX', 'CLOUD_RUN_TASK_ATTEMPT'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])) if (options.seccompProbeReceipt) capture.environment.seccompProbe = JSON.parse(await fsp.readFile(options.seccompProbeReceipt, 'utf8')) + if (context.managed) { + capture.environment.managedLayout = context.managed + capture.environment.nativeSandboxPolicy = nativeSandboxPolicy() + delete capture.environment.nativeSandboxPolicy.profile + delete capture.environment.nativeSandboxPolicy.json + } if (process.env.FRESHELL_CODEX_CONTRACT_POLICY === 'prepared') { const declared = nativeSandboxPolicy() assert.equal(context.staged?.nativeSandboxPolicy?.id, declared.id) @@ -1183,7 +1190,8 @@ async function managedVersion(options) { const unregister = register() const { RuntimeHarness } = await import('../../../scripts/testing/runtime-sandbox.ts') const harness = new RuntimeHarness(repoRoot, `codex-contract-${randomUUID()}`, 3) - const receiptDir = path.resolve(options.receiptDir ?? path.join(repoRoot, '.runtime-build/codex-contract/managed')) + const receiptDir = path.join(path.resolve(options.receiptDir ?? path.join(repoRoot, '.runtime-build/codex-contract/managed')), `run-${randomUUID()}`) + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_receipt_root', path: receiptDir })) await fsp.mkdir(receiptDir, { recursive: true, mode: 0o700 }) await fsp.mkdir(harness.evidenceDir, { recursive: true, mode: 0o700 }) try { @@ -1241,7 +1249,8 @@ async function managedVersion(options) { return } assert.equal(output.stdout.trim(), `codex-cli ${artifactMetadata.version}`, 'managed runtime must execute the required Codex release') - throw new Error('CODEX_CONTRACT_UNIMPLEMENTED: managed native scenarios have not run; a version receipt is insufficient') + const { runManagedContract } = await import('./codex-managed-contract.mjs') + await runManagedContract(harness, receiptDir, options) } finally { const cleanup = await harness.cleanup(); unregister(); assert.equal(cleanup.ok, true, JSON.stringify(cleanup.errors)) } } @@ -1280,8 +1289,8 @@ async function main(args) { else if (args[index] === '--approval-control') { options.approvalControl = args[++index]; assert(['stdin', 'network'].includes(options.approvalControl)) } else throw new Error(`unknown argument ${args[index]}`) } - if (options.probePolicyEffects || options.probeApprovalPolicy || options.probeDefaultPolicy || options.legacyLandlock) assert(options.probeMetadata || options.probeSeccomp || options.seccompProbeReceipt, 'probe options require an explicit candidate manifest or declared seccomp probe') - if (managed) return managedVersion(options) + if (options.probePolicyEffects || options.probeApprovalPolicy || options.probeDefaultPolicy || options.legacyLandlock) assert(managed || process.env.FRESHELL_CODEX_MANAGED_CONTRACT === '1' || options.probeMetadata || options.probeSeccomp || options.seccompProbeReceipt, 'probe options require an explicit candidate manifest or declared seccomp probe') + if (managed) return managedVersion({ ...options, scenario }) for (const [name, selected] of scenario === 'all' ? nativeContractCases() : [[scenario, {}]]) { const capture = await captureNativeScenario(name, { ...options, ...selected }) console.log(JSON.stringify({ severity: 'info', event: 'codex_native_scenario_passed', scenario: name, parameters: capture.parameters, version: capture.binary.version, threadId: capture.threadId })) diff --git a/test/integration/providers/codex-managed-cleanup.mjs b/test/integration/providers/codex-managed-cleanup.mjs new file mode 100644 index 000000000..0e7458435 --- /dev/null +++ b/test/integration/providers/codex-managed-cleanup.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { runManagedContainer, verifyManagedPrerequisites } from './codex-managed-contract.mjs' +import { prepareContractImage } from '../../../scripts/testing/prepare-codex-contract.mjs' + +const owned = fs.mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? (fs.existsSync('/freshell-test-artifacts') ? '/freshell-test-artifacts' : os.tmpdir()), 'codex-image-cleanup-')) +const originalPath = process.env.PATH +const base = `sha256:${'a'.repeat(64)}` +const state = path.join(owned, 'state.json') +const calls = path.join(owned, 'calls.jsonl') +const fake = path.join(owned, 'docker') +fs.writeFileSync(fake, `#!${process.execPath} +import fs from 'node:fs'; +const state=${JSON.stringify(state)}, calls=${JSON.stringify(calls)}; +const args=process.argv.slice(2);fs.appendFileSync(calls,JSON.stringify(args)+'\\n'); +const data=fs.existsSync(state)?JSON.parse(fs.readFileSync(state,'utf8')):{}; +const save=()=>fs.writeFileSync(state,JSON.stringify(data)); +if(args[0]==='image'&&args[1]==='tag'){data[args[3]]={Id:args[2],Config:{Labels:{}}};save()} +else if(args[0]==='build'){const owner=args[args.indexOf('--label')+1].split('=')[1];const tag=args[args.indexOf('-t')+1];data[tag]={Id:'sha256:'+'b'.repeat(64),Config:{Labels:{'org.freshell.codex-contract-owner':owner}}};save();process.stderr.write('injected lost successful build acknowledgement');process.exitCode=71} +else if(args[0]==='image'&&args[1]==='inspect'){const value=data[args[2]];if(!value){process.stderr.write('Error: No such image: '+args[2]);process.exitCode=1}else process.stdout.write(args.includes('--format')?value.Id:JSON.stringify([value]))} +else if(args[0]==='image'&&args[1]==='rm'){assertOwned: {if(!data[args[2]])throw Error('unexpected deletion');delete data[args[2]];save()}} +else if(args[0]==='create'){const name=args[args.indexOf('--name')+1];const owner=args[args.indexOf('--label')+1].split('=')[1];data[name]={Id:'c'.repeat(64),Image:'sha256:'+'a'.repeat(64),Config:{Labels:{'org.freshell.codex-contract':process.env.CODEX_CLEANUP_FOREIGN==='1'?'foreign':owner}}};save();process.stderr.write('injected lost successful create acknowledgement');process.exitCode=72} +else if(args[0]==='inspect'){const value=data[args[1]];if(value)process.stdout.write(JSON.stringify([value]));else{process.stderr.write('Error: No such object: '+args[1]);process.exitCode=1}} +else if(args[0]==='rm'){const entry=Object.entries(data).find(([,v])=>v.Id===args[2]);if(!entry)throw Error('unowned container deletion');delete data[entry[0]];save()} +else if(args[0]==='volume'&&args[1]==='inspect'){process.stderr.write('Error: No such volume');process.exitCode=1} +else throw Error('unexpected fake Docker operation '+JSON.stringify(args)); +`, { mode: 0o755 }) +// The fake CLI is a regular process with an owned executable directory. It +// records actual subprocess calls; it never contacts a Docker daemon. +process.env.PATH = `${owned}:${originalPath}` +const contexts = [] +try { + for (const failure of ['dockerfile-write', 'lost-build-reply']) { + fs.writeFileSync(state, '{}') + fs.writeFileSync(calls, '') + const owner = `cleanup-${randomUUID()}` + const contextRoot = path.join(owned, 'contexts') + const context = path.join(contextRoot, owner) + contexts.push(context) + if (failure === 'dockerfile-write') fs.mkdirSync(path.join(context, 'Dockerfile'), { recursive: true }) + await assert.rejects(prepareContractImage(base, owner, contextRoot)) + assert.deepEqual(JSON.parse(fs.readFileSync(state, 'utf8')), {}, `${failure}: every acquired owned image tag must be removed`) + } + for (const foreign of [false, true]) { + fs.writeFileSync(state, '{}'); fs.writeFileSync(calls, '') + process.env.CODEX_CLEANUP_FOREIGN = foreign ? '1' : '0' + const owner = `container-${randomUUID()}` + await assert.rejects(runManagedContainer({ owner, image: base, receiptDir: owned, + common: ['--label', `org.freshell.codex-contract=${owner}`], label: 'lost-reply', args: [] }), error => { + const primary = foreign ? error.errors?.[0] : error + assert.match(primary?.message ?? '', /injected lost successful create acknowledgement/) + if (foreign) assert(error instanceof AggregateError, 'execution and ownership refusal must both remain diagnosable') + return true + }) + const retained = JSON.parse(fs.readFileSync(state, 'utf8')) + assert.equal(Object.keys(retained).length, foreign ? 1 : 0, 'only the exact owned lost-ack container may be removed') + const deleted = fs.readFileSync(calls, 'utf8').trim().split('\n').map(line => JSON.parse(line)).filter(args => args[0] === 'rm') + assert.equal(deleted.length, foreign ? 0 : 1) + } + delete process.env.CODEX_CLEANUP_FOREIGN + fs.writeFileSync(calls, '') + assert.throws(() => verifyManagedPrerequisites({ complete: true, policyClientProbe: { path: `/freshell-test-artifacts/freshcodex-contract-${randomUUID()}/policy-client-probe`, sha256: 'a'.repeat(64) } })) + const admissionCalls = fs.readFileSync(calls, 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.deepEqual(admissionCalls.map(args => args.slice(0, 2)), [['volume', 'inspect']], 'a missing prepared volume must refuse before create or provisioning') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_image_cleanup_passed', cases: 5 })) +} finally { + process.env.PATH = originalPath + for (const context of contexts) fs.rmSync(context, { recursive: true, force: true }) + fs.rmSync(owned, { recursive: true, force: true }) +} diff --git a/test/integration/providers/codex-managed-contract.mjs b/test/integration/providers/codex-managed-contract.mjs new file mode 100644 index 000000000..8d714d3b7 --- /dev/null +++ b/test/integration/providers/codex-managed-contract.mjs @@ -0,0 +1,127 @@ +// The managed proof uses the installed npm entrypoint, not the staged executable. +import assert from 'node:assert/strict' +import fsp from 'node:fs/promises' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { repoRoot, artifactMetadata, verifyArtifact, nativeSandboxPolicy, prepareContractImage } from '../../../scripts/testing/prepare-codex-contract.mjs' + +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +function docker(args, options = {}) { + const result = spawnSync('docker', args, { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, ...options }) + assert.equal(result.status, 0, `${result.error?.message ?? ''}\n${result.stderr}\n${result.stdout}`) + return result.stdout +} + +export async function runManagedContainer({ owner, image, receiptDir, common, label, args, extra = [] }) { + const name = `${owner}-${label}` + let id + let failure + try { + id = docker(['create', '--name', name, ...common, ...extra, image, ...args]).trim() + assert.match(id, /^[a-f0-9]{64}$/) + const observed = JSON.parse(docker(['inspect', id]))[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], owner) + assert.equal(observed.Image, image) + assert.equal(observed.Config.User, '65534:0') + assert.equal(observed.HostConfig.ReadonlyRootfs, true) + assert.deepEqual(observed.HostConfig.CapDrop, ['ALL']) + assert.equal(observed.HostConfig.NetworkMode, 'none') + await fsp.writeFile(path.join(receiptDir, `${label}-container.json`), `${JSON.stringify(observed, null, 2)}\n`) + const result = spawnSync('docker', ['start', '--attach', id], { encoding: 'utf8', timeout: 1_200_000, maxBuffer: 64 * 1024 * 1024 }) + await fsp.writeFile(path.join(receiptDir, `${label}-execution.json`), `${JSON.stringify({ status: result.status, error: result.error?.message, stderr: result.stderr }, null, 2)}\n`) + await fsp.writeFile(path.join(receiptDir, `${label}-stdout.jsonl`), result.stdout, { mode: 0o600 }) + return result + } catch (error) { + failure = error + throw error + } finally { + try { + // The exact UUID name remains cleanup authority after a lost create ACK. + const inspection = spawnSync('docker', ['inspect', name], { encoding: 'utf8', timeout: 30_000 }) + if (inspection.status === 0) { + const observed = JSON.parse(inspection.stdout)[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], owner) + assert.equal(observed.Image, image) + assert.match(observed.Id, /^[a-f0-9]{64}$/) + docker(['rm', '--force', observed.Id]) + } else assert.match(inspection.stderr, /No such (?:object|container)/) + } catch (cleanup) { + console.error(JSON.stringify({ severity: 'error', event: 'codex_managed_container_cleanup_failed', owner, name, message: cleanup.message })) + if (failure) throw new AggregateError([failure, cleanup], 'Managed Codex execution and owned cleanup failed') + throw cleanup + } + } +} + +export function verifyManagedPrerequisites(staged) { + assert.equal(staged.complete, true, 'managed contract requires completed preparation') + assert.match(staged.policyClientProbe?.path ?? '', /^\/freshell-test-artifacts\/freshcodex-contract-[a-f0-9-]+\/policy-client-probe$/) + assert.match(staged.policyClientProbe?.sha256 ?? '', /^[a-f0-9]{64}$/) + // Docker would silently create this shared prerequisite volume if absent. + const volume = JSON.parse(docker(['volume', 'inspect', 'freshell-sandbox-cargo-target']))[0] + assert.equal(volume.Name, 'freshell-sandbox-cargo-target') +} + +export async function runManagedContract(harness, receiptDir, options = {}) { + const owner = `managed-codex-${randomUUID()}` + const artifact = await verifyArtifact(path.join(repoRoot, '.runtime-build/codex-contract/artifact')) + const staged = JSON.parse(await fsp.readFile(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'), 'utf8')) + verifyManagedPrerequisites(staged) + const policy = nativeSandboxPolicy() + const policyFile = path.join(receiptDir, 'codex-policy.json') + await fsp.writeFile(policyFile, policy.json, { mode: 0o644 }) + // Proxy-approval requirements belong only to the approval fixture derivative. + // The production network-enabled mode is qualified against the actual base. + const image = options.probeNetworkEnabled + ? { image: harness.imageRef, baseImage: harness.imageRef, subdir: owner } + : await prepareContractImage(harness.imageRef, owner) + try { + const ambient = path.join(receiptDir, `${owner}-ambient-helper`) + await fsp.mkdir(ambient, { mode: 0o755 }) + await fsp.writeFile(path.join(ambient, 'bwrap'), '#!/bin/sh\ncase "$1" in --version) echo ambient-incompatible-bwrap;; --help) echo "--as-pid-1 --perms";; *) printf used > "$TMPDIR/ambient-bwrap-used"; exit 97;; esac\n', { mode: 0o755 }) + const common = ['--label', `org.freshell.codex-contract=${owner}`, '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--security-opt', `seccomp=${policyFile}`, '--user', '65534:0', '--pids-limit', '512', '--memory', '1g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=128m,mode=1777', '--tmpfs', '/probe-state:rw,nosuid,nodev,size=512m,mode=700,uid=65534,gid=0', '--mount', `type=bind,source=${repoRoot},target=/contract,readonly`, '--mount', `type=bind,source=${artifact.path}/artifact-receipt.json,target=/native-reference.json,readonly`, '--mount', `type=bind,source=${ambient},target=/opt/freshell-codex-ambient,readonly`, '--mount', 'type=volume,source=freshell-sandbox-cargo-target,target=/freshell-test-artifacts,readonly', '--env', 'FRESHELL_CODEX_MANAGED_CONTRACT=1', '--env', `FRESHELL_CODEX_POLICY_CLIENT_PROBE=${staged.policyClientProbe.path}`, '--env', 'PATH=/opt/freshell-codex-ambient:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '--entrypoint', '/usr/local/bin/node'] + const run = (label, args, extra = []) => runManagedContainer({ owner, image: image.image, receiptDir, common, label, args, extra }) + const missing = await run('missing-helper', [policy.native.launchWrapper, '--version'], ['--entrypoint', '/usr/local/bin/node', '--tmpfs', `${policy.native.helperDirectory}:ro,nosuid,nodev,size=1m,mode=755`]) + assert.notEqual(missing.status, 0, 'missing owned helper must refuse before native startup') + assert.match(missing.stderr, /owned bwrap helper is missing/) + const command = ['/contract/test/integration/providers/codex-cli-contract.mjs', '--scenario', options.scenario ?? 'all', '--state-root', '/probe-state', '--receipt-dir', '/probe-state/receipts', '--emit-receipt'] + if (options.probeNetworkEnabled) command.push('--probe-network-enabled') + if (options.probeApprovalPolicy) command.push('--probe-approval-policy', options.probeApprovalPolicy) + if (options.sandboxMode) command.push('--sandbox-mode', options.sandboxMode) + const result = await run('native', command) + const captures = result.stdout.split('\n').filter(Boolean).flatMap(line => { try { const frame = JSON.parse(line); return frame.nativeCapture ? [frame.capture] : [] } catch { return [] } }) + const index = [] + for (const capture of captures) { + const file = `${index.length.toString().padStart(2, '0')}-${capture.scenario}.json` + const json = `${JSON.stringify(capture)}\n` + await fsp.writeFile(path.join(receiptDir, file), json, { mode: 0o600 }) + index.push({ file, sha256: hash(json), scenario: capture.scenario, parameters: capture.parameters, passed: capture.passed, uid: capture.environment.uid, gid: capture.environment.gid, binary: capture.binary, requestCount: capture.requests.length, fixtureRequestCount: capture.fixtureTraffic.length }) + } + await fsp.writeFile(path.join(receiptDir, 'receipt-index.json'), `${JSON.stringify({ image, profile: { id: policy.id, sha256: policy.profileSha256 }, captures: index }, null, 2)}\n`) + assert.equal(result.status, 0, result.stderr) + assert.equal(captures.length, (options.scenario ?? 'all') === 'all' ? 24 : 1, 'all selected actual managed native cases must execute') + for (const capture of captures) { + assert.equal(capture.passed, true) + assert.equal(capture.environment.uid, 65534) + assert.equal(capture.environment.gid, 0) + assert.equal(capture.binary.path, policy.native.launchWrapper) + assert.equal(capture.binary.version, artifactMetadata.version) + for (const helper of capture.observations.executedSandboxHelpers ?? []) assert.equal(helper.executable, capture.environment.managedLayout.bwrap) + } + assert(captures.some(capture => capture.observations.executedSandboxHelpers?.length), 'the installed bundled helper must actually execute') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_native_contract_passed', image: image.image, cases: captures.length, uid: 65534, gid: 0, profile: policy.profileSha256 })) + } finally { + if (image.contractImage) { + const observed = JSON.parse(docker(['image', 'inspect', image.contractImage.tag]))[0] + assert.equal(observed.Id, image.image) + assert.equal(observed.Config.Labels['org.freshell.codex-contract-owner'], owner) + docker(['image', 'rm', image.contractImage.tag]) + } + } + if ((options.scenario ?? 'all') === 'all') { + const networkReceipts = path.join(receiptDir, 'network-enabled') + await fsp.mkdir(networkReceipts, { mode: 0o700 }) + await runManagedContract(harness, networkReceipts, { scenario: 'approval-policy-restricted-exec', probeNetworkEnabled: true, probeApprovalPolicy: 'never', sandboxMode: 'workspace-write' }) + } +} diff --git a/test/integration/providers/codex-managed-launch.mjs b/test/integration/providers/codex-managed-launch.mjs new file mode 100644 index 000000000..84071ca64 --- /dev/null +++ b/test/integration/providers/codex-managed-launch.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' + +const root = fs.mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? os.tmpdir(), 'codex-launch-unit-')) +const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +try { + const packageRoot = path.join(root, 'package') + const platformPackageRoot = path.join(packageRoot, 'node_modules/platform') + const helperDirectory = path.join(root, 'helpers') + for (const directory of [packageRoot, platformPackageRoot, helperDirectory]) fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@openai/codex', version: '0.164.0', type: 'module' })) + fs.writeFileSync(path.join(platformPackageRoot, 'package.json'), JSON.stringify({ version: '0.164.0-linux-x64' })) + const marker = path.join(root, 'dispatched.json') + const npmWrapper = path.join(packageRoot, 'entry.mjs') + fs.writeFileSync(npmWrapper, `import fs from 'node:fs'; import assert from 'node:assert/strict'; + assert(process.env.PATH.startsWith(${JSON.stringify(helperDirectory + ':')}), 'verified helper must precede the ambient PATH'); + assert.equal(process.env.CODEX_MANAGED_PACKAGE_ROOT, ${JSON.stringify(packageRoot)}); + assert.equal(process.env.CODEX_MANAGED_BY_NPM, '1'); + assert.equal(process.env.CODEX_MANAGED_BY_BUN, undefined); + assert.equal(process.env.npm_config_user_agent, undefined); + assert.deepEqual(process.argv.slice(2), ['argument with spaces', '$'+'(literal)', 'line1\\nline2']); + fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv.slice(2),path:process.env.PATH})); + `, { mode: 0o755 }) + for (const name of ['native', 'bwrap']) fs.writeFileSync(path.join(platformPackageRoot, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + fs.symlinkSync(path.join(platformPackageRoot, 'bwrap'), path.join(helperDirectory, 'bwrap')) + const launchWrapper = path.join(root, 'launch.mjs') + fs.copyFileSync('docker/runtime/codex-launch.mjs', launchWrapper) + const identity = { version: '0.164.0', platform: process.platform, arch: process.arch, launchWrapper, nodeExecutable: process.execPath, npmWrapper, packageRoot, helperDirectory, layoutFile: path.join(root, 'layout.json'), executable: 'native', bwrap: 'bwrap', bwrapSha256: hash(path.join(platformPackageRoot, 'bwrap')), npmEntrypointSha256: hash(npmWrapper) } + const layout = { ...identity, platformPackageRoot, nativeExecutable: path.join(platformPackageRoot, 'native'), bwrap: path.join(platformPackageRoot, 'bwrap') } + fs.writeFileSync(identity.layoutFile, JSON.stringify(layout)) + fs.writeFileSync(path.join(root, 'codex-native-sandbox.json'), JSON.stringify({ native: identity })) + const run = () => spawnSync(process.execPath, [launchWrapper, 'argument with spaces', '$(literal)', 'line1\nline2'], { + env: { PATH: '/usr/bin:/bin', HOME: root, CODEX_MANAGED_BY_BUN: '1', npm_config_user_agent: 'bun/fixture' }, encoding: 'utf8', timeout: 10_000, + }) + const positive = run() + assert.equal(positive.status, 0, positive.stderr) + assert(fs.existsSync(marker), 'actual npm entrypoint fixture must execute') + fs.unlinkSync(marker) + fs.unlinkSync(path.join(helperDirectory, 'bwrap')) + const missing = run() + assert.notEqual(missing.status, 0) + assert.match(missing.stderr, /owned bwrap helper is missing/) + assert.equal(fs.existsSync(marker), false, 'missing helper must refuse before npm dispatch') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_launch_unit_passed', cases: 2 })) +} finally { fs.rmSync(root, { recursive: true, force: true }) } diff --git a/test/unit/tooling/testing/codex-managed-launch.test.ts b/test/unit/tooling/testing/codex-managed-launch.test.ts new file mode 100644 index 000000000..10c742833 --- /dev/null +++ b/test/unit/tooling/testing/codex-managed-launch.test.ts @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' + +it('executes the managed Codex wrapper with exact arguments and refuses a missing helper before npm dispatch', () => { + const result = spawnSync(process.execPath, ['test/integration/providers/codex-managed-launch.mjs'], { encoding: 'utf8', timeout: 30_000 }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('codex_managed_launch_unit_passed') +}) + +it('cleans owned image tags after setup and lost build acknowledgement failures', () => { + const result = spawnSync(process.execPath, ['test/integration/providers/codex-managed-cleanup.mjs'], { encoding: 'utf8', timeout: 30_000 }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('codex_managed_image_cleanup_passed') +}) diff --git a/test/unit/tooling/testing/runtime-sandbox.test.ts b/test/unit/tooling/testing/runtime-sandbox.test.ts index b74b10f84..15ca65c8f 100644 --- a/test/unit/tooling/testing/runtime-sandbox.test.ts +++ b/test/unit/tooling/testing/runtime-sandbox.test.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { EventEmitter } from 'node:events' import fs from 'node:fs/promises' import http from 'node:http' @@ -983,3 +983,31 @@ describe('runtime harness candidate scratch cleanup', () => { }, ) }) + +it('records the selected Codex pin from each owned runtime inventory', async () => { + for (const version of ['0.164.fixture-a', '0.164.fixture-b']) { + const repository = await fs.mkdtemp(path.join(os.tmpdir(), 'frs-codex-pin-')) + const home = path.join(repository, 'home') + const xdg = path.join(home, '.config') + const workspace = path.join(repository, 'workspace') + let harness: RuntimeHarness | undefined + try { + for (const directory of [workspace, path.join(repository, 'test/runtime'), path.join(repository, 'docker/runtime')]) await fs.mkdir(directory, { recursive: true }) + prepareProviderParityUserRoots(home, xdg) + await fs.writeFile(path.join(repository, 'test/runtime/gate-manifest.json'), '{}') + await fs.writeFile(path.join(repository, 'docker/runtime/provider-versions.json'), JSON.stringify({ providers: { codex: { version } } })) + const host = path.join(repository, 'owned-host') + await fs.writeFile(host, 'owned host fixture') + harness = new RuntimeHarness(repository, randomUUID(), 3, home, xdg, { workspaceRoots: [workspace], bootstrapFiles: [] }) + useOwnedHarnessArtifacts(harness, host) + await harness.prepare() + const result = JSON.parse(await fs.readFile(path.join(harness.evidenceDir, 'provider-results.json'), 'utf8')) + expect(result.providers.codex.version).toBe(version) + } finally { + await harness?.cleanup() + if (harness) await fs.rm(harness.testRoot, { recursive: true, force: true }) + await fs.rm(repository, { recursive: true, force: true }) + vi.restoreAllMocks() + } + } +}) diff --git a/test/unit/tooling/testing/runtime-test-broker.test.ts b/test/unit/tooling/testing/runtime-test-broker.test.ts index 950e77d53..dcc0169df 100644 --- a/test/unit/tooling/testing/runtime-test-broker.test.ts +++ b/test/unit/tooling/testing/runtime-test-broker.test.ts @@ -374,6 +374,40 @@ describe('managed runtime broker actor storage', () => { }) }) +describe('Codex native workload policy admission', () => { + it.each(['missing', 'mutated', 'unconfined', 'duplicate', 'extra', 'plain-command', 'other-provider'])('refuses %s policy before forwarding an actual Docker request', async mutation => { + const resource = JSON.parse(await fs.readFile(path.resolve('docker/runtime/codex-native-sandbox.json'), 'utf8')) + await withHttpBroker(async ({ request, forwarded }) => { + const body = JSON.parse(createBody([...ordinaryBinds, actorBind]).toString()) + body.Env.push(`CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- ${resource.native.launchWrapper}`) + const option = `seccomp=${JSON.stringify(resource.profile)}` + body.HostConfig.SecurityOpt.push(option) + if (mutation === 'missing') body.HostConfig.SecurityOpt.pop() + if (mutation === 'mutated') body.HostConfig.SecurityOpt[1] = `seccomp=${JSON.stringify({ ...resource.profile, defaultErrnoRet: 0 })}` + if (mutation === 'unconfined') body.HostConfig.SecurityOpt[1] = 'seccomp=unconfined' + if (mutation === 'duplicate') body.HostConfig.SecurityOpt.push(option) + if (mutation === 'extra') body.HostConfig.SecurityOpt.push('apparmor=unconfined') + if (mutation === 'plain-command') body.Env[1] = 'CODEX_CMD=codex' + if (mutation === 'other-provider') body.Env = ['FRESHELL_HOSTED_FRESH_AGENT=claude'] + const response = await request('POST', '/v1.47/containers/create?name=native-policy-fixture', body) + expect(response.status).toBe(403) + expect(forwarded).toEqual([]) + }) + }) + + it('forwards the exact declared policy through the owned HTTP broker', async () => { + const resource = JSON.parse(await fs.readFile(path.resolve('docker/runtime/codex-native-sandbox.json'), 'utf8')) + await withHttpBroker(async ({ request, forwarded }) => { + const body = JSON.parse(createBody([...ordinaryBinds, actorBind]).toString()) + body.Env.push(`CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- ${resource.native.launchWrapper}`) + body.HostConfig.SecurityOpt.push(`seccomp=${JSON.stringify(resource.profile)}`) + const response = await request('POST', '/v1.47/containers/create?name=native-policy-fixture', body) + expect(response.status).toBe(201) + expect(forwarded).toHaveLength(1) + }) + }) +}) + describe('managed MCP mount', () => { it('accepts the exact private grant and approved provider root mounts', () => { const validate = (body: Buffer): { ok: boolean } => ( From c85f182b182b05dbf2dede3eaaf064c228bf4b25 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:39:10 -0700 Subject: [PATCH 08/92] fix(codex): refuse unsupported role approval policies --- .../src/managed_mcp_capability.rs | 118 ++++++++++- .../managed_mcp_capability/ordinary_stage.rs | 6 +- .../src/managed_provider_references/codex.rs | 188 ++++++++++++------ 3 files changed, 240 insertions(+), 72 deletions(-) diff --git a/crates/freshell-server/src/managed_mcp_capability.rs b/crates/freshell-server/src/managed_mcp_capability.rs index 9f6d7d13c..19a7200a1 100644 --- a/crates/freshell-server/src/managed_mcp_capability.rs +++ b/crates/freshell-server/src/managed_mcp_capability.rs @@ -8544,6 +8544,106 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ } } + #[test] + fn prepared_codex_role_refusal_keeps_prior_grant_and_inputs() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex/roles")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let config = + "approval_policy = 'never'\n[agents.reader]\nconfig_file = 'roles/reader.toml'\n"; + let role = home.join(".codex/roles/reader.toml"); + let valid_role = "approval_policy = 'on-request'\n"; + std::fs::write(home.join(".codex/config.toml"), config).unwrap(); + std::fs::write(&role, valid_role).unwrap(); + let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &cwd, &home, None, + ) + .unwrap() + .unwrap(); + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let soul = SoulId::new(); + let endpoint = "http://host.docker.internal:4000"; + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let issue = || { + store.issue( + soul.clone(), + &directory, + endpoint.into(), + Some(("codex", &home, &[])), + OpencodeEphemeralInput { + codex_manifest: Some(&manifest), + launch_context: Some(&context), + cwd: Some(&cwd), + workspace: Some(&cwd), + ..Default::default() + }, + None, + ) + }; + let prior = issue().unwrap(); + let incarnation = IncarnationId::new(); + store + .activate( + &prior.grant_id, + soul.clone(), + incarnation.clone(), + &directory, + endpoint, + ) + .unwrap(); + store + .commit_preparation(&prior.grant_id, &soul, &incarnation, &directory) + .unwrap(); + let prior_path = grant_path(&directory, &prior.grant_id).unwrap(); + let prior_bytes = std::fs::read(&prior_path).unwrap(); + let prior_stage = staged_provider_root_path(&directory, &prior.grant_id).unwrap(); + let descriptor: freshell_runtime_protocol::PreparedProviderInputs = serde_json::from_slice( + &std::fs::read(prior_stage.join(".freshell/prepared-inputs.json")).unwrap(), + ) + .unwrap(); + let role_bind = descriptor + .config_binds + .iter() + .find(|bind| bind.logical_destination == role.to_str().unwrap()) + .unwrap(); + let prepared_role = prior_stage.join(&role_bind.source_relative_path); + assert_eq!(std::fs::read_to_string(&prepared_role).unwrap(), valid_role); + + let rejected_role = "approval_policy = 'untrusted'\n"; + std::fs::write(&role, rejected_role).unwrap(); + let error = issue().expect_err("unrepresentable role policy must not publish a generation"); + assert!( + error.contains("approval_policy") && error.contains(role.to_str().unwrap()), + "{error}" + ); + assert_eq!(std::fs::read(&prior_path).unwrap(), prior_bytes); + assert_eq!(std::fs::read_to_string(prepared_role).unwrap(), valid_role); + assert_eq!(std::fs::read_to_string(&role).unwrap(), rejected_role); + assert_eq!( + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + config + ); + assert_eq!(store.grants.lock().unwrap().len(), 1); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); + assert_eq!( + std::fs::read_dir(prior_stage.parent().unwrap()) + .unwrap() + .count(), + 1 + ); + } + #[test] fn prepared_codex_nonprojected_policy_stays_under_native_authority() { let owned = tempfile::tempdir().unwrap(); @@ -8919,11 +9019,8 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ }; std::fs::write(home.join(".codex/config.toml"), &raw).unwrap(); std::fs::write(home.join(".codex/instructions.md"), "home-input").unwrap(); - std::fs::write( - home.join(".codex/agents/nested/reader.toml"), - "model_instructions_file = 'instructions.md'\n", - ) - .unwrap(); + let role_config = "approval_policy = 'on-request'\nmodel = 'role-model'\nmodel_instructions_file = 'instructions.md'\n"; + std::fs::write(home.join(".codex/agents/nested/reader.toml"), role_config).unwrap(); std::fs::write( home.join(".codex/agents/nested/instructions.md"), "role-input", @@ -8999,6 +9096,17 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ "Codex selected layers and typed resources must be accepted as one generation", ); let stage = staged_provider_root_path(&directory, &capability.grant_id).unwrap(); + let role_path = home.join(".codex/agents/nested/reader.toml"); + let role_bind = inputs + .config_binds + .iter() + .find(|bind| bind.logical_destination == role_path.to_str().unwrap()) + .expect("valid child policy must be published with its own configuration"); + assert_eq!( + std::fs::read_to_string(stage.join(&role_bind.source_relative_path)).unwrap(), + role_config + ); + assert_eq!(std::fs::read_to_string(&role_path).unwrap(), role_config); if untrusted_policy { let bind = inputs .config_binds diff --git a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs index c254e597c..993846cd9 100644 --- a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs +++ b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs @@ -1,7 +1,7 @@ //! Native-selected ordinary inputs use the shared resource copier and //! existing exact mounts. No configuration payload enters callback metadata. use super::{private_directory, write_private_bytes}; -use crate::managed_provider_references::codex::{discover, InputKind}; +use crate::managed_provider_references::codex::discover; use freshell_platform::{codex_inputs::CodexInputManifest, provider_environment::CopySelection}; use freshell_runtime_protocol::*; use std::{ @@ -41,12 +41,12 @@ pub(super) fn prepare( let configurations = discovery .inputs .iter() - .filter(|input| input.kind == InputKind::Configuration) + .filter(|input| input.kind.is_configuration()) .map(|input| (input.logical_path.clone(), input.canonical_source.clone())) .collect(); let mut ordinary = discovery.resource_roots.clone(); for input in &discovery.inputs { - if input.kind != InputKind::Configuration + if !input.kind.is_configuration() && !ordinary .iter() .any(|root| input.logical_path.starts_with(root)) diff --git a/crates/freshell-server/src/managed_provider_references/codex.rs b/crates/freshell-server/src/managed_provider_references/codex.rs index f13052f7c..5324ac674 100644 --- a/crates/freshell-server/src/managed_provider_references/codex.rs +++ b/crates/freshell-server/src/managed_provider_references/codex.rs @@ -6,11 +6,16 @@ use std::path::{Path, PathBuf}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum InputKind { - Configuration, + Configuration(LayerKind), Instructions, CompactPrompt, ModelCatalog, } +impl InputKind { + pub fn is_configuration(self) -> bool { + matches!(self, Self::Configuration(_)) + } +} pub struct SelectedInput { pub logical_path: PathBuf, pub canonical_source: PathBuf, @@ -32,11 +37,9 @@ pub struct SourceLayer { pub fn select_inputs( layers: &[SourceLayer], home: &Path, - profile: Option<&str>, ) -> Result, &'static str> { let mut selection = Selection { home, - profile, inputs: Vec::new(), active: std::collections::BTreeSet::new(), metadata_bytes: 0, @@ -49,7 +52,6 @@ pub fn select_inputs( struct Selection<'a> { home: &'a Path, - profile: Option<&'a str>, inputs: Vec, active: std::collections::BTreeSet<(PathBuf, PathBuf)>, metadata_bytes: usize, @@ -95,7 +97,7 @@ impl Selection<'_> { return Err("Codex role configuration depth exceeded"); } let logical = normalize(path); - let canonical = self.add(logical.clone(), InputKind::Configuration)?; + let canonical = self.add(logical.clone(), InputKind::Configuration(kind))?; if !enabled { return Ok(()); } @@ -126,14 +128,6 @@ impl Selection<'_> { .parent() .ok_or("Codex declaring configuration parent unavailable")?; self.file_fields(&value, parent)?; - if let Some(profile) = self.profile.filter(|_| kind == LayerKind::User) { - if let Some(value) = value - .get("profiles") - .and_then(|profiles| profiles.get(profile)) - { - self.file_fields(value, parent)?; - } - } let mut role_paths = Vec::new(); let mut role_path_bytes = 0usize; if let Some(roles) = value.get("agents").and_then(toml::Value::as_table) { @@ -627,12 +621,7 @@ pub fn discover( if preceding.get("profile").is_some() { return Err("Codex legacy profile config is no longer supported; use --profile NAME with NAME.config.toml instead"); } - let legacy = if manifest.named_profile.is_none() { - preceding.get("profile").and_then(toml::Value::as_str) - } else { - None - }; - let inputs = select_inputs(&layers, &manifest.original_home, legacy)?; + let inputs = select_inputs(&layers, &manifest.original_home)?; resources.sort(); resources.dedup(); Ok(Discovery { @@ -658,6 +647,20 @@ pub fn approval_projection(discovery: &Discovery) -> Result Result "[agents.reader]\nconfig_file = 'roles/reader.toml'\n", + "discovered" => "", + "nested" => { + std::fs::write( + codex_home.join("roles/parent.toml"), + "[agents.reader]\nconfig_file = 'reader.toml'\n", + ) + .unwrap(); + "[agents.parent]\nconfig_file = 'roles/parent.toml'\n" + } + _ => unreachable!(), + }; + let config = format!("approval_policy = '{parent_policy}'\n{declaration}"); + let role_config = "approval_policy = 'untrusted'\nmodel = 'role-model'\n"; + std::fs::write(codex_home.join("config.toml"), &config).unwrap(); + std::fs::write(&role, role_config).unwrap(); + // There is no runtime to accept reservations or native work: + // this known incompatibility must be classified from sources. + let client = freshell_runtime_client::RuntimeClient::new( + owned.path().join("absent-supervisor.sock"), + "0123456789abcdef", + ); + let soul = freshell_runtime_protocol::SoulId::new(); + let result = prepare_managed(CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + client: &client, + soul: &soul, + creation_seed: "role-policy-refusal", + home: &home, + cwd: &cwd, + workspace: &cwd, + original: &|_| None, + args: &[], + references: &[], + }) + .await; + let error = + result.expect_err("role-specific untrusted cannot inherit parent policy"); + assert_eq!( + error.code, + freshell_runtime_protocol::RuntimeErrorCode::InvalidRequest, + "{parent_policy}/{selection}: {}", + error.message + ); + assert!( + error.message.contains("approval_policy") + && error.message.contains(role.to_str().unwrap()), + "{}", + error.message + ); + assert_eq!(std::fs::read_to_string(&role).unwrap(), role_config); + assert_eq!( + std::fs::read_to_string(codex_home.join("config.toml")).unwrap(), + config + ); + } + } + } + #[test] fn prepared_codex_legacy_profile_is_refused_before_approval_projection() { let owned = tempfile::tempdir().unwrap(); @@ -1129,9 +1209,7 @@ mod tests { file.set_times(std::fs::FileTimes::new().set_accessed(unread)) .unwrap(); let rejected = match kind { - "selection" => { - select_inputs(&[layer(path.clone(), true)], owned.path(), None).is_err() - } + "selection" => select_inputs(&[layer(path.clone(), true)], owned.path()).is_err(), "configuration" => read_config(&path).is_err(), "plugin" => is_agent_plugin(&root).is_err(), _ => unreachable!(), @@ -1168,7 +1246,7 @@ mod tests { } match kind { "selection" => assert_eq!( - select_inputs(&[layer(path, true)], owned.path(), None) + select_inputs(&[layer(path, true)], owned.path()) .unwrap() .len(), 1 @@ -1215,8 +1293,7 @@ mod tests { }; let raw = document(8191, false); std::fs::write(&configuration, &raw).unwrap(); - let selected = - select_inputs(&[layer(configuration.clone(), true)], owned.path(), None).unwrap(); + let selected = select_inputs(&[layer(configuration.clone(), true)], owned.path()).unwrap(); assert_eq!(selected.len(), 8192); assert_eq!( selected @@ -1226,16 +1303,14 @@ mod tests { .sum::(), 8 * 1024 * 1024 ); - assert!(selected - .iter() - .all(|input| input.kind == InputKind::Configuration)); + assert!(selected.iter().all(|input| input.kind.is_configuration())); assert!(selected[1..] .iter() .all(|input| input.logical_path == role && input.canonical_source == role)); let metadata_overflow = root.join("m".repeat(length)); std::fs::write(&metadata_overflow, document(8191, true)).unwrap(); assert_eq!( - select_inputs(&[layer(metadata_overflow, true)], owned.path(), None).err(), + select_inputs(&[layer(metadata_overflow, true)], owned.path()).err(), Some("Codex selected input metadata budget exceeded") ); let event_overflow = root.join("e".repeat(length)); @@ -1246,7 +1321,7 @@ mod tests { .set_times(std::fs::FileTimes::new().set_accessed(unread)) .unwrap(); assert_eq!( - select_inputs(&[layer(event_overflow, true)], owned.path(), None).err(), + select_inputs(&[layer(event_overflow, true)], owned.path()).err(), Some("Codex selected role event budget exceeded") ); assert_eq!( @@ -2023,11 +2098,9 @@ mod tests { "[[skills.config]]\npath = 'absent-skill'\nenabled = false\n", ) .unwrap(); - let inputs = select_inputs(&[layer(config,true)],owned.path(),None).expect("native disabled path selectors do not require an existing file or grant a new skill discovery root"); + let inputs = select_inputs(&[layer(config,true)],owned.path()).expect("native disabled path selectors do not require an existing file or grant a new skill discovery root"); assert_eq!(inputs.len(), 1); - assert!(inputs - .iter() - .all(|input| input.kind == InputKind::Configuration)); + assert!(inputs.iter().all(|input| input.kind.is_configuration())); } #[test] @@ -2042,12 +2115,8 @@ mod tests { std::fs::write(root.join("agents/z.md"), b"Z_INSTRUCTIONS").unwrap(); std::fs::write(nested.join("a.md"), b"A_NESTED_INSTRUCTIONS").unwrap(); std::fs::write(nested.join("not-a-role.json"), b"not a TOML role").unwrap(); - let selected = select_inputs( - &[layer(root.join("config.toml"), true)], - fixture.path(), - None, - ) - .unwrap(); + let selected = + select_inputs(&[layer(root.join("config.toml"), true)], fixture.path()).unwrap(); let instructions = selected .iter() .filter(|input| input.kind == InputKind::Instructions) @@ -2056,13 +2125,9 @@ mod tests { assert_eq!(instructions,vec![digest(&nested.join("a.md")),digest(&root.join("agents/z.md"))], "native recursive role discovery must retain each role's declaring parent in sorted file order"); assert_eq!( - select_inputs( - &[layer(root.join("config.toml"), false)], - fixture.path(), - None - ) - .unwrap() - .len(), + select_inputs(&[layer(root.join("config.toml"), false)], fixture.path(),) + .unwrap() + .len(), 1, "disabled project configuration must not activate its discovered role inputs" ); @@ -2086,7 +2151,7 @@ mod tests { "[agents.worker]\nconfig_file='./agents/declared.toml'\n", ) .unwrap(); - let selected = select_inputs(&[layer(config, true)], fixture.path(), None).unwrap(); + let selected = select_inputs(&[layer(config, true)], fixture.path()).unwrap(); assert_eq!( selected .iter() @@ -2108,7 +2173,7 @@ mod tests { } #[test] - fn managed_provider_codex_typed_inputs_keep_each_logical_role_and_profile_origin() { + fn managed_provider_codex_typed_inputs_keep_logical_origins_without_legacy_profiles() { let fixture = tempfile::tempdir().unwrap(); let home = fixture.path().join("home"); let logical = fixture.path().join("project/.codex"); @@ -2121,12 +2186,13 @@ mod tests { std::fs::write(logical.join("instructions.md"), "logical-layer").unwrap(); std::fs::write(actual.join("instructions.md"), "wrong-storage-origin").unwrap(); std::fs::write(logical.join("roles/instructions.md"), "logical-role").unwrap(); - std::fs::write(logical.join("profile.md"), "selected-profile").unwrap(); + std::fs::write(logical.join("compact.md"), "root-compact-prompt").unwrap(); std::fs::write(logical.join("skills/SKILL.md"), "selected-skill").unwrap(); std::fs::write( actual.join("layer.toml"), r#" model_instructions_file = "./instructions.md" +experimental_compact_prompt_file = "./compact.md" notify = ["./missing-command", "./missing-argument"] [mcp_servers.fixture] command = "./missing-command" @@ -2135,7 +2201,7 @@ cwd = "./missing-cwd" [agents.worker] config_file = "./roles/worker.toml" [profiles.chosen] -experimental_compact_prompt_file = "./profile.md" +experimental_compact_prompt_file = "./missing-profile.md" [profiles.unselected] model_instructions_file = "./missing-unselected.md" [[skills.config]] @@ -2152,12 +2218,7 @@ enabled = false std::os::unix::fs::symlink(actual.join("layer.toml"), logical.join("config.toml")).unwrap(); std::os::unix::fs::symlink(actual.join("role.toml"), logical.join("roles/worker.toml")) .unwrap(); - let selected = select_inputs( - &[layer(logical.join("config.toml"), true)], - &home, - Some("chosen"), - ) - .unwrap(); + let selected = select_inputs(&[layer(logical.join("config.toml"), true)], &home).unwrap(); let instructions = selected .iter() .filter(|input| input.kind == InputKind::Instructions) @@ -2173,7 +2234,7 @@ enabled = false assert!(selected .iter() .any(|input| input.kind == InputKind::CompactPrompt - && digest(&input.canonical_source) == digest(&logical.join("profile.md")))); + && digest(&input.canonical_source) == digest(&logical.join("compact.md")))); assert!( !selected .iter() @@ -2193,19 +2254,19 @@ enabled = false let path = fixture.path().join("config.toml"); std::fs::write(&path, "[agents.loop]\nconfig_file='./config.toml'\n").unwrap(); assert_eq!( - select_inputs(&[layer(path.clone(), true)], fixture.path(), None).err(), + select_inputs(&[layer(path.clone(), true)], fixture.path()).err(), Some("Codex role configuration cycle detected") ); std::fs::write(&path, "model_instructions_file='./missing'\n").unwrap(); - let disabled = select_inputs(&[layer(path.clone(), false)], fixture.path(), None).unwrap(); + let disabled = select_inputs(&[layer(path.clone(), false)], fixture.path()).unwrap(); assert_eq!(disabled.len(), 1); - assert!(disabled[0].kind == InputKind::Configuration); + assert!(disabled[0].kind.is_configuration()); std::fs::write( &path, "model_instructions_file='PRIVATE_INVALID_TOML_SENTINEL\n", ) .unwrap(); - let error = select_inputs(&[layer(path, true)], fixture.path(), None) + let error = select_inputs(&[layer(path, true)], fixture.path()) .err() .unwrap(); assert_eq!(error, "Codex selected configuration TOML is invalid"); @@ -2234,7 +2295,6 @@ path='./missing-user-only-selector' kind: LayerKind::Project, }], fixture.path(), - Some("chosen"), ) .unwrap(); assert_eq!(selected.len(), 2); From 9083ea9e5a1e91e0c38aa57dbe6bc1725841f945 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:58:16 -0700 Subject: [PATCH 09/92] feat(codex): project native live transcript and usage --- crates/freshell-freshagent/src/codex.rs | 341 ++- .../src/codex/native_history.rs | 7 + .../src/codex/projection.rs | 2013 +++++++++++++++++ shared/fresh-agent-contract.ts | 30 + .../fresh-agent/FreshAgentDiffPanel.tsx | 23 +- .../fresh-agent/FreshAgentItemCard.tsx | 3 +- .../fresh-agent/FreshAgentSidebar.tsx | 34 + .../fresh-agent/FreshAgentTranscript.tsx | 8 + src/components/fresh-agent/FreshAgentView.tsx | 82 +- .../fresh-agent/fresh-agent-event-classes.ts | 1 + src/lib/fresh-agent-context-usage.ts | 15 + src/lib/fresh-agent-transcript-projection.ts | 339 +++ src/lib/fresh-agent-ws.ts | 14 +- src/store/freshAgentLiveSlice.ts | 83 +- src/store/freshAgentLiveTypes.ts | 15 +- .../freshcodex-live-transcript-rust.spec.ts | 82 + .../codex-app-server/fake-app-server.mjs | 46 + .../codex/native-projection-0.164.json | 1 + .../fresh-agent/FreshAgentDiffPanel.test.tsx | 14 + .../fresh-agent/FreshAgentItemCard.test.tsx | 6 + .../fresh-agent/FreshAgentSidebar.test.tsx | 17 + .../fresh-agent/FreshAgentTranscript.test.tsx | 20 + .../lib/fresh-agent-context-usage.test.ts | 7 + .../fresh-agent-transcript-projection.test.ts | 189 ++ test/unit/client/lib/fresh-agent-ws.test.ts | 17 + .../client/store/freshAgentLiveSlice.test.ts | 34 + test/unit/shared/fresh-agent-contract.test.ts | 16 + 27 files changed, 3409 insertions(+), 48 deletions(-) create mode 100644 crates/freshell-freshagent/src/codex/projection.rs create mode 100644 src/lib/fresh-agent-transcript-projection.ts create mode 100644 test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts create mode 100644 test/fixtures/fresh-agent/codex/native-projection-0.164.json create mode 100644 test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx create mode 100644 test/unit/client/lib/fresh-agent-transcript-projection.test.ts diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index b6096cf0a..3f0dba853 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -85,6 +85,7 @@ use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySi mod controls; mod metadata; pub(crate) mod native_history; +mod projection; /// Unified agent names (Task 2): the ambient `CODEX_HOME` fallback for the /// durability-driven pending bind when the app-server's own initialize @@ -248,6 +249,7 @@ const STATUS_RANG_TURN_IDS_CAP: usize = 8; /// guard, and emission barrier for the same thread to the same task. struct CodexConsumerRuntime { thread_id: String, + projection: Arc>, active_turn: Arc>>, /// The session's USER-interrupt marker: the interrupt control lane arms it, /// the consumer's subscription consumes it at the `interrupted` completion, @@ -475,6 +477,7 @@ struct CodexCreateRecord { /// One live freshcodex session: the app-server client, its owned sidecar, and the /// normalized create-time settings a later `send` re-uses. struct CodexSession { + projection: Arc>, client: Arc, /// Normalized model (`normalizeFreshcodexModel`), reused verbatim on `send`. model: String, @@ -2809,6 +2812,10 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let (created_tx, created_rx) = oneshot::channel(); let consumer = self.spawn_consumer_after( notifs, @@ -2824,6 +2831,7 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, Some(created_rx), ); @@ -2868,6 +2876,7 @@ impl FreshCodexState { history_mode, turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -4289,6 +4298,11 @@ impl FreshCodexState { *compact_turn_id.lock().expect("compact_turn_id mutex") = None; } self.emit_fresh_agent_error(&session_id, "CODEX_COMPACT_FAILED", &err.to_string()); + } else if let Some(session) = self.sessions.lock().await.get(&session_id) { + let mut p = session.projection.lock().expect("codex projection lock"); + let ops = p.invalidate_history(&[]); + broadcast_codex_event(&session_id, p.patch(ops), &self.broadcast_tx); + broadcast_codex_event(&session_id, p.metadata_event(), &self.broadcast_tx); } } @@ -4647,6 +4661,18 @@ impl FreshCodexState { return; } + if let Some(session) = self.sessions.lock().await.get(&thread_id) { + let mut p = session.projection.lock().expect("codex projection lock"); + let removed_native_ids: Vec = raw_turns + .iter() + .skip_while(|turn| turn["id"] != before_turn_id) + .filter_map(|turn| turn["id"].as_str().map(str::to_owned)) + .collect(); + let ops = p.invalidate_history(&removed_native_ids); + broadcast_codex_event(&thread_id, p.patch(ops), &self.broadcast_tx); + broadcast_codex_event(&thread_id, p.metadata_event(), &self.broadcast_tx); + } + // Converge siblings + revoke attention (decisions 6/10). The broadcast // carries NO prompt text (other devices' composers are untouched), and // a rollback NEVER chimes (no freshAgent.turn.complete). @@ -6810,6 +6836,10 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + session_id.to_string(), + ))); let exited = Arc::new(AtomicBool::new(false)); let consumer = self.spawn_consumer( notifs, @@ -6825,6 +6855,7 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); @@ -6875,6 +6906,7 @@ impl FreshCodexState { history_mode: read_rollout_history_mode(session_id), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -7121,6 +7153,10 @@ impl FreshCodexState { let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); let exited = Arc::new(AtomicBool::new(false)); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + new_thread_id.to_string(), + ))); let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { @@ -7135,6 +7171,7 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); @@ -7176,6 +7213,7 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -7561,6 +7599,7 @@ impl FreshCodexState { compact_in_flight, compact_turn_id, event_emission_gate, + projection, } = runtime; let broadcast_tx = self.broadcast_tx.clone(); // The deadman feed needs the state handle (window config + waiter spawn); a @@ -7582,7 +7621,18 @@ impl FreshCodexState { let mut subscription = CodexSubscription::new(thread_id.clone()) .with_user_interrupt_pending(user_interrupt_pending) .with_last_turn_complete_at(last_turn_complete_at.clone()); - while let Some(notification) = notifs.recv().await { + let mut projection_announced = false; + let mut patch_tick = tokio::time::interval(Duration::from_millis(33)); + patch_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + let notification = tokio::select! { + notification = notifs.recv() => match notification { Some(n) => n, None => break }, + _ = patch_tick.tick() => { + let _emission = event_emission_gate.lock().await; + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + continue; + } + }; // Unified agent names (Task 3): an upstream `thread/name/updated` // is a NATIVE title observation — automatic provider metadata, // folded at the store's current location revision. It emits no @@ -7635,6 +7685,7 @@ impl FreshCodexState { // turn. Every notification capable of emitting a wire frame stays // behind the barrier below. if matches!(¬ification, CodexNotification::TurnStarted(_)) { + project_codex_notification(&projection, ¬ification); let events = reduce_notification( &mut subscription, notification, @@ -7658,6 +7709,55 @@ impl FreshCodexState { // also ensures an immediate completion cannot retire a just-started // turn before handle_send has recorded its returned turn id. let _emission = event_emission_gate.lock().await; + // A native item is projected before lifecycle/control emission. + // Appends wait at most 33 ms; every boundary flushes in order. + let append = matches!(¬ification, CodexNotification::Other { method, .. } if method.ends_with("Delta") || method.ends_with("/delta")); + if !append { + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + } + let has_patch = project_codex_notification(&projection, ¬ification); + if has_patch { + if !projection_announced { + let p = projection.lock().expect("codex projection lock"); + broadcast_codex_event( + &thread_id, + json!({"type":"freshAgent.session.init","sessionId":thread_id,"projectionId":p.id,"historyGeneration":p.history_generation}), + &broadcast_tx, + ); + projection_announced = true; + } + if !append { + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + } + } + if let CodexNotification::Other { + method, + params: Some(params), + } = ¬ification + { + if params["threadId"] == thread_id + && matches!( + method.as_str(), + "thread/tokenUsage/updated" | "turn/plan/updated" | "turn/diff/updated" + ) + { + let p = projection.lock().expect("codex projection lock"); + broadcast_codex_event(&thread_id, p.metadata_event(), &broadcast_tx); + } + if params["threadId"] == thread_id && method == "error" { + // Retry notices never replace the lifecycle's failed-turn + // result. Only native diagnostics are shown, never payloads. + let message = params["error"]["message"] + .as_str() + .unwrap_or("Codex reported a provider error"); + let event = if params["willRetry"] == true { + json!({"type":"freshAgent.retry","attempt":params["retryAttempt"].as_u64().unwrap_or(1),"error":message}) + } else { + json!({"type":"freshAgent.notice","level":"error","code":"CODEX_PROVIDER_ERROR","message":message}) + }; + broadcast_codex_event(&thread_id, event, &broadcast_tx); + } + } if state .consume_control_notification(&thread_id, ¬ification) .await @@ -7727,6 +7827,7 @@ impl FreshCodexState { } } } + flush_codex_projection(&projection, &thread_id, &broadcast_tx); state.clear_controls(&thread_id).await; }) } @@ -7880,14 +7981,84 @@ impl FreshCodexState { // this port previously had no fallback at all, so ANY such rejection became an // unconditional 500. Retry once with `includeTurns:false`, matching the reference // exactly (still a valid, if turn-less, snapshot). - let raw = match client.read_thread(thread_id, true).await { - Ok(raw) => raw, - Err(err) if is_codex_include_turns_unavailable(&err) => client - .read_thread(thread_id, false) - .await - .map_err(CodexSnapshotError::AppServer)?, - Err(err) => return Err(CodexSnapshotError::AppServer(err)), - }; + let projection = self + .sessions + .lock() + .await + .get(thread_id) + .filter(|session| Arc::ptr_eq(&session.client, &client)) + .map(|session| session.projection.clone()) + .ok_or(CodexSnapshotError::NotFound)?; + let mut selected = None; + for _ in 0..3 { + let owner = self.ownership_snapshot(PROVIDER, thread_id); + let fence = { + let mut p = projection.lock().expect("codex projection lock"); + p.bind_owner(owner.epoch, owner.generation); + // A read cannot cut through a coalesced append. Publish the + // entire pending batch before capturing its consumed prefix; + // future batches then contain only events after this fence. + if let Some(patch) = p.take_patch() { + broadcast_codex_event(thread_id, patch, &self.broadcast_tx); + } + p.begin_history_read() + }; + let read = match client.read_thread(thread_id, true).await { + Ok(raw) => Ok(raw), + Err(err) if is_codex_include_turns_unavailable(&err) => { + client.read_thread(thread_id, false).await + } + Err(err) => Err(err), + }; + let current = self.sessions.lock().await.get(thread_id).map(|session| { + ( + Arc::ptr_eq(&session.client, &client) + && Arc::ptr_eq(&session.projection, &projection), + session.exited.load(Ordering::SeqCst), + ) + }); + if !current.is_some_and(|(same, _)| same) { + return Err(CodexSnapshotError::HandoffInProgress { + generation: self.ownership_snapshot(PROVIDER, thread_id).generation, + }); + } + let owner_after = self.ownership_snapshot(PROVIDER, thread_id); + let mut p = projection.lock().expect("codex projection lock"); + p.bind_owner(owner_after.epoch, owner_after.generation); + if !p.matches_fence(&fence) { + continue; + } + let projected = match read { + Ok(raw) => p.accept_native_history(&raw, &fence), + Err(err) => { + if !p.has_usable_history() { + return Err(CodexSnapshotError::AppServer(err)); + } + if current.is_some_and(|(_, exited)| exited) { + p.freeze_active("interrupted"); + } + tracing::warn!(provider=PROVIDER, session_id=thread_id, projection_id=%p.id, "freshagent.codex.history_unavailable"); + if let Some(patch) = p.take_patch() { + broadcast_codex_event(thread_id, patch, &self.broadcast_tx); + } + let retained_fence = p.begin_history_read(); + p.unavailable_history(&retained_fence) + } + }; + match projected { + Ok(snapshot) => { + selected = Some(snapshot); + break; + } + Err(_) => { + tracing::debug!(provider=PROVIDER, session_id=thread_id, projection_id=%p.id, "freshagent.codex.stale_history_reread") + } + } + } + let projected = selected.ok_or_else(|| CodexSnapshotError::HandoffInProgress { + generation: self.ownership_snapshot(PROVIDER, thread_id).generation, + })?; + let raw = projected.raw; // U-1 repair (wedged-sidecar deadman): a thread restored MID-TURN has no // adapter-observed `turn/started` (this process never saw one), so // `active_turn` is empty and the quiet deadman would never arm. When the FRESH @@ -7974,6 +8145,10 @@ impl FreshCodexState { stuck, ) .map_err(CodexSnapshotError::Protocol)?; + projection + .lock() + .expect("codex projection lock") + .overlay(&mut snapshot, projected.metadata); self.overlay_controls(thread_id, &mut snapshot).await; if let Some(session) = self.sessions.lock().await.get(thread_id) { let mut settings = Map::new(); @@ -8868,6 +9043,10 @@ impl FreshCodexState { let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); let exited = Arc::new(AtomicBool::new(false)); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { @@ -8882,6 +9061,7 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); @@ -8919,6 +9099,7 @@ impl FreshCodexState { history_mode, turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -9013,6 +9194,10 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))), consumer, kill_tx: Some(kill_tx), watcher, @@ -9272,15 +9457,18 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result Ok(vec![json!({ + "mcpToolCall" => { + let mut mapped = json!({ "id": item_id, "kind": "mcp_tool", "server": item.get("server").and_then(Value::as_str).unwrap_or(""), @@ -9289,7 +9477,10 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result Ok(vec![json!({ "id": item_id, "kind": "dynamic_tool", @@ -9531,13 +9722,9 @@ fn summarize_codex_items(items: &[Value]) -> (String, &'static str) { /// `normalizeCodexThreadSnapshot` (`normalize.ts:748-787`): map a raw `thread/read` result /// into the `FreshAgentSnapshotSchema` shape. /// -/// `tokenUsage` is always the zero-fallback (`normalize.ts:774-779`'s `?? {...zeros}` branch): -/// `CodexThreadReadResultSchema` (`protocol.ts:258-259`) is `{ thread: CodexThreadSchema }`, and -/// neither `CodexThreadSchema` (`protocol.ts:148-167`) nor anywhere else in the codex app-server -/// RPC surface exposes a `tokenUsage` field -- confirmed by inspection of the full protocol -/// schema, not by omission. The reference's `rawSnapshot.tokenUsage` is therefore ALWAYS -/// `undefined` on this path too; this is not a Rust-side gap, it is the reference's own honest -/// zero, faithfully reproduced. +/// `thread/read` carries no measured usage. The base snapshot stays explicitly +/// unobserved; a tracked session overlays the actual native usage notification +/// (total counters plus its separate latest sample) from its projection. fn build_codex_snapshot_json( thread_id: &str, raw: &Value, @@ -9606,7 +9793,11 @@ fn build_codex_snapshot_json( // NOT per-raw-turn, it is the display row's position in the final transcript. let turns: Vec = raw_turns .iter() - .map(|raw_turn| build_codex_turn_json(raw_turn, 0)) + .map(|raw_turn| { + let mut rows = build_codex_turn_json(raw_turn, 0)?; + projection::stamp_saved_output_state(raw_turn, &mut rows); + Ok(rows) + }) .collect::>, String>>()? .into_iter() .flatten() @@ -9654,6 +9845,7 @@ fn build_codex_snapshot_json( }, }, "tokenUsage": { + "observed": false, "inputTokens": 0, "outputTokens": 0, "cachedTokens": 0, @@ -11074,6 +11266,82 @@ fn reduce_notification( /// Map an adapter event to a `freshAgent.event` wire frame (sdk-events.ts normalization: /// `sdk.*` → `freshAgent.*`). Returns the pre-serialized JSON, or `None` on a serialize error. +fn project_codex_notification( + projection: &Arc>, + notification: &CodexNotification, +) -> bool { + let (method, params) = match notification { + CodexNotification::Other { method, params } => { + (method.as_str(), params.clone().unwrap_or(Value::Null)) + } + CodexNotification::TurnStarted(event) => { + ("turn/started", Value::Object(event.params.clone())) + } + CodexNotification::TurnCompleted(event) => { + ("turn/completed", Value::Object(event.params.clone())) + } + CodexNotification::ThreadClosed { thread_id } => { + let mut p = projection.lock().expect("codex projection lock"); + if p.owns_thread(thread_id) { + p.freeze_active("interrupted"); + } + return false; + } + CodexNotification::ThreadStatusChanged { thread_id, status } => { + let mut p = projection.lock().expect("codex projection lock"); + if p.owns_thread(thread_id) + && normalize_codex_thread_status(status) == CodexStatus::Exited + { + p.freeze_active("interrupted"); + } + return false; + } + _ => return false, + }; + let mut p = projection.lock().expect("codex projection lock"); + match p.apply(method, ¶ms) { + Ok(ops) => { + let has_ops = !ops.is_empty(); + p.enqueue(ops); + has_ops + } + Err(error) => { + tracing::warn!(provider=PROVIDER, projection_id=%p.id, method=%error.method, native_turn_id=?error.turn_id, native_item_id=?error.item_id, reason=error.reason, "freshagent.codex.projection_refused"); + false + } + } +} + +fn broadcast_codex_event( + thread_id: &str, + event: Value, + tx: &tokio::sync::broadcast::Sender, +) { + let msg = ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.to_owned(), + session_type: SESSION_TYPE.to_owned(), + session_id: thread_id.to_owned(), + event, + }); + if let Ok(frame) = serde_json::to_string(&msg) { + let _ = tx.send(frame); + } +} + +fn flush_codex_projection( + projection: &Arc>, + thread_id: &str, + tx: &tokio::sync::broadcast::Sender, +) { + let patch = projection + .lock() + .expect("codex projection lock") + .take_patch(); + if let Some(patch) = patch { + broadcast_codex_event(thread_id, patch, tx); + } +} + fn adapter_event_to_frame(event: &CodexAdapterEvent, thread_id: &str) -> Option { let inner = match event { CodexAdapterEvent::StatusSnapshot { @@ -12047,6 +12315,10 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))), consumer, kill_tx: Some(kill_tx), watcher, @@ -12104,6 +12376,10 @@ pub(crate) mod tests { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let consumer = state.spawn_consumer( notifs, CodexConsumerRuntime { @@ -12118,6 +12394,7 @@ pub(crate) mod tests { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); @@ -12156,6 +12433,7 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -14172,6 +14450,10 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + "thread-stall".to_string(), + ))), consumer, kill_tx: None, watcher, @@ -15249,6 +15531,15 @@ pub(crate) mod tests { peer.respond(&compact_id, json!({})); compact_1.await.expect("compact-1 task"); + // Accepted compaction advances the transcript history fence before + // another request can observe this window. Consume those additive + // projection frames; the refusal below must still beat every RPC. + let projection: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + assert_eq!(projection["event"]["type"], "freshAgent.transcript.patch"); + assert_eq!(projection["event"]["historyGeneration"], 1); + let metadata: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + assert_eq!(metadata["event"]["type"], "freshAgent.session.metadata"); + // Compact-2 in the window: REFUSED BUSY_TURN with ZERO RPCs reaching // the app-server (the red shape instead issues a second // thread/compact/start — surface it for a loud failure). @@ -26493,7 +26784,13 @@ pub(crate) mod tests { assert_eq!(snapshot["capabilities"]["interrupt"], json!(false)); assert_eq!(snapshot["tokenUsage"]["inputTokens"], json!(0)); assert_eq!(snapshot["pendingApprovals"], json!([])); - assert_eq!(snapshot["extensions"]["codex"], json!({})); + assert_eq!(snapshot["tokenUsage"]["observed"], false); + let projection = &snapshot["extensions"]["codex"]["projection"]; + assert!(projection["id"].as_str().is_some_and(|id| !id.is_empty())); + assert_eq!(projection["eventRevision"], 0); + assert_eq!(projection["historyGeneration"], 0); + assert_eq!(projection["nativeFinalTurnIds"], json!(["turn-1"])); + assert_eq!(projection["turnRows"]["turn-1"], json!(["turn-1"])); // kata z7j7: codex honors all four settings knobs per send (merged over // the session baseline before turn/start). diff --git a/crates/freshell-freshagent/src/codex/native_history.rs b/crates/freshell-freshagent/src/codex/native_history.rs index d6a059857..5f53cd55e 100644 --- a/crates/freshell-freshagent/src/codex/native_history.rs +++ b/crates/freshell-freshagent/src/codex/native_history.rs @@ -160,6 +160,13 @@ pub(super) fn read_rollout(path: &Path, id: &str) -> Result { } target.bytes = 0; target.finished |= finished; + if finished { + target.value["status"] = json!(if payload["type"] == "turn_aborted" { + "interrupted" + } else { + "completed" + }); + } } _ => {} } diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs new file mode 100644 index 000000000..f8f735cb9 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -0,0 +1,2013 @@ +//! The event-owned part of a Codex transcript. Reads and notifications have +//! independent clocks; reading a body never makes it a delta baseline. +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; + +const MAX_FROZEN_BYTES: usize = 8 * 1024 * 1024; +const MAX_FROZEN_TURNS: usize = 32; +const MAX_SAVE_READS: u8 = 3; + +#[derive(Debug)] +pub(super) struct ProjectionError { + pub method: String, + pub turn_id: Option, + pub item_id: Option, + pub reason: &'static str, +} + +#[derive(Debug)] +pub(super) struct SnapshotStale; + +#[derive(Clone)] +struct EventTurn { + raw: Value, + row_by_item: HashMap, + completed_items: HashSet, + // Recovery reads can supply display context, never an append baseline. + read_only_items: HashSet, + emitted: bool, + terminal: bool, + save_reads: u8, + allow_missing_membership: bool, +} + +impl EventTurn { + fn nonappendable_ids(&self) -> Vec { + self.raw["items"] + .as_array() + .into_iter() + .flatten() + .filter(|item| { + item["id"] + .as_str() + .is_some_and(|id| self.read_only_items.contains(id)) + }) + .flat_map(|item| { + super::map_codex_item( + item["id"].as_str().unwrap_or(""), + item, + item["type"].as_str().unwrap_or(""), + ) + .unwrap_or_default() + }) + .filter_map(|item| item["id"].as_str().map(str::to_owned)) + .collect() + } + + fn refresh_read_only(&mut self, native: &Value) { + for item in self.raw["items"].as_array_mut().into_iter().flatten() { + if item["id"] + .as_str() + .is_some_and(|id| self.read_only_items.contains(id)) + { + if let Some(saved) = native["items"] + .as_array() + .into_iter() + .flatten() + .find(|saved| saved["id"] == item["id"] && saved["type"] == item["type"]) + { + *item = saved.clone(); + } + } + } + } + + fn native_can_finish_unobserved_items(&self, native: &Value, legacy: bool) -> bool { + let mut candidate = self.raw.clone(); + let mut saved = native.clone(); + let mut finalizable = HashSet::new(); + for item in self.raw["items"].as_array().into_iter().flatten() { + let Some(id) = item["id"].as_str() else { + continue; + }; + if self.completed_items.contains(id) || self.read_only_items.contains(id) { + continue; + } + if native["items"] + .as_array() + .into_iter() + .flatten() + .any(|saved| saved["id"] == id && saved["type"] == item["type"]) + { + finalizable.insert(id.to_owned()); + } + } + for raw in [&mut candidate, &mut saved] { + if let Some(items) = raw["items"].as_array_mut() { + items.retain(|item| { + !item["id"] + .as_str() + .is_some_and(|id| finalizable.contains(id)) + }); + } + } + // Exact canonical identity permits finalizing a missed item boundary. + // A PTY can finish after its turn and be saved at that later position. + // Every completed role, ordered item and outcome must still agree; + // an unfinished item absent from persistence is retained, not erased. + !finalizable.is_empty() && equivalent_turns(&candidate, &saved, legacy) + } +} + +#[derive(Clone)] +pub(super) struct SnapshotReadFence { + projection_id: String, + owner_epoch: u64, + owner_generation: u64, + history_generation: u64, + event_revision: u64, + turns: Vec, + turn_order: Vec, +} + +pub(super) struct ProjectionSnapshot { + pub raw: Value, + pub metadata: Value, +} + +pub(super) struct CodexProjection { + pub id: String, + thread_id: String, + owner_epoch: u64, + owner_generation: u64, + pub history_generation: u64, + revision: u64, + active_turn: Option, + turns: Vec, + // Membership fences outlive payload retention. They are never the eight-turn + // attention cache: old native identities cannot reopen a historical operation. + known_turns: HashSet, + turn_order: Vec, + terminal_turns: HashSet, + removed_turns: HashSet, + unknown_methods: HashSet, + last_native: Option, + pub usage: Option, + pub plan: Option, + pub diff: Option, + pending_ops: Vec, +} + +impl CodexProjection { + pub(super) fn new(projection_id: String, thread_id: String) -> Self { + Self { + id: projection_id, + thread_id, + owner_epoch: 0, + owner_generation: 0, + history_generation: 0, + revision: 0, + active_turn: None, + turns: vec![], + known_turns: HashSet::new(), + turn_order: vec![], + removed_turns: HashSet::new(), + terminal_turns: HashSet::new(), + unknown_methods: HashSet::new(), + last_native: None, + usage: None, + plan: None, + diff: None, + pending_ops: vec![], + } + } + + pub(super) fn bind_owner(&mut self, epoch: u64, generation: u64) { + self.owner_epoch = epoch; + self.owner_generation = generation; + } + + pub(super) fn begin_history_read(&self) -> SnapshotReadFence { + SnapshotReadFence { + projection_id: self.id.clone(), + owner_epoch: self.owner_epoch, + owner_generation: self.owner_generation, + history_generation: self.history_generation, + event_revision: self.revision, + turns: self.turns.clone(), + turn_order: self.turn_order.clone(), + } + } + + pub(super) fn owns_thread(&self, id: &str) -> bool { + self.thread_id == id + } + + pub(super) fn matches_fence(&self, fence: &SnapshotReadFence) -> bool { + fence.projection_id == self.id + && fence.owner_epoch == self.owner_epoch + && fence.owner_generation == self.owner_generation + && fence.history_generation == self.history_generation + } + + pub(super) fn has_usable_history(&self) -> bool { + self.last_native.is_some() || !self.turns.is_empty() + } + + pub(super) fn patch(&self, ops: Vec) -> Value { + json!({"type":"freshAgent.transcript.patch", "sessionId":self.thread_id, + "projectionId":self.id, "historyGeneration":self.history_generation, "ops":ops}) + } + + pub(super) fn metadata_event(&self) -> Value { + json!({"type":"freshAgent.session.metadata","sessionId":self.thread_id,"codex":{"projectionId":self.id,"historyGeneration":self.history_generation,"usage":self.usage,"plan":self.plan,"diff":self.diff}}) + } + + pub(super) fn enqueue(&mut self, ops: Vec) { + for op in ops { + if let Some(last) = self.pending_ops.last_mut() { + if last["op"] == "appendItem" + && op["op"] == "appendItem" + && ["nativeTurnId", "rowId", "itemId", "field", "index"] + .iter() + .all(|key| last[key] == op[key]) + { + append_string(last, "text", op["text"].as_str().unwrap_or("")); + last["revision"] = op["revision"].clone(); + continue; + } + } + self.pending_ops.push(op); + } + } + + pub(super) fn take_patch(&mut self) -> Option { + if self.pending_ops.is_empty() { + None + } else { + let ops = std::mem::take(&mut self.pending_ops); + Some(self.patch(ops)) + } + } + + fn op(&mut self, mut value: Value) -> Value { + // Revisions are consumed-notification clocks, never native-read watermarks. + self.revision = self.revision.saturating_add(1).min(9_007_199_254_740_991); + value["revision"] = json!(self.revision); + value + } + + fn open_turn(&mut self, id: &str) -> bool { + if self.removed_turns.contains(id) || self.terminal_turns.contains(id) { + return false; + } + if self.active_turn.as_deref() == Some(id) { + return true; + } + if self.known_turns.contains(id) && !self.recoverable_native_turn(id) { + return false; + } + // A new native turn is an operation boundary, including when the old + // completion was lost. Its unfinished body becomes a finite retained view. + self.freeze_active("interrupted"); + self.active_turn = Some(id.to_owned()); + if self.known_turns.insert(id.to_owned()) { + self.turn_order.push(id.to_owned()); + } + let preview = self.last_native.as_ref().and_then(|raw| { + raw["thread"]["turns"] + .as_array()? + .iter() + .find(|turn| turn["id"] == id && turn["status"] == "inProgress") + .map(|turn| (turn, raw["thread"]["historyMode"] == "legacy")) + }); + let items: Vec = preview + .into_iter() + .flat_map(|(turn, legacy)| { + turn["items"] + .as_array() + .into_iter() + .flatten() + .filter(move |item| { + // Legacy message ids are reconstructed, so only the immutable + // user prefix is safe to associate with new canonical items. + !legacy || item["type"] == "userMessage" + }) + .cloned() + }) + .collect(); + let read_only_items = items + .iter() + .filter_map(|item| item["id"].as_str().map(str::to_owned)) + .collect(); + self.turns.push(EventTurn { + raw: json!({"id":id,"status":"inProgress","items":items}), + row_by_item: HashMap::new(), + completed_items: HashSet::new(), + read_only_items, + emitted: false, + terminal: false, + save_reads: 0, + allow_missing_membership: true, + }); + true + } + + fn recoverable_native_turn(&self, id: &str) -> bool { + self.active_turn.is_none() + && self.last_native.as_ref().is_some_and(|raw| { + raw["thread"]["turns"].as_array().is_some_and(|turns| { + turns + .iter() + .any(|turn| turn["id"] == id && turn["status"] == "inProgress") + }) + }) + } + + pub(super) fn freeze_active(&mut self, status: &str) { + if let Some(id) = self.active_turn.take() { + self.terminal_turns.insert(id.clone()); + if let Some(turn) = self.turns.iter_mut().find(|t| t.raw["id"] == id) { + turn.terminal = true; + turn.raw["status"] = json!(status); + } + } + self.trim_frozen(); + } + + fn trim_frozen(&mut self) { + let mut bytes: usize = self + .turns + .iter() + .filter(|t| t.terminal) + .map(|t| t.raw.to_string().len()) + .sum(); + let mut count = self.turns.iter().filter(|t| t.terminal).count(); + self.turns.retain(|t| { + if t.terminal && (bytes > MAX_FROZEN_BYTES || count > MAX_FROZEN_TURNS) { + bytes = bytes.saturating_sub(t.raw.to_string().len()); + count -= 1; + false + } else { + true + } + }); + } + + pub(super) fn invalidate_history(&mut self, removed: &[String]) -> Vec { + self.history_generation += 1; + self.pending_ops.clear(); + if !removed.is_empty() { + self.freeze_active("interrupted"); + } + for turn in &mut self.turns { + if turn.terminal { + turn.allow_missing_membership = false; + } + } + self.removed_turns.extend(removed.iter().cloned()); + if self + .plan + .as_ref() + .is_some_and(|plan| removed.iter().any(|id| plan["turnId"] == *id)) + { + self.plan = None; + } + if self.diff.as_ref().is_some_and(|diff| { + removed + .iter() + .any(|id| diff["id"] == format!("codex-turn:{id}")) + }) { + self.diff = None; + } + self.turns + .retain(|t| !removed.iter().any(|id| t.raw["id"] == *id)); + if let Some(raw) = &mut self.last_native { + if let Some(turns) = raw["thread"]["turns"].as_array_mut() { + turns.retain(|t| !removed.iter().any(|id| t["id"] == *id)); + } + } + let mut resets: Vec = self.turns.iter().map(|turn| json!({"op":"resetTurn","nativeTurnId":turn.raw["id"],"turns":super::build_codex_turn_json(&turn.raw,0).unwrap_or_default(),"completedItemIds":turn.completed_items,"nonAppendableItemIds":turn.nonappendable_ids()})).collect(); + resets.extend( + removed + .iter() + .map(|id| json!({"op":"resetTurn","nativeTurnId":id,"turns":[]})), + ); + resets.into_iter().map(|op| self.op(op)).collect() + } + + pub(super) fn apply( + &mut self, + method: &str, + params: &Value, + ) -> Result, ProjectionError> { + if params.get("threadId").and_then(Value::as_str) != Some(&self.thread_id) { + return Ok(vec![]); + } + if method == "thread/tokenUsage/updated" { + if let Some(usage) = params + .get("tokenUsage") + .filter(|u| u["total"].is_object() && u["last"].is_object()) + { + self.usage = Some(usage.clone()); + } + return Ok(vec![]); + } + let turn_id = params + .get("turnId") + .and_then(Value::as_str) + .or_else(|| params.get("turn").and_then(|t| t["id"].as_str())); + let error = |reason| ProjectionError { + method: method.to_owned(), + turn_id: turn_id.map(str::to_owned), + item_id: params + .get("itemId") + .and_then(Value::as_str) + .map(str::to_owned), + reason, + }; + if method == "turn/started" { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + self.open_turn(id); + return Ok(vec![]); + } + if method == "turn/completed" { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + // Summary/NotLoaded is status-only. Even a Full notification is + // not persistence evidence; complete items already supplied bodies. + let turn = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .expect("active event turn"); + let status = params["turn"]["status"] + .as_str() + .or_else(|| params["status"].as_str()) + .unwrap_or("completed"); + turn.raw["status"] = json!(status); + if let Some(err) = params["turn"].get("error") { + turn.raw["error"] = err.clone(); + } + self.freeze_active(status); + return Ok(vec![ + self.op(json!({"op":"settleTurn","nativeTurnId":id,"status":status})) + ]); + } + if matches!(method, "turn/plan/updated" | "turn/diff/updated") { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + if method == "turn/plan/updated" { + self.plan = Some( + json!({"turnId":id,"explanation":params.get("explanation"),"plan":params.get("plan").cloned().unwrap_or_else(|| json!([]))}), + ); + } else if let Some(diff) = params["diff"].as_str() { + self.diff = Some( + json!({"id":format!("codex-turn:{id}"),"title":"Turn changes","diff":diff}), + ); + } + return Ok(vec![]); + } + let item_boundary = matches!(method, "item/started" | "item/completed"); + let delta_field = match method { + "item/agentMessage/delta" | "item/plan/delta" => Some(("text", "text", None)), + "item/commandExecution/outputDelta" | "item/fileChange/outputDelta" => { + Some(("aggregatedOutput", "output", None)) + } + "item/reasoning/summaryTextDelta" => Some(("summary", "summary", Some("summaryIndex"))), + "item/reasoning/textDelta" => Some(("content", "content", Some("contentIndex"))), + _ => None, + }; + if !item_boundary + && delta_field.is_none() + && !matches!( + method, + "item/fileChange/patchUpdated" + | "item/mcpToolCall/progress" + | "item/reasoning/summaryPartAdded" + ) + { + if self.unknown_methods.insert(method.to_owned()) { + tracing::debug!(provider="codex", session_id=%self.thread_id, projection_id=%self.id, method, "freshagent.codex.unprojected_notification"); + } + return Ok(vec![]); + } + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + // Only the first observed item may recover a missed turn/started. Later + // turns must be opened by their native lifecycle event. + if self.active_turn.is_none() + && item_boundary + && (self.known_turns.is_empty() || self.recoverable_native_turn(id)) + { + self.open_turn(id); + } + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + let turn_index = self + .turns + .iter() + .position(|t| t.raw["id"] == id) + .expect("active event turn"); + let item_id = if item_boundary { + params["item"]["id"].as_str() + } else { + params["itemId"].as_str() + } + .filter(|id| !id.is_empty()) + .ok_or_else(|| error("missing native item id"))?; + if self.turns[turn_index].completed_items.contains(item_id) { + return Ok(vec![]); + } + if item_boundary { + let item = params + .get("item") + .filter(|i| i["type"].is_string()) + .ok_or_else(|| error("missing native item type"))?; + let old_rows = super::build_codex_turn_json(&self.turns[turn_index].raw, 0) + .map_err(|_| error("unmappable native turn"))?; + let items = self.turns[turn_index].raw["items"] + .as_array_mut() + .expect("owned items array"); + match items.iter_mut().find(|i| i["id"] == item_id) { + Some(existing) => *existing = item.clone(), + None => items.push(item.clone()), + } + let complete = method == "item/completed"; + self.turns[turn_index].read_only_items.remove(item_id); + if complete { + self.turns[turn_index] + .completed_items + .insert(item_id.to_owned()); + } + let rows = super::build_codex_turn_json(&self.turns[turn_index].raw, 0) + .map_err(|_| error("unmappable native item"))?; + self.turns[turn_index].row_by_item = rows + .iter() + .flat_map(|row| { + row["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| { + Some(( + item["id"].as_str()?.to_owned(), + row["id"].as_str()?.to_owned(), + )) + }) + }) + .collect(); + let old_shape: Vec<_> = old_rows.iter().map(|r| (&r["id"], &r["role"])).collect(); + let new_shape: Vec<_> = rows.iter().map(|r| (&r["id"], &r["role"])).collect(); + let first = !self.turns[turn_index].emitted; + self.turns[turn_index].emitted = true; + if first || old_shape != new_shape { + return Ok(vec![self.op(json!({"op":"resetTurn","nativeTurnId":id,"turns":rows,"completedItemIds":self.turns[turn_index].completed_items,"nonAppendableItemIds":self.turns[turn_index].nonappendable_ids()}))]); + } + let mut ops = vec![]; + for row in rows { + for mapped in row["items"].as_array().into_iter().flatten().filter(|i| { + i["id"] == item_id + || i["id"] + .as_str() + .is_some_and(|v| v.starts_with(&format!("{item_id}:part:"))) + }) { + let mut header = row.clone(); + header["items"] = json!([]); + ops.push(self.op(json!({"op":"upsertItem","nativeTurnId":id,"row":header,"item":mapped,"complete":complete}))); + } + } + return Ok(ops); + } + let turn = &mut self.turns[turn_index]; + if turn.read_only_items.contains(item_id) { + return Ok(vec![]); + } + let Some(item) = turn.raw["items"] + .as_array_mut() + .expect("owned items array") + .iter_mut() + .find(|i| i["id"] == item_id) + else { + return Ok(vec![]); + }; + if let Some((native_field, field, index_field)) = delta_field { + let delta = params["delta"] + .as_str() + .ok_or_else(|| error("missing string delta"))?; + let expected_type = match field { + "text" => matches!(item["type"].as_str(), Some("agentMessage" | "plan")), + "output" => matches!( + item["type"].as_str(), + Some("commandExecution" | "fileChange") + ), + _ => item["type"] == "reasoning", + }; + if !expected_type { + return Err(error("delta does not match native item type")); + } + let index = index_field + .map(|key| { + params[key] + .as_u64() + .filter(|i| *i < 1024) + .ok_or_else(|| error("invalid native reasoning index")) + }) + .transpose()?; + if let Some(index) = index { + if !item[native_field].is_array() { + item[native_field] = json!([]); + } + let parts = item[native_field].as_array_mut().expect("indexed array"); + while parts.len() <= index as usize { + parts.push(json!("")); + } + // Each native indexed part is an owned String, as for ordinary text. + if let Value::String(target) = &mut parts[index as usize] { + target.push_str(delta); + } else { + parts[index as usize] = json!(delta); + } + } else { + append_string(item, native_field, delta); + } + let row_id = turn.row_by_item.get(item_id).cloned(); + // Do not emit accumulated bodies on token arrival. + return Ok(row_id.into_iter().map(|row_id| self.op(json!({"op":"appendItem","nativeTurnId":id,"rowId":row_id,"itemId":item_id,"field":field,"index":index,"text":delta}))).collect()); + } + if method == "item/fileChange/patchUpdated" { + if let Some(changes) = params.get("changes").filter(|v| v.is_array()) { + item["changes"] = changes.clone(); + } + } else if method == "item/mcpToolCall/progress" { + item["progress"] = params.get("message").cloned().unwrap_or(Value::Null); + } else { + return Ok(vec![]); + } + let rows = super::build_codex_turn_json(&turn.raw, 0) + .map_err(|_| error("unmappable native progress"))?; + let mut ops = vec![]; + for row in rows { + for mapped in row["items"] + .as_array() + .into_iter() + .flatten() + .filter(|i| i["id"] == item_id) + { + let mut header = row.clone(); + header["items"] = json!([]); + ops.push( + self.op( + json!({"op":"upsertItem","nativeTurnId":id,"row":header,"item":mapped}), + ), + ); + } + } + Ok(ops) + } + + pub(super) fn accept_native_history( + &mut self, + raw: &Value, + fence: &SnapshotReadFence, + ) -> Result { + if !self.matches_fence(fence) { + return Err(SnapshotStale); + } + if raw["thread"]["id"] + .as_str() + .is_some_and(|id| id != self.thread_id) + { + return Err(SnapshotStale); + } + self.last_native = Some(raw.clone()); + self.build_snapshot(raw, fence, false) + } + + pub(super) fn unavailable_history( + &mut self, + fence: &SnapshotReadFence, + ) -> Result { + let raw = self.last_native.clone().unwrap_or_else( + || json!({"thread":{"id":self.thread_id,"status":{"type":"idle"},"turns":[]}}), + ); + self.build_snapshot(&raw, fence, true) + } + + fn build_snapshot( + &mut self, + raw: &Value, + fence: &SnapshotReadFence, + unavailable: bool, + ) -> Result { + if !self.matches_fence(fence) { + return Err(SnapshotStale); + } + let mut chosen = vec![]; + let mut event_owned = vec![]; + let mut previews = vec![]; + let mut finals = vec![]; + let mut states = Map::new(); + let mut turn_rows = Map::new(); + let mut nonappendable = Map::new(); + let mut native_ids = HashSet::new(); + let owned: HashMap<_, _> = fence + .turns + .iter() + .filter_map(|t| t.raw["id"].as_str().map(|id| (id, t))) + .collect(); + let legacy = raw["thread"]["historyMode"] == "legacy"; + for native in raw["thread"]["turns"].as_array().into_iter().flatten() { + let Some(id) = native["id"].as_str() else { + continue; + }; + if self.removed_turns.contains(id) { + continue; + } + native_ids.insert(id.to_owned()); + if self.known_turns.insert(id.to_owned()) { + self.turn_order.push(id.to_owned()); + } + let full = native["itemsView"] + .as_str() + .is_none_or(|view| view == "full"); + let terminal = matches!( + native["status"].as_str(), + Some("completed" | "failed" | "interrupted") + ); + if terminal { + self.terminal_turns.insert(id.to_owned()); + } + if let Some(event) = owned.get(id) { + let mut event = (*event).clone(); + event.refresh_read_only(native); + if let Some(current) = self.turns.iter_mut().find(|turn| turn.raw["id"] == id) { + // An item boundary during the RPC may already have claimed + // a field. Refresh only fields that remain read-only now. + current.refresh_read_only(native); + } + nonappendable.insert(id.to_owned(), json!(event.nonappendable_ids())); + if terminal && full { + let equal = equivalent_turns(&event.raw, native, legacy); + let interrupted = native["status"] == "interrupted"; + let truncated = provider_truncated(native); + // A native full read can finish an item whose completion + // this incarnation never observed (a PTY may outlive its + // originating turn). Only a completed event body demands + // semantic agreement; an unfinished body is not final. + let finalized_unobserved = + event.native_can_finish_unobserved_items(native, legacy); + if equal || interrupted || truncated || finalized_unobserved { + chosen.push(native.clone()); + finals.push(id.to_owned()); + let mut state = json!({"provenance":"saved","refreshPending":false}); + if interrupted { + state["diagnostic"] = json!("interrupted"); + } + if truncated { + state["diagnostic"] = json!("provider-truncated"); + } + if !equal && (interrupted || truncated) { + state["provenance"] = json!("saved-and-retained"); + state["retainedTurns"] = json!(super::build_codex_turn_json( + &event.raw, 0 + ) + .unwrap_or_default()); + } + states.insert(id.to_owned(), state); + // Only release the captured prefix, never newer event work. + if (equal || (finalized_unobserved && !interrupted && !truncated)) + && self.revision == fence.event_revision + { + if self.active_turn.as_deref() == Some(id) { + self.active_turn = None; + } + self.turns.retain(|t| t.raw["id"] != id); + } + continue; + } + } + event_owned.push(id.to_owned()); + chosen.push(event.raw.clone()); + let terminal_event = event.terminal || terminal; + let reads = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .map(|t| { + if terminal_event { + t.save_reads = t.save_reads.saturating_add(1); + } + t.save_reads + }) + .unwrap_or(MAX_SAVE_READS); + let pending = terminal_event && reads < MAX_SAVE_READS && !unavailable; + let mut state = json!({"provenance":"retained","refreshPending":pending}); + if terminal_event && !pending { + state["diagnostic"] = json!(if event.raw["status"] == "interrupted" { + "interrupted" + } else { + "unsaved" + }); + } + if terminal && full { + state["savedTurns"] = + json!(super::build_codex_turn_json(native, 0).unwrap_or_default()); + } + states.insert(id.to_owned(), state); + } else { + chosen.push(native.clone()); + if terminal && full { + finals.push(id.to_owned()); + let mut state = json!({"provenance":"saved","refreshPending":false}); + if native["status"] == "interrupted" { + state["diagnostic"] = json!("interrupted"); + } + if provider_truncated(native) { + state["diagnostic"] = json!("provider-truncated"); + } + states.insert(id.to_owned(), state); + } else { + previews.push(id.to_owned()); + states.insert( + id.to_owned(), + json!({"provenance":"preview","refreshPending":false}), + ); + } + } + } + for event in &fence.turns { + let Some(id) = event.raw["id"].as_str() else { + continue; + }; + if native_ids.contains(id) + || self.removed_turns.contains(id) + || !event.allow_missing_membership + { + continue; + } + let after = fence + .turn_order + .iter() + .position(|known| known == id) + .map(|index| &fence.turn_order[index + 1..]) + .unwrap_or(&[]); + let insertion = after + .iter() + .find_map(|next| chosen.iter().position(|turn| turn["id"] == *next)) + .unwrap_or(chosen.len()); + chosen.insert(insertion, event.raw.clone()); + event_owned.push(id.to_owned()); + nonappendable.insert(id.to_owned(), json!(event.nonappendable_ids())); + let reads = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .map(|t| { + if event.terminal { + t.save_reads = t.save_reads.saturating_add(1); + } + t.save_reads + }) + .unwrap_or(MAX_SAVE_READS); + let pending = event.terminal && reads < MAX_SAVE_READS && !unavailable; + let mut state = json!({"provenance":"retained","refreshPending":pending}); + if event.terminal && !pending { + state["diagnostic"] = json!(if event.raw["status"] == "interrupted" { + "interrupted" + } else { + "unsaved" + }); + } + states.insert(id.to_owned(), state); + } + for turn in &chosen { + let id = turn["id"].as_str().unwrap_or(""); + turn_rows.insert( + id.to_owned(), + json!(super::build_codex_turn_json(turn, 0) + .unwrap_or_default() + .iter() + .map(|r| r["id"].clone()) + .collect::>()), + ); + } + let mut order: Vec = chosen + .iter() + .filter_map(|turn| turn["id"].as_str().map(str::to_owned)) + .collect(); + let mut ordered: HashSet = order.iter().cloned().collect(); + for id in &self.turn_order { + if !self.removed_turns.contains(id) && ordered.insert(id.clone()) { + order.push(id.clone()); + } + } + self.turn_order = order; + let mut output = raw.clone(); + output["thread"]["turns"] = json!(chosen); + let completed_items: Map = fence + .turns + .iter() + .filter_map(|turn| { + Some(( + turn.raw["id"].as_str()?.to_owned(), + json!(turn.completed_items), + )) + }) + .collect(); + let settled: Vec<_> = fence + .turns + .iter() + .filter(|turn| turn.terminal) + .map(|turn| turn.raw["id"].clone()) + .collect(); + let mut metadata = json!({"id":self.id,"eventRevision":fence.event_revision,"historyGeneration":fence.history_generation,"completedItems":completed_items,"nonAppendableItemIds":nonappendable,"settledTurnIds":settled, + "eventOwnedTurnIds":event_owned,"nativePreviewTurnIds":previews,"nativeFinalTurnIds":finals,"turnOutputStates":states,"turnRows":turn_rows}); + if unavailable { + metadata["availability"] = + json!({"status":"unavailable","retryable":true,"code":"CODEX_HISTORY_UNAVAILABLE"}); + } + Ok(ProjectionSnapshot { + raw: output, + metadata, + }) + } + + pub(super) fn overlay(&self, snapshot: &mut Value, metadata: Value) { + snapshot["extensions"]["codex"]["projection"] = metadata; + snapshot["tokenUsage"]["observed"] = json!(self.usage.is_some()); + if let Some(usage) = &self.usage { + snapshot["extensions"]["codex"]["usage"] = usage.clone(); + let total = &usage["total"]; + snapshot["tokenUsage"] = json!({"observed":true,"inputTokens":total["inputTokens"].as_u64().unwrap_or(0),"outputTokens":total["outputTokens"].as_u64().unwrap_or(0),"cachedTokens":total["cachedInputTokens"].as_u64().unwrap_or(0),"totalTokens":total["totalTokens"].as_u64().unwrap_or(0)}); + if let Some(window) = usage["modelContextWindow"].as_u64() { + snapshot["tokenUsage"]["modelContextWindow"] = json!(window); + } + } + if let Some(plan) = &self.plan { + snapshot["extensions"]["codex"]["plan"] = plan.clone(); + } + if let Some(diff) = &self.diff { + if let Some(diffs) = snapshot["diffs"].as_array_mut() { + diffs.push(diff.clone()); + } + snapshot["capabilities"]["diffs"] = json!(true); + } + } + + #[cfg(test)] + fn snapshot_rows(&self, turn_id: &str) -> Option> { + super::build_codex_turn_json(&self.turns.iter().find(|t| t.raw["id"] == turn_id)?.raw, 0) + .ok() + } +} + +fn append_string(target: &mut Value, field: &str, delta: &str) { + match target.get_mut(field) { + Some(Value::String(text)) => text.push_str(delta), + _ => target[field] = Value::String(delta.to_owned()), + } +} + +pub(super) fn stamp_saved_output_state(raw: &Value, rows: &mut [Value]) { + let diagnostic = if raw["status"] == "interrupted" { + Some("interrupted") + } else if provider_truncated(raw) { + Some("provider-truncated") + } else { + None + }; + if let (Some(diagnostic), Some(row)) = (diagnostic, rows.first_mut()) { + row["outputState"] = + json!({"provenance":"saved","diagnostic":diagnostic,"refreshPending":false}); + } +} + +// Compare the complete ordered display representation, preserving roles, +// duplicate bodies, tool outcomes and errors. Legacy reconstructs item-N ids; +// only that evidenced presentation difference is normalized. +fn equivalent_turns(event: &Value, native: &Value, legacy: bool) -> bool { + let (Ok(mut event_rows), Ok(mut native_rows)) = ( + super::build_codex_turn_json(event, 0), + super::build_codex_turn_json(native, 0), + ) else { + return false; + }; + if event_rows.len() != native_rows.len() { + return false; + } + for (event, native) in event_rows.iter_mut().zip(native_rows.iter_mut()) { + if event["role"] != native["role"] { + return false; + } + let left = event["items"].as_array_mut().expect("mapped items"); + let right = native["items"].as_array_mut().expect("mapped items"); + if left.len() != right.len() { + return false; + } + for (event, native) in left.iter_mut().zip(right.iter_mut()) { + let reconstructed = legacy + && native["id"].as_str().is_some_and(|id| { + let base = id.split(":part:").next().unwrap_or(id); + base.strip_prefix("item-") + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) + }); + if reconstructed { + strip_presentation_id(event); + strip_presentation_id(native); + } + if event != native { + return false; + } + } + } + true +} + +fn strip_presentation_id(item: &mut Value) { + if let Some(object) = item.as_object_mut() { + object.remove("id"); + } + if let Some(raw) = item + .get_mut("extensions") + .and_then(|v| v.get_mut("codex")) + .and_then(Value::as_object_mut) + { + raw.remove("id"); + } +} + +fn provider_truncated(turn: &Value) -> bool { + turn["items"] + .as_array() + .into_iter() + .flatten() + .any(|item| match item["type"].as_str() { + Some("commandExecution") => item["aggregatedOutput"] + .as_str() + .is_some_and(|s| s.contains("... command output truncated for persistence ...")), + Some("mcpToolCall") => item["result"].to_string().contains(" chars truncated"), + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codex::tests as rig; + use freshell_codex::CodexAppServerClient; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + fn started(p: &mut CodexProjection, id: &str) { + p.apply( + "turn/started", + &json!({"threadId":"thread-1","turn":{"id":id,"status":"inProgress"}}), + ) + .unwrap(); + } + fn item(p: &mut CodexProjection, turn: &str, body: Value, completed: bool) -> Vec { + p.apply( + if completed { + "item/completed" + } else { + "item/started" + }, + &json!({"threadId":"thread-1","turnId":turn,"item":body}), + ) + .unwrap() + } + fn delta(p: &mut CodexProjection, turn: &str, text: &str) -> Vec { + p.apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":turn,"itemId":"answer","delta":text}), + ) + .unwrap() + } + fn ended(p: &mut CodexProjection, turn: &str, status: &str) { + p.apply("turn/completed", &json!({"threadId":"thread-1","turn":{"id":turn,"status":status,"itemsView":"summary","items":[]}})).unwrap(); + } + fn history(turns: Value) -> Value { + json!({"thread":{"id":"thread-1","historyMode":"paginated","status":{"type":"idle"},"turns":turns}}) + } + fn snapshot(p: &mut CodexProjection, turns: Value) -> ProjectionSnapshot { + p.accept_native_history(&history(turns), &p.begin_history_read()) + .unwrap() + } + + #[test] + fn independent_read_never_becomes_the_event_delta_baseline() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "Hel"); + let ahead = snapshot( + &mut p, + json!([{"id":"t","status":"inProgress","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"Hello"}]}]), + ); + assert_eq!(ahead.metadata["eventOwnedTurnIds"], json!(["t"])); + delta(&mut p, "t", "lo"); + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"], + "Hello" + ); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"Hello"}), + true, + ); + assert!(delta(&mut p, "t", "lo").is_empty()); + ended(&mut p, "t", "completed"); + let saved = snapshot( + &mut p, + json!([{"id":"t","status":"completed","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"Hello"}]}]), + ); + assert_eq!(saved.metadata["nativeFinalTurnIds"], json!(["t"])); + assert_eq!( + saved.raw["thread"]["turns"][0]["items"] + .as_array() + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn summary_interruption_retains_fragments_and_cold_recovery_is_saved_only() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "unfinished 🙂"); + ended(&mut p, "t", "interrupted"); + let raw = json!([{"id":"t","status":"interrupted","itemsView":"full","items":[{"type":"userMessage","id":"user","content":[{"type":"text","text":"go"}]}]}]); + let warm = snapshot(&mut p, raw.clone()); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["provenance"], + "saved-and-retained" + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["diagnostic"], + "interrupted" + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["retainedTurns"][0]["items"][0]["text"], + "unfinished 🙂" + ); + assert!(delta(&mut p, "t", "late").is_empty()); + let mut cold = CodexProjection::new("cold".into(), "thread-1".into()); + let saved = snapshot(&mut cold, raw); + assert_eq!( + saved.metadata["turnOutputStates"]["t"], + json!({"provenance":"saved","diagnostic":"interrupted","refreshPending":false}) + ); + } + + #[test] + fn missing_or_different_assistant_persistence_reaches_finite_unsaved_state() { + for turns in [ + json!([]), + json!([{"id":"t","status":"completed","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"short"}]}]), + ] { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"retained complete answer"}), + true, + ); + ended(&mut p, "t", "completed"); + for attempt in 1..=4 { + let view = snapshot(&mut p, turns.clone()); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "retained complete answer" + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + attempt < 3 + ); + if attempt >= 3 { + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + } + } + } + } + + #[test] + fn legacy_comparison_preserves_duplicate_bodies_roles_and_outcomes() { + let event = json!({"id":"t","status":"completed","items":[{"type":"userMessage","id":"uuid-user","content":[{"type":"text","text":"same"}]},{"type":"agentMessage","id":"msg-1","text":"same"},{"type":"agentMessage","id":"msg-2","text":"same"},{"type":"commandExecution","id":"call-1","command":"pwd","status":"failed","exitCode":1}]}); + let mut native = event.clone(); + for (index, i) in native["items"] + .as_array_mut() + .unwrap() + .iter_mut() + .enumerate() + { + i["id"] = json!(format!("item-{}", index + 1)); + } + assert!(equivalent_turns(&event, &native, true)); + assert!(!equivalent_turns(&event, &native, false)); + // Legacy reconstructs message ids while preserving actual tool call + // ids in the same turn (also observed in the large-result capture). + native["items"][3]["id"] = json!("call-1"); + assert!(equivalent_turns(&event, &native, true)); + native["items"].as_array_mut().unwrap().remove(2); + assert!(!equivalent_turns(&event, &native, true)); + assert!(!provider_truncated( + &json!({"items":[{"type":"agentMessage","text":"short"}]}) + )); + } + + #[test] + fn native_tool_truncation_preserves_saved_markers_and_richer_retained_output() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + let item_body = json!({"type":"commandExecution","id":"cmd","command":"large","status":"completed","aggregatedOutput":"x".repeat(100_000)}); + item(&mut p, "t", item_body.clone(), true); + ended(&mut p, "t", "completed"); + let mut saved_item = item_body; + saved_item["aggregatedOutput"] = json!(format!( + "{}\n... command output truncated for persistence ...\n{}", + "x".repeat(32000), + "x".repeat(32000) + )); + let view = snapshot( + &mut p, + json!([{"id":"t","status":"completed","itemsView":"full","items":[saved_item.clone()]}]), + ); + assert_eq!(view.raw["thread"]["turns"][0]["items"][0], saved_item); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "provider-truncated" + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["retainedTurns"][0]["items"][0]["output"] + .as_str() + .unwrap() + .len(), + 100_000 + ); + } + + #[test] + fn reads_crossing_history_or_owner_mutation_are_rejected_and_removed_turns_never_reopen() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"before undo"}), + true, + ); + ended(&mut p, "t", "completed"); + let fence = p.begin_history_read(); + p.invalidate_history(&["t".into()]); + assert!(p + .accept_native_history(&history(json!([])), &fence) + .is_err()); + for index in 0..12 { + let id = format!("later-{index}"); + started(&mut p, &id); + ended(&mut p, &id, "completed"); + } + assert!(item( + &mut p, + "t", + json!({"type":"agentMessage","id":"late","text":"removed"}), + false + ) + .is_empty()); + assert!(delta(&mut p, "t", "removed").is_empty()); + assert_eq!( + snapshot(&mut p, json!([])).metadata["nativeFinalTurnIds"], + json!([]) + ); + let owner_fence = p.begin_history_read(); + p.bind_owner(1, 2); + assert!(p + .accept_native_history(&history(json!([])), &owner_fence) + .is_err()); + } + + #[test] + fn cold_native_preview_can_receive_a_normal_complete_item_without_appending_from_the_read() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + snapshot( + &mut p, + json!([{"id":"t","status":"inProgress","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"preview"}]}]), + ); + assert!(delta(&mut p, "t", "not-a-baseline").is_empty()); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"complete"}), + true, + ); + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"], + "complete" + ); + } + + #[test] + fn cold_recovery_preserves_native_prefix_roles_and_finishes_as_one_saved_group() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + let mut native = json!({"id":"t","status":"inProgress","itemsView":"full","items":[ + {"id":"u","type":"userMessage","content":[{"type":"text","text":"go"}]}, + {"id":"prior","type":"agentMessage","text":"Prior commentary"}, + {"id":"cmd","type":"commandExecution","command":"pwd","status":"completed","aggregatedOutput":"/work"}, + {"id":"answer","type":"agentMessage","text":"partial"} + ]}); + snapshot(&mut p, json!([native.clone()])); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"complete"}), + true, + ); + let rows = p.snapshot_rows("t").unwrap(); + assert_eq!( + rows.len(), + 4, + "recovering the complete item cannot erase the independently read prefix" + ); + assert_eq!(rows[0]["role"], "user"); + assert_eq!(rows[1]["items"][0]["text"], "Prior commentary"); + assert!(p.apply("item/agentMessage/delta",&json!({"threadId":"thread-1","turnId":"t","itemId":"prior","delta":"must not append"})).unwrap().is_empty()); + ended(&mut p, "t", "completed"); + native["status"] = json!("completed"); + native["items"][3]["text"] = json!("complete"); + let view = snapshot(&mut p, json!([native])); + assert_eq!(view.metadata["nativeFinalTurnIds"], json!(["t"])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["provenance"], + "saved" + ); + } + + #[test] + fn read_only_prefix_refresh_never_overwrites_live_fields_or_hides_a_completed_mismatch() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + let mut native = json!({"id":"t","status":"inProgress","itemsView":"full","items":[ + {"id":"prior","type":"agentMessage","text":"older preview"}, + {"id":"answer","type":"agentMessage","text":"read-only answer"} + ]}); + snapshot(&mut p, json!([native.clone()])); + let ops = item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":""}), + false, + ); + assert_eq!(ops[0]["nonAppendableItemIds"], json!(["prior"])); + let fence = p.begin_history_read(); + delta(&mut p, "t", "Hel"); + native["items"][0]["text"] = json!("newer preview"); + native["items"][1]["text"] = json!("Hello"); + let view = p + .accept_native_history(&history(json!([native.clone()])), &fence) + .unwrap(); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "newer preview" + ); + assert_eq!(view.raw["thread"]["turns"][0]["items"][1]["text"], ""); + assert_eq!(p.snapshot_rows("t").unwrap()[0]["items"][1]["text"], "Hel"); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"actual completed mismatch"}), + true, + ); + ended(&mut p, "t", "completed"); + native["status"] = json!("completed"); + let mut view = snapshot(&mut p, json!([native.clone()])); + for _ in 1..MAX_SAVE_READS { + view = snapshot(&mut p, json!([native.clone()])); + } + assert_eq!(view.metadata["nativeFinalTurnIds"], json!([])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][1]["text"], + "actual completed mismatch" + ); + } + + #[test] + fn an_unfinished_sibling_cannot_excuse_a_completed_item_mismatch() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"cmd","type":"commandExecution","command":"pwd","status":"inProgress","aggregatedOutput":""}), + false, + ); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"actual completed output"}), + true, + ); + ended(&mut p, "t", "completed"); + let native = json!([{"id":"t","status":"completed","itemsView":"full","items":[ + {"id":"cmd","type":"commandExecution","command":"pwd","status":"completed","aggregatedOutput":"/work"}, + {"id":"answer","type":"agentMessage","text":"different unexplained output"} + ]}]); + let mut view = snapshot(&mut p, native.clone()); + for _ in 1..MAX_SAVE_READS { + view = snapshot(&mut p, native.clone()); + } + assert_eq!(view.metadata["nativeFinalTurnIds"], json!([])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][1]["text"], + "actual completed output" + ); + } + + #[test] + fn indexed_reasoning_command_progress_and_file_patch_updates_keep_native_item_identity() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"r","type":"reasoning","summary":[],"content":[]}), + false, + ); + for (index, text) in [(1, "思"), (0, "🙂"), (1, "考")] { + p.apply("item/reasoning/summaryTextDelta",&json!({"threadId":"thread-1","turnId":"t","itemId":"r","summaryIndex":index,"delta":text})).unwrap(); + } + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["summary"], + json!(["🙂", "思考"]) + ); + item( + &mut p, + "t", + json!({"id":"cmd","type":"commandExecution","command":"echo hi","status":"inProgress"}), + false, + ); + let ops = p + .apply( + "item/commandExecution/outputDelta", + &json!({"threadId":"thread-1","turnId":"t","itemId":"cmd","delta":"live output"}), + ) + .unwrap(); + assert_eq!(ops[0]["text"], "live output"); + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][0]["output"], + "live output" + ); + item( + &mut p, + "t", + json!({"id":"files","type":"fileChange","status":"inProgress","changes":[]}), + false, + ); + for diff in ["+first", "+replacement"] { + p.apply("item/fileChange/patchUpdated",&json!({"threadId":"thread-1","turnId":"t","itemId":"files","changes":[{"path":"a","diff":diff}]})).unwrap(); + } + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][1]["changes"][0]["diff"], + "+replacement" + ); + p.apply( + "item/fileChange/outputDelta", + &json!({"threadId":"thread-1","turnId":"t","itemId":"files","delta":"Applying patch"}), + ) + .unwrap(); + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][1]["output"], + "Applying patch" + ); + } + + #[test] + fn usage_plan_diff_are_latest_aggregates_and_have_no_invented_context_or_cost() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + p.apply("thread/tokenUsage/updated",&json!({"threadId":"thread-1","turnId":"t","tokenUsage":{"last":{"totalTokens":20,"inputTokens":15,"outputTokens":5,"reasoningOutputTokens":3},"total":{"totalTokens":200,"inputTokens":150,"cachedInputTokens":80,"outputTokens":50},"modelContextWindow":258400}})).unwrap(); + for text in ["old", "latest"] { + p.apply("turn/plan/updated",&json!({"threadId":"thread-1","turnId":"t","explanation":text,"plan":[{"step":text,"status":"inProgress"}]})).unwrap(); + p.apply( + "turn/diff/updated", + &json!({"threadId":"thread-1","turnId":"t","diff":text}), + ) + .unwrap(); + } + let mut view = + json!({"extensions":{"codex":{}},"tokenUsage":{},"diffs":[],"capabilities":{}}); + p.overlay(&mut view, json!({})); + assert_eq!(view["tokenUsage"]["totalTokens"], 200); + assert_eq!( + view["extensions"]["codex"]["usage"]["last"]["totalTokens"], + 20 + ); + assert!(view["tokenUsage"].get("contextTokens").is_none()); + assert!(view["tokenUsage"].get("costUsd").is_none()); + assert_eq!(view["extensions"]["codex"]["plan"]["explanation"], "latest"); + assert_eq!( + view["diffs"], + json!([{"id":"codex-turn:t","title":"Turn changes","diff":"latest"}]) + ); + } + + #[test] + fn emitted_delta_bytes_grow_linearly_and_batch_adjacent_fields() { + fn run(count: usize) -> (usize, String) { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":""}), + false, + ); + let mut bytes = 0; + for _ in 0..count { + let ops = delta(&mut p, "t", "🙂"); + bytes += serde_json::to_vec(&ops).unwrap().len(); + p.enqueue(ops); + } + let batch = p.take_patch().unwrap(); + assert_eq!(batch["ops"].as_array().unwrap().len(), 1); + assert_eq!(batch["ops"][0]["text"], "🙂".repeat(count)); + ( + bytes, + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"] + .as_str() + .unwrap() + .to_owned(), + ) + } + let (a, text) = run(1000); + let (b, other) = run(2000); + assert_eq!(text.len() * 2, other.len()); + assert!(b > a * 19 / 10 && b < a * 21 / 10); + } + + #[tokio::test] + async fn native_frames_reach_real_consumer_before_completion_with_thread_isolation_and_mcp_progress( + ) { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let mut rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-consumer", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"t","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"agentMessage","id":"answer","text":""}})); + peer.emit_notification( + "item/agentMessage/delta", + json!({"threadId":"thread-1","turnId":"t","itemId":"answer","delta":"Hel"}), + ); + peer.emit_notification("item/agentMessage/delta",json!({"threadId":"foreign","turnId":"t","itemId":"answer","delta":"SECRET_OTHER_THREAD"})); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"commandExecution","id":"cmd","command":"echo progress","status":"inProgress"}})); + peer.emit_notification( + "item/commandExecution/outputDelta", + json!({"threadId":"thread-1","turnId":"t","itemId":"cmd","delta":"tool progress"}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"mcpToolCall","id":"mcp","server":"fixture","tool":"probe","status":"inProgress","arguments":{}}})); + peer.emit_notification( + "item/mcpToolCall/progress", + json!({"threadId":"thread-1","turnId":"t","itemId":"mcp","message":"halfway"}), + ); + peer.emit_notification("error",json!({"threadId":"thread-1","turnId":"t","error":{"message":"temporary failure"},"willRetry":true})); + peer.emit_notification("error",json!({"threadId":"thread-1","turnId":"t","error":{"message":"provider notice"},"willRetry":false})); + let mut frames = vec![]; + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + let end = frame["event"]["type"] == "freshAgent.notice"; + frames.push(frame); + if end { + break; + } + } + }) + .await + .expect("consumer flushes before notice without a turn completion"); + let ops: Vec<_> = frames + .iter() + .flat_map(|f| f["event"]["ops"].as_array().into_iter().flatten()) + .collect(); + assert!(ops.iter().any(|op| op["text"] == "Hel")); + assert!(ops.iter().any(|op| op["text"] == "tool progress")); + assert!(ops.iter().any(|op| op["item"]["progress"] == "halfway")); + assert!(frames + .iter() + .any(|f| f["event"]["type"] == "freshAgent.retry")); + assert!(!serde_json::to_string(&frames) + .unwrap() + .contains("SECRET_OTHER_THREAD")); + assert!(!frames + .iter() + .any(|f| f["event"]["type"] == "freshAgent.turn.complete")); + // The in-memory transport's recv owns its mutex while awaiting a + // frame. End the scripted peer before close so cleanup can acquire it. + drop(peer); + state.shutdown().await; + } + + #[tokio::test] + async fn snapshot_rpc_held_across_history_mutation_is_reread_without_resuming_a_provider() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let _rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-read-fence", + ) + .await; + let reader = state.clone(); + let snapshot = tokio::spawn(async move { reader.get_snapshot("thread-1", None).await }); + let init = peer.expect_request().await; + assert_eq!(init.1, "initialize"); + peer.respond(&init.0,json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + assert_eq!(peer.expect_notification().await.0, "initialized"); + let request = peer.expect_request().await; + assert_eq!(request.1, "thread/read"); + assert_eq!(request.2["includeTurns"], true); + state + .sessions + .lock() + .await + .get("thread-1") + .unwrap() + .projection + .lock() + .unwrap() + .invalidate_history(&["removed".into()]); + peer.respond(&request.0,history(json!([{"id":"removed","status":"completed","itemsView":"full","items":[{"id":"a","type":"agentMessage","text":"must disappear"}]}]))); + let reread = peer.expect_request().await; + assert_eq!(reread.1, "thread/read"); + peer.respond(&reread.0, history(json!([]))); + let result = snapshot.await.unwrap().unwrap(); + assert_eq!(result["turns"], json!([])); + assert_eq!( + result["extensions"]["codex"]["projection"]["historyGeneration"], + 1 + ); + drop(peer); + state.shutdown().await; + } + + #[test] + fn a_native_preview_cannot_reopen_an_already_ended_operation() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + snapshot(&mut p, json!([{"id":"t","status":"inProgress","items":[]}])); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"final"}), + true, + ); + ended(&mut p, "t", "completed"); + started(&mut p, "t"); + assert!(item( + &mut p, + "t", + json!({"id":"late","type":"agentMessage","text":"old"}), + false + ) + .is_empty()); + } + + #[test] + fn cold_saved_rollout_explains_interrupted_output_without_a_provider_runtime() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("rollout.jsonl"); + let rows = [ + json!({"type":"session_meta","payload":{"id":"thread-1"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"t"}}), + json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}), + json!({"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"t","reason":"interrupted"}}), + ]; + std::fs::write( + &file, + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + let saved = super::super::native_history::read_rollout(&file, "thread-1").unwrap(); + assert_eq!( + saved["turns"][0]["outputState"], + json!({"provenance":"saved","diagnostic":"interrupted","refreshPending":false}) + ); + assert_eq!(saved["tokenUsage"]["observed"], false); + } + + #[test] + fn read_unavailability_preserves_retained_output_and_reports_typed_status() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "retained"); + p.freeze_active("interrupted"); + let view = p.unavailable_history(&p.begin_history_read()).unwrap(); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "retained" + ); + assert_eq!( + view.metadata["availability"], + json!({"status":"unavailable","retryable":true,"code":"CODEX_HISTORY_UNAVAILABLE"}) + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + } + + #[tokio::test] + async fn snapshot_cuts_pending_batch_before_capturing_its_event_prefix() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let mut rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-batch-fence", + ) + .await; + let projection = { + let sessions = state.sessions.lock().await; + let session = sessions.get("thread-1").unwrap(); + session.consumer.abort(); + session.projection.clone() + }; + // The consumer's owned batching state is seeded directly so a timer + // cannot accidentally flush the regression before the read captures it. + { + let mut p = projection.lock().unwrap(); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + let ops = delta(&mut p, "t", "Hel"); + p.enqueue(ops); + } + let reader = state.clone(); + let snapshot = tokio::spawn(async move { reader.get_snapshot("thread-1", None).await }); + let init = peer.expect_request().await; + assert_eq!(init.1, "initialize"); + peer.respond(&init.0,json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + peer.expect_notification().await; + let request = peer.expect_request().await; + assert_eq!(request.1, "thread/read"); + { + let mut p = projection.lock().unwrap(); + let ops = delta(&mut p, "t", "lo"); + p.enqueue(ops); + } + peer.respond( + &request.0, + history(json!([{"id":"t","status":"inProgress","itemsView":"full","items":[]}])), + ); + let view = snapshot.await.unwrap().unwrap(); + assert_eq!(view["turns"][0]["items"][0]["text"], "Hel"); + let pending = projection.lock().unwrap().take_patch().unwrap(); + assert_eq!( + pending["ops"][0]["text"], "lo", + "the captured Hel must not be appended again by a coalesced Hello" + ); + let published: Value = serde_json::from_str( + &tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(published["event"]["ops"][0]["text"], "Hel"); + drop(peer); + state.shutdown().await; + } + + #[test] + fn missing_older_output_stays_before_a_later_native_turn_after_its_payload_is_released() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "older"); + item( + &mut p, + "older", + json!({"id":"a","type":"agentMessage","text":"older unsaved"}), + true, + ); + ended(&mut p, "older", "completed"); + started(&mut p, "later"); + item( + &mut p, + "later", + json!({"id":"b","type":"agentMessage","text":"later saved"}), + true, + ); + ended(&mut p, "later", "completed"); + let native = json!([{"id":"later","status":"completed","itemsView":"full","items":[{"id":"b","type":"agentMessage","text":"later saved"}]}]); + for _ in 0..4 { + let view = snapshot(&mut p, native.clone()); + let ids: Vec<_> = view.raw["thread"]["turns"] + .as_array() + .unwrap() + .iter() + .map(|turn| turn["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["older", "later"]); + } + } + + #[test] + fn native_text_delta_then_full_item_is_one_exact_item() { + let mut p = CodexProjection::new("runtime-1".into(), "thread-1".into()); + p.apply("item/started", &json!({"threadId":"thread-1","turnId":"turn-1", "item":{"type":"agentMessage","id":"answer-1","text":""}})).unwrap(); + let ops = p + .apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"Hel"}), + ) + .unwrap(); + assert!(ops.iter().any(|op| op["text"] == "Hel")); + p.apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"lo"}), + ) + .unwrap(); + p.apply("item/completed", &json!({"threadId":"thread-1","turnId":"turn-1", "item":{"type":"agentMessage","id":"answer-1","text":"Hello"}})).unwrap(); + let rows = p.snapshot_rows("turn-1").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0]["items"], + json!([{"id":"answer-1","kind":"text","text":"Hello"}]) + ); + assert!(p + .apply( + "item/agentMessage/delta", + &json!({"threadId":"other","turnId":"turn-1","itemId":"answer-1","delta":"leak"}) + ) + .unwrap() + .is_empty()); + } + + #[test] + fn actual_0164_captures_converge_legacy_duplicates_tool_outcomes_and_bounded_native_results() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-projection-0.164.json" + )) + .unwrap(); + for case in corpus["cases"].as_array().unwrap() { + let native = &case["read"]; + let thread = native["thread"]["id"].as_str().unwrap(); + let mut p = CodexProjection::new("actual-capture".into(), thread.into()); + for notification in case["notifications"].as_array().unwrap() { + p.apply( + notification["method"].as_str().unwrap(), + ¬ification["params"], + ) + .unwrap(); + } + let saved = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + if case["scenario"] == "approval-policy-restricted-exec" { + // The first PTY finishes after its turn and is persisted after + // the answer. A later running PTY is absent from full native + // persistence. Keep that omitted body as finite retained data. + p.accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let finite = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let turns = native["thread"]["turns"].as_array().unwrap(); + for index in [0, 1, 3] { + assert_eq!(finite.raw["thread"]["turns"][index], turns[index]); + } + let omitted = &turns[2]; + let state = &finite.metadata["turnOutputStates"][omitted["id"].as_str().unwrap()]; + assert_eq!(state["diagnostic"], "unsaved"); + assert_eq!(state["refreshPending"], false); + assert_eq!( + state["savedTurns"], + json!(super::super::build_codex_turn_json(omitted, 0).unwrap()) + ); + assert!(finite.raw["thread"]["turns"][2]["items"] + .as_array() + .unwrap() + .iter() + .any(|item| item["type"] == "commandExecution")); + assert_eq!( + finite.metadata["nativeFinalTurnIds"] + .as_array() + .unwrap() + .len(), + 3 + ); + continue; + } + if case["scenario"] == "mcp-form-url" && case["parameters"]["historyMode"] == "legacy" { + // This actual Legacy read omits the command altogether while + // preserving the full MCP result. It cannot agree with the + // richer event turn. Retain it with its exact saved view and + // settle finitely, rather than inventing truncation/equality. + p.accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let finite = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let turn = &native["thread"]["turns"][0]; + let state = &finite.metadata["turnOutputStates"][turn["id"].as_str().unwrap()]; + assert_eq!(state["diagnostic"], "unsaved"); + assert_eq!(state["refreshPending"], false); + assert!( + state["savedTurns"] + == json!(super::super::build_codex_turn_json(turn, 0).unwrap()), + "the native saved representation is preserved exactly" + ); + assert!( + finite.raw["thread"]["turns"][0]["items"] + .as_array() + .unwrap() + .iter() + .any(|item| item["type"] == "commandExecution"), + "richer retained command output remains available" + ); + continue; + } + assert!( + saved.raw["thread"]["turns"] == native["thread"]["turns"], + "actual saved representation: {}", + case["scenario"] + ); + assert_eq!( + saved.metadata["nativeFinalTurnIds"] + .as_array() + .unwrap() + .len(), + native["thread"]["turns"].as_array().unwrap().len(), + "all captured terminal native groups are final: {}", + case["scenario"] + ); + if case["parameters"]["largeOutput"] == true { + let states = saved.metadata["turnOutputStates"].as_object().unwrap(); + assert!(states + .values() + .any(|state| state["diagnostic"] == "provider-truncated" + && state["refreshPending"] == false + && state["retainedTurns"].is_array())); + } + } + } +} diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index 15166df97..b32c58bce 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -41,6 +41,8 @@ export const FreshAgentCapabilitiesSchema = z.object({ }).strict() export const FreshAgentTokenUsageSchema = z.object({ + observed: z.boolean().optional(), + modelContextWindow: z.number().int().nonnegative().optional(), inputTokens: z.number().int().nonnegative(), outputTokens: z.number().int().nonnegative(), cachedTokens: z.number().int().nonnegative().optional(), @@ -113,6 +115,7 @@ export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ z.object({ id: z.string().min(1), kind: z.literal('file_change'), + output: z.string().optional(), status: z.enum(['running', 'completed', 'failed', 'declined']), changes: z.array(z.record(z.string(), z.unknown())), extensions: z.record(z.string(), z.unknown()).optional(), @@ -120,6 +123,7 @@ export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ z.object({ id: z.string().min(1), kind: z.literal('mcp_tool'), + progress: z.string().optional(), server: z.string(), tool: z.string(), status: z.enum(['running', 'completed', 'failed']), @@ -226,6 +230,13 @@ export const FreshAgentTurnSchema = z.object({ messageId: z.string().min(1).optional(), ordinal: z.number().int().nonnegative().optional(), source: z.enum(['durable', 'live', 'server']).optional(), + outputState: z.object({ + provenance: z.enum(['saved', 'retained', 'saved-and-retained', 'preview']), + diagnostic: z.enum(['interrupted', 'unsaved', 'provider-truncated']).optional(), + refreshPending: z.boolean(), + retainedItems: z.array(FreshAgentTranscriptItemSchema).optional(), + savedItems: z.array(FreshAgentTranscriptItemSchema).optional(), + }).strict().optional(), role: z.enum(['user', 'assistant', 'system', 'tool']).optional(), timestamp: z.string().optional(), model: z.string().optional(), @@ -261,6 +272,23 @@ export const FreshAgentTurnSchema = z.object({ restorable: z.boolean().optional(), }).strict() +const EventRevisionSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +const TranscriptPatchBase = { revision: EventRevisionSchema, nativeTurnId: z.string().min(1) } +export const FreshAgentTranscriptPatchSchema = z.object({ + type: z.literal('freshAgent.transcript.patch').optional(), + sessionId: z.string().min(1), + projectionId: z.string().min(1), + historyGeneration: EventRevisionSchema, + ops: z.array(z.discriminatedUnion('op', [ + z.object({ ...TranscriptPatchBase, op: z.literal('resetTurn'), turns: z.array(FreshAgentTurnSchema), completedItemIds: z.array(z.string()).optional(), nonAppendableItemIds: z.array(z.string()).optional() }).strict(), + z.object({ ...TranscriptPatchBase, op: z.literal('upsertItem'), row: FreshAgentTurnSchema, item: FreshAgentTranscriptItemSchema, complete: z.boolean().optional() }).strict(), + z.object({ ...TranscriptPatchBase, op: z.literal('appendItem'), rowId: z.string().min(1), itemId: z.string().min(1), field: z.enum(['text', 'output', 'summary', 'content']), index: z.number().int().nonnegative().max(1023).nullable().optional(), text: z.string() }).strict(), + z.object({ ...TranscriptPatchBase, op: z.literal('settleTurn'), status: z.string().optional() }).strict(), + ])), +}).strict() + +export type FreshAgentTranscriptPatch = z.infer + // Claude's permission update (the SDK's `PermissionUpdate`): what the CLI // suggests on an approval, and what "Allow and don't ask again" sends back as // `updatedPermissions`. Mirrors the server's normalizer @@ -335,6 +363,7 @@ export const FreshAgentDiffSummarySchema = z.object({ path: z.string().optional(), title: z.string().optional(), status: z.string().optional(), + diff: z.string().optional(), }).strict() export const FreshAgentChildThreadSchema = z.object({ @@ -444,6 +473,7 @@ export const FRESH_AGENT_CONTRACT_SCHEMA_NAMES = [ 'FreshAgentSettingsSchema', 'FreshAgentTranscriptItemSchema', 'FreshAgentTurnSchema', + 'FreshAgentTranscriptPatchSchema', 'FreshAgentPendingApprovalSchema', 'FreshAgentPendingQuestionSchema', 'FreshAgentWorktreeSchema', diff --git a/src/components/fresh-agent/FreshAgentDiffPanel.tsx b/src/components/fresh-agent/FreshAgentDiffPanel.tsx index 75d005f66..515b59449 100644 --- a/src/components/fresh-agent/FreshAgentDiffPanel.tsx +++ b/src/components/fresh-agent/FreshAgentDiffPanel.tsx @@ -3,7 +3,7 @@ import { ChevronRight, MessageSquarePlus } from 'lucide-react' import { api, ApiError } from '@/lib/api' import { cn } from '@/lib/utils' -type DiffSummary = { id: string; path?: string; title?: string; status?: string } +type DiffSummary = { id: string; path?: string; title?: string; status?: string; diff?: string } /** * A 404 from /api/fresh-agent/diff means the connected server predates the @@ -58,7 +58,8 @@ function FreshAgentFileDiff({ }, [diff, loading]) const label = summary.title ?? summary.path ?? summary.id - const lines = diff !== null && diff.trim() ? diff.split('\n') : null + const shownDiff = summary.diff ?? diff + const lines = shownDiff !== null && shownDiff.trim() ? shownDiff.split('\n') : null return (
@@ -69,7 +70,7 @@ function FreshAgentFileDiff({ aria-label={`Diff: ${label}`} onClick={() => { setExpanded((value) => !value) - if (!expanded && cwd && summary.path) load(cwd, summary.path) + if (!expanded && summary.diff === undefined && cwd && summary.path) load(cwd, summary.path) }} > @@ -78,8 +79,8 @@ function FreshAgentFileDiff({ {expanded ? (
- {loading ?
Loading diff…
: null} - {error ? ( + {summary.diff === undefined && loading ?
Loading diff…
: null} + {summary.diff === undefined && error ? (
{error}{' '} {cwd && summary.path ? ( @@ -96,15 +97,15 @@ function FreshAgentFileDiff({ ) : null}
) : null} - {!cwd || !summary.path ? ( + {summary.diff === undefined && (!cwd || !summary.path) ? (
Diff unavailable for this file.
) : null} - {!loading && !error && lines === null && diff !== null ? ( -
No uncommitted changes for this file.
+ {(summary.diff !== undefined || (!loading && !error)) && lines === null && shownDiff !== null ? ( +
{summary.diff !== undefined ? 'No changes in this turn.' : 'No uncommitted changes for this file.'}
) : null} {lines?.map((line, index) => { const kind = classifyLine(line) - if (kind === 'meta') return null + if (kind === 'meta' && summary.diff === undefined) return null return (
} + +export function readCodexPlan(raw: unknown): CodexPlan | undefined { + if (!raw || typeof raw !== 'object') return undefined + const value = raw as { explanation?: unknown; plan?: unknown } + if (!Array.isArray(value.plan) || !value.plan.every((step) => step && typeof step.step === 'string' && ['pending', 'inProgress', 'completed'].includes(step.status))) return undefined + return { plan: value.plan, ...(typeof value.explanation === 'string' ? { explanation: value.explanation } : {}) } +} + export function FreshAgentSidebar({ worktrees, childThreads, codexReview, codexFork, + codexPlan, + codexUsage, }: { worktrees: Array<{ id: string; path: string; branch?: string }> childThreads: Array<{ id: string; threadId: string; origin?: string; title?: string }> codexReview?: { id?: string; status?: string } codexFork?: { parentThreadId?: string } + codexPlan?: CodexPlan + codexUsage?: CodexLatestUsage }) { if ( worktrees.length === 0 && childThreads.length === 0 && !codexReview && !codexFork + && !codexPlan + && !codexUsage ) return null return (
@@ -5013,6 +5078,7 @@ export function FreshAgentView({ isRestoring, landedTranscript, liveSessionKey, + codexLiveMetadata, liveTailActive, loadError, managedRecoveryPending, diff --git a/src/components/fresh-agent/fresh-agent-event-classes.ts b/src/components/fresh-agent/fresh-agent-event-classes.ts index e1132df28..3f74e3468 100644 --- a/src/components/fresh-agent/fresh-agent-event-classes.ts +++ b/src/components/fresh-agent/fresh-agent-event-classes.ts @@ -39,6 +39,7 @@ export const TRANSCRIPT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = n 'freshAgent.error', 'freshAgent.assistant', 'freshAgent.stream', + 'freshAgent.transcript.patch', 'freshAgent.session.rolledBack', 'freshAgent.session.redone', 'freshAgent.rolledBack', diff --git a/src/lib/fresh-agent-context-usage.ts b/src/lib/fresh-agent-context-usage.ts index ee01526be..29eaa21f2 100644 --- a/src/lib/fresh-agent-context-usage.ts +++ b/src/lib/fresh-agent-context-usage.ts @@ -14,6 +14,21 @@ export type FreshAgentContextUsage = { thresholdTokens: number } +export type CodexLatestUsage = { observed: boolean; latestTokens?: number; totalTokens?: number; modelContextWindow?: number } + +/** Native last is a measured sample, not demonstrated current context occupancy. */ +export function latestCodexUsage(raw: unknown): CodexLatestUsage { + if (!raw || typeof raw !== 'object') return { observed: false } + const usage = raw as { last?: { totalTokens?: unknown }; total?: { totalTokens?: unknown }; modelContextWindow?: unknown } + const latestTokens = usage.last?.totalTokens + const totalTokens = usage.total?.totalTokens + if (typeof latestTokens !== 'number' || !Number.isSafeInteger(latestTokens) || latestTokens < 0 || typeof totalTokens !== 'number' || !Number.isSafeInteger(totalTokens) || totalTokens < 0) return { observed: false } + const window = usage.modelContextWindow + return { observed: true, latestTokens, totalTokens, + ...(typeof window === 'number' && Number.isSafeInteger(window) && window >= 0 ? { modelContextWindow: window } : {}), + } +} + export function findIndexedSessionById( projects: ProjectGroup[], provider: CodingCliProviderName, diff --git a/src/lib/fresh-agent-transcript-projection.ts b/src/lib/fresh-agent-transcript-projection.ts new file mode 100644 index 000000000..1c3a1bf67 --- /dev/null +++ b/src/lib/fresh-agent-transcript-projection.ts @@ -0,0 +1,339 @@ +import { FreshAgentTranscriptPatchSchema, type FreshAgentSnapshot, type FreshAgentTranscriptItem, type FreshAgentTurn, type FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' + +export type TranscriptProjection = { + id: string + historyGeneration: number + eventRevision: number + turns: FreshAgentTurn[] + turnRows?: Record + closedItems?: Record + settledTurns?: Record + removedTurns?: Record + frozenTurns?: Record + turnRevisions?: Record + previewTurns?: Record + deferredOps?: FreshAgentTranscriptPatch['ops'] + deferredBytes?: number + needsResnapshot?: boolean + sessionId?: string + turnOrder?: string[] + nonAppendableItemIds?: Record +} + +const itemKey = (turnId: string, itemId: string) => JSON.stringify([turnId, itemId]) + +/** Read-only recovery context may coexist with event-owned append baselines. */ +export function applyFreshAgentTranscriptPatch(state: TranscriptProjection, input: unknown): TranscriptProjection { + const parsed = FreshAgentTranscriptPatchSchema.safeParse(input) + if (!parsed.success) return state + const patch = parsed.data + if (patch.projectionId !== state.id || patch.historyGeneration < state.historyGeneration) return state + const next: TranscriptProjection = patch.historyGeneration > state.historyGeneration + ? { id: state.id, historyGeneration: patch.historyGeneration, eventRevision: state.eventRevision, turns: [], turnRows: {}, closedItems: {}, settledTurns: {}, removedTurns: {}, turnRevisions: {}, deferredOps: [] } + : { ...state, turns: [...state.turns], turnRows: { ...state.turnRows }, closedItems: { ...state.closedItems }, settledTurns: { ...state.settledTurns }, removedTurns: { ...state.removedTurns }, turnRevisions: { ...state.turnRevisions }, deferredOps: [...state.deferredOps ?? []], turnOrder: [...state.turnOrder ?? []], nonAppendableItemIds: { ...state.nonAppendableItemIds } } + next.nonAppendableItemIds ??= {} + next.sessionId = patch.sessionId + for (const op of patch.ops) { + if (op.revision <= next.eventRevision) continue + next.eventRevision = op.revision + const nativeId = op.nativeTurnId + next.turnRevisions![nativeId] = op.revision + if (op.op === 'settleTurn') { + next.settledTurns![nativeId] = true + if (op.status === 'interrupted') { + const ids = new Set(next.turnRows![nativeId] ?? []) + next.turns = next.turns.map((row) => ids.has(row.turnId) ? { ...row, outputState: { provenance: 'retained', diagnostic: 'interrupted', refreshPending: false } } : row) + } + next.deferredOps = next.deferredOps?.filter((op) => op.nativeTurnId !== nativeId) + const prefix = JSON.stringify([nativeId]).slice(0, -1) + ',' + for (const key of Object.keys(next.closedItems!)) if (key.startsWith(prefix)) delete next.closedItems![key] + trimSettledTurns(next) + continue + } + if (next.removedTurns![nativeId]) continue + if (op.op === 'resetTurn') { + if (next.settledTurns![nativeId] && op.turns.length > 0) continue + next.turnOrder ??= [] + if (!next.turnOrder.includes(nativeId)) next.turnOrder.push(nativeId) + const previousIds = new Set(next.turnRows![nativeId] ?? []) + const insertion = next.turns.findIndex((row) => previousIds.has(row.turnId)) + next.turns = next.turns.filter((row) => !previousIds.has(row.turnId)) + next.turns.splice(insertion < 0 ? next.turns.length : insertion, 0, ...op.turns) + next.turnRows![nativeId] = op.turns.map((row) => row.turnId) + next.nonAppendableItemIds[nativeId] = op.nonAppendableItemIds ?? [] + if (op.turns.length === 0) next.removedTurns![nativeId] = true + for (const id of op.completedItemIds ?? []) next.closedItems![itemKey(nativeId, id)] = true + continue + } + if (next.settledTurns![nativeId]) continue + if (op.op === 'upsertItem') { + if (!next.turnRows![nativeId]) { + if (op.complete && next.previewTurns?.[nativeId]) next.turnRows![nativeId] = [] + else { deferOp(next, op); continue } + } + if (next.closedItems![itemKey(nativeId, op.item.id)]) continue + let index = next.turns.findIndex((row) => row.turnId === op.row.turnId) + if (index < 0) { + next.turns.push(op.row) + index = next.turns.length - 1 + next.turnRows![nativeId].push(op.row.turnId) + } + const row = next.turns[index] + const items = [...row.items] + const itemIndex = items.findIndex((item) => item.id === op.item.id) + if (itemIndex < 0) items.push(op.item) + else items[itemIndex] = op.item + next.turns[index] = { ...op.row, items } + next.nonAppendableItemIds[nativeId] = (next.nonAppendableItemIds[nativeId] ?? []).filter((id) => id !== op.item.id) + if (op.complete) next.closedItems![itemKey(nativeId, op.item.id)] = true + continue + } + if (next.closedItems![itemKey(nativeId, op.itemId)] || next.nonAppendableItemIds[nativeId]?.includes(op.itemId)) continue + if (!next.turnRows![nativeId]?.includes(op.rowId)) { deferOp(next, op); continue } + const rowIndex = next.turns.findIndex((row) => row.turnId === op.rowId) + if (rowIndex < 0) continue + const row = next.turns[rowIndex] + const index = row.items.findIndex((item) => item.id === op.itemId) + if (index < 0) continue + const item = row.items[index] + let updated: FreshAgentTranscriptItem | undefined + if (op.field === 'text' && (item.kind === 'text' || item.kind === 'thinking')) updated = { ...item, text: item.text + op.text } + else if (op.field === 'output' && (item.kind === 'command' || item.kind === 'file_change')) updated = { ...item, output: (item.output ?? '') + op.text } + else if ((op.field === 'summary' || op.field === 'content') && item.kind === 'reasoning' && typeof op.index === 'number') { + const parts = [...item[op.field]] + while (parts.length <= op.index) parts.push('') + parts[op.index] += op.text + const summary = op.field === 'summary' ? parts : item.summary + const content = op.field === 'content' ? parts : item.content + updated = { ...item, [op.field]: parts, text: (summary.length > 0 ? summary : content).join('\n') } + } + if (updated) { + const items = [...row.items]; items[index] = updated + next.turns[rowIndex] = { ...row, items } + } + } + return next +} + +function trimSettledTurns(state: TranscriptProjection): void { + const groups = Object.entries(state.turnRows ?? {}).filter(([id]) => state.settledTurns?.[id]) + .map(([id, ids]) => ({ id, ids: new Set(ids), revision: state.turnRevisions?.[id] ?? 0 })) + .sort((a, b) => a.revision - b.revision) + let bytes = groups.reduce((sum, group) => sum + JSON.stringify(state.turns.filter((row) => group.ids.has(row.turnId))).length, 0) + let count = groups.length + for (const group of groups) { + if (count <= 32 && bytes <= 8 * 1024 * 1024) break + bytes -= JSON.stringify(state.turns.filter((row) => group.ids.has(row.turnId))).length + state.turns = state.turns.filter((row) => !group.ids.has(row.turnId)) + delete state.turnRows![group.id] + count -= 1 + } +} + +export function freezeTranscriptProjection(state: TranscriptProjection): TranscriptProjection { + const interrupted = new Set() + for (const [id, rows] of Object.entries(state.turnRows ?? {})) { + if (!state.settledTurns?.[id]) for (const row of rows) interrupted.add(row) + } + const next: TranscriptProjection = { ...state, closedItems: {}, deferredOps: [], deferredBytes: 0, settledTurns: { ...state.settledTurns }, + turns: state.turns.map((row) => ({ ...row, outputState: { provenance: 'retained', diagnostic: interrupted.has(row.turnId) ? 'interrupted' : row.outputState?.diagnostic ?? 'unsaved', refreshPending: false } })), + } + for (const id of Object.keys(state.turnRows ?? {})) next.settledTurns![id] = true + trimSettledTurns(next) + return next +} + +// A reconnect can receive a delta during its first history RPC. Keep only +// these short-lived, unbased operations until an authoritative event prefix +// arrives. They never seed from a native preview or survive a terminal fence. +function deferOp(state: TranscriptProjection, op: FreshAgentTranscriptPatch['ops'][number]): void { + const ops = state.deferredOps ??= [] + const bytes = JSON.stringify(op).length + if (ops.length < 1024 && (state.deferredBytes ?? 0) + bytes <= 64 * 1024) { + ops.push(op) + state.deferredBytes = (state.deferredBytes ?? 0) + bytes + } else state.needsResnapshot = true +} + +export type ProjectionMetadata = { + id: string + eventRevision: number + historyGeneration: number + eventOwnedTurnIds: string[] + nativePreviewTurnIds: string[] + nativeFinalTurnIds: string[] + turnRows: Record + completedItems?: Record + nonAppendableItemIds?: Record + settledTurnIds?: string[] + turnOutputStates: Record +} + +export function bindTranscriptProjection(previous: TranscriptProjection | undefined, id: string, historyGeneration: number): TranscriptProjection { + if (previous?.id === id && previous.historyGeneration >= historyGeneration) return previous + const frozenTurns = previous && previous.id !== id + ? { ...previous.frozenTurns, ...Object.fromEntries(Object.entries(previous.turnRows ?? {}).map(([id, ids]) => [id, previous.turns.filter((row) => ids.includes(row.turnId))])) } + : undefined + if (frozenTurns) { + const entries = Object.entries(frozenTurns) + let bytes = entries.reduce((sum, [, rows]) => sum + JSON.stringify(rows).length, 0) + while (entries.length > 32 || bytes > 8 * 1024 * 1024) { + const oldest = entries.shift() + if (!oldest) break + bytes -= JSON.stringify(oldest[1]).length + delete frozenTurns[oldest[0]] + } + } + return { id, historyGeneration, eventRevision: 0, turns: [], turnRows: {}, sessionId: previous?.sessionId, ...(frozenTurns ? { frozenTurns } : {}) } +} + +const metadataCache = new WeakMap() + +export function readTranscriptProjection(snapshot: FreshAgentSnapshot): ProjectionMetadata | undefined { + const raw = snapshot.extensions?.codex?.projection + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const cached = metadataCache.get(raw) + if (cached) return cached + const value = raw as ProjectionMetadata + if (typeof value.id !== 'string' || !Number.isSafeInteger(value.eventRevision) || !Number.isSafeInteger(value.historyGeneration) + || ![value.eventOwnedTurnIds, value.nativePreviewTurnIds, value.nativeFinalTurnIds].every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === 'string')) + || !value.turnOutputStates || typeof value.turnOutputStates !== 'object' || Array.isArray(value.turnOutputStates)) return undefined + const ids = [...value.eventOwnedTurnIds, ...value.nativePreviewTurnIds, ...value.nativeFinalTurnIds] + const normalized = value.turnRows && typeof value.turnRows === 'object' && !Array.isArray(value.turnRows) ? value : { + ...value, + turnRows: Object.fromEntries(ids.map((id) => [id, snapshot.turns.filter((row) => rowBelongsToNativeTurn(row, id)).map((row) => row.turnId)])), + } + metadataCache.set(raw, normalized) + return normalized +} + +function rowBelongsToNativeTurn(row: FreshAgentTurn, id: string): boolean { + return row.nativeTurnId === id || row.turnId === id || (row.turnId.startsWith(`${id}:row-`) && /^\d+$/.test(row.turnId.slice(`${id}:row-`.length))) +} + +export function hydrateTranscriptProjection(state: TranscriptProjection, snapshot: FreshAgentSnapshot): TranscriptProjection { + const metadata = readTranscriptProjection(snapshot) + if (!metadata || metadata.id !== state.id || metadata.historyGeneration !== state.historyGeneration) return state + let next: TranscriptProjection = { ...state, turns: [...state.turns], turnRows: { ...state.turnRows }, turnRevisions: { ...state.turnRevisions }, closedItems: { ...state.closedItems }, settledTurns: { ...state.settledTurns }, nonAppendableItemIds: { ...state.nonAppendableItemIds }, previewTurns: Object.fromEntries(metadata.nativePreviewTurnIds.map((id) => [id, true as const])) } + for (const [id, items] of Object.entries(metadata.completedItems ?? {})) { + for (const item of items) next.closedItems![itemKey(id, item)] = true + } + for (const id of metadata.settledTurnIds ?? []) next.settledTurns![id] = true + for (const id of metadata.eventOwnedTurnIds) { + if ((state.turnRevisions?.[id] ?? 0) > metadata.eventRevision && (state.turnRows?.[id]?.length ?? 0) > 0) { + const readOnly = new Set(state.nonAppendableItemIds?.[id] ?? []) + const rows = new Set(metadata.turnRows[id] ?? []) + const saved = new Map(snapshot.turns.filter((row) => rows.has(row.turnId)).flatMap((row) => row.items.map((item) => [item.id, item] as const))) + const currentRows = new Set(state.turnRows?.[id] ?? []) + next.turns = next.turns.map((row) => currentRows.has(row.turnId) ? { ...row, items: row.items.map((item) => readOnly.has(item.id) ? saved.get(item.id) ?? item : item) } : row) + continue + } + const oldIds = new Set(next.turnRows![id] ?? []) + const ids = metadata.turnRows[id] ?? [] + const accepted = new Set(ids) + const rows = snapshot.turns.filter((row) => accepted.has(row.turnId)) + const at = next.turns.findIndex((row) => oldIds.has(row.turnId)) + next.turns = next.turns.filter((row) => !oldIds.has(row.turnId)) + next.turns.splice(at < 0 ? next.turns.length : at, 0, ...rows) + next.turnRows![id] = [...ids] + next.nonAppendableItemIds![id] = metadata.nonAppendableItemIds?.[id] ?? [] + next.turnRevisions![id] = metadata.eventRevision + } + const deferred = (state.deferredOps ?? []).filter((op) => op.revision > metadata.eventRevision && !next.settledTurns?.[op.nativeTurnId]) + if (deferred.length) next = applyFreshAgentTranscriptPatch({ ...next, eventRevision: metadata.eventRevision, deferredOps: [] }, { sessionId: snapshot.threadId, projectionId: state.id, historyGeneration: state.historyGeneration, ops: deferred }) + else next.deferredOps = [] + next.eventRevision = Math.max(state.eventRevision, metadata.eventRevision, next.eventRevision) + next.sessionId = snapshot.threadId + const groupOf = new Map(Object.entries(metadata.turnRows).flatMap(([id, rows]) => rows.map((row) => [row, id] as const))) + const order = [...new Set(snapshot.turns.map((row) => groupOf.get(row.turnId)).filter((id): id is string => Boolean(id)))] + const observed = state.turnOrder ?? [] + for (const [index, id] of observed.entries()) { + if (order.includes(id)) continue + const next = observed.slice(index + 1).find((candidate) => order.includes(candidate)) + order.splice(next ? order.indexOf(next) : order.length, 0, id) + } + next.turnOrder = order + if (metadata.eventRevision >= state.eventRevision) next.needsResnapshot = false + next.deferredBytes = JSON.stringify(next.deferredOps ?? []).length + return next +} + +function withOutputState(rows: FreshAgentTurn[], state: ProjectionMetadata['turnOutputStates'][string] | undefined): FreshAgentTurn[] { + if (!state || rows.length === 0 || (state.provenance === 'saved' && !state.diagnostic && !state.retainedTurns && !state.savedTurns)) return rows + const { retainedTurns, savedTurns, ...outputState } = state + return [{ ...rows[0], outputState: { ...outputState, + ...(retainedTurns ? { retainedItems: retainedTurns.flatMap((row) => row.items) } : {}), + ...(savedTurns ? { savedItems: savedTurns.flatMap((row) => row.items) } : {}), + } }, ...rows.slice(1)] +} + +/** Replace native-turn groups atomically. Read previews never consume event revisions. */ +export function mergeFreshAgentTranscriptProjection(snapshot: FreshAgentSnapshot, live?: TranscriptProjection): FreshAgentTurn[] { + const metadata = readTranscriptProjection(snapshot) + if (!metadata) return mergeSavedOnlyRetainedOutput(snapshot, live) + const currentLive = live?.id === metadata.id && live.historyGeneration === metadata.historyGeneration ? live : undefined + const liveRows = currentLive?.turnRows ?? {} + const nativeRows = metadata.turnRows ?? {} + const groupOf = new Map(Object.entries(nativeRows).flatMap(([id, rows]) => rows.map((row) => [row, id] as const))) + const savedGroups = new Map() + for (const row of snapshot.turns) { + const id = groupOf.get(row.turnId) + if (id) { const rows = savedGroups.get(id) ?? []; rows.push(row); savedGroups.set(id, rows) } + } + const result: FreshAgentTurn[] = [] + const seen = new Set() + const finals = new Set(metadata.nativeFinalTurnIds) + const previews = new Set(metadata.nativePreviewTurnIds) + const appendGroup = (id: string, saved: FreshAgentTurn[]) => { + if (seen.has(id)) return + seen.add(id) + const eventIds = new Set(liveRows[id] ?? []) + const eventRows = currentLive?.turns.filter((row) => eventIds.has(row.turnId)) ?? [] + const complete = eventRows.some((row) => row.items.some((item) => currentLive?.closedItems?.[itemKey(id, item.id)])) + const frozenState = eventRows.find((row) => row.outputState?.provenance === 'retained' && !row.outputState.refreshPending)?.outputState + const eventWins = eventRows.length > 0 && !finals.has(id) + && (saved.length === 0 || frozenState || ((currentLive!.turnRevisions?.[id] ?? 0) > metadata.eventRevision && (!previews.has(id) || complete))) + const rows = eventWins ? eventRows : saved + let state: ProjectionMetadata['turnOutputStates'][string] | undefined = (eventWins && frozenState ? frozenState : undefined) ?? metadata.turnOutputStates?.[id] + ?? (eventWins ? { provenance: 'retained' as const, refreshPending: Boolean(currentLive?.settledTurns?.[id]) } : undefined) + // Legacy recovery message ids may be reconstructed. Keep its unmatched + // context available without guessing which read item a live item replaces. + if (eventWins && complete && previews.has(id) && saved.length && !state?.savedTurns) { + state = { ...state!, savedTurns: saved } + } + const frozen = currentLive?.frozenTurns?.[id] + if (frozen?.length && (previews.has(id) || state?.diagnostic === 'interrupted') && !state?.retainedTurns) { + state = { provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedTurns: frozen } + } + result.push(...withOutputState(rows, state)) + } + for (const row of snapshot.turns) { + const id = groupOf.get(row.turnId) + if (!id) { result.push(row); continue } + appendGroup(id, savedGroups.get(id) ?? []) + } + const observed = currentLive?.turnOrder ?? Object.keys(liveRows) + for (const [index, id] of observed.entries()) { + if (seen.has(id) || currentLive?.removedTurns?.[id] || finals.has(id)) continue + const before = result.length + appendGroup(id, []) + const added = result.splice(before) + const anchor = observed.slice(index + 1).find((candidate) => seen.has(candidate) && ((nativeRows[candidate]?.length ?? 0) > 0 || (liveRows[candidate]?.length ?? 0) > 0)) + const anchorRows = new Set(anchor ? nativeRows[anchor] ?? liveRows[anchor] ?? [] : []) + const insertion = result.findIndex((row) => anchorRows.has(row.turnId)) + result.splice(insertion < 0 ? result.length : insertion, 0, ...added) + } + return result +} + +function mergeSavedOnlyRetainedOutput(snapshot: FreshAgentSnapshot, live?: TranscriptProjection): FreshAgentTurn[] { + if (snapshot.provider !== 'codex' || live?.sessionId !== snapshot.threadId || snapshot.extensions?.codex?.nativeHistoryAvailable !== true || snapshot.extensions.codex.ownerKind !== 'vacant') return snapshot.turns + const rows = [...snapshot.turns] + const groups = { ...live.frozenTurns, ...Object.fromEntries(Object.entries(live.turnRows ?? {}).map(([id, ids]) => [id, live.turns.filter((row) => ids.includes(row.turnId))])) } + for (const [id, retained] of Object.entries(groups)) { + const index = rows.findIndex((row) => rowBelongsToNativeTurn(row, id)) + if (index < 0 || !retained.length || (rows[index].outputState?.diagnostic !== 'interrupted' && !retained.some((row) => row.outputState?.diagnostic === 'interrupted'))) continue + rows[index] = { ...rows[index], outputState: { provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedItems: retained.flatMap((row) => row.items) } } + } + return rows +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 0661ea5e2..444bc7d3d 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -12,7 +12,8 @@ import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' import type { LiveBlockKind, LiveNotice, LiveNoticeLevel, LiveRateLimit, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' -import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced } from '@/store/freshAgentLiveSlice' +import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced, transcriptProjectionBound, transcriptPatchApplied, codexMetadataReceived } from '@/store/freshAgentLiveSlice' +import { FreshAgentTranscriptPatchSchema } from '@shared/fresh-agent-contract' import { addPermissionRequest, addQuestionRequest, @@ -467,6 +468,12 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh if (event.type !== 'freshAgent.stream') streamBatcherFor(dispatch).flush(locator) switch (event.type) { + case 'freshAgent.transcript.patch': { + const parsed = FreshAgentTranscriptPatchSchema.safeParse(event) + if (parsed.success && parsed.data.sessionId === sessionId) dispatch(transcriptPatchApplied({ ...locator, patch: parsed.data })) + else log.warn('dropping malformed native transcript patch', { sessionId }) + return true + } case 'freshAgent.session.snapshot': dispatch(observeFreshAgentSession(locator)) dispatch(sessionSnapshotReceived({ @@ -486,6 +493,10 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh } return true case 'freshAgent.session.init': + if (typeof event.projectionId === 'string' && Number.isSafeInteger(event.historyGeneration)) { + dispatch(transcriptProjectionBound({ ...locator, projectionId: event.projectionId, historyGeneration: event.historyGeneration as number })) + if (!['cliSessionId', 'model', 'effort', 'cwd', 'tools'].some((key) => key in event)) return true + } dispatch(sessionInit({ ...locator, cliSessionId: event.cliSessionId as string | undefined, @@ -496,6 +507,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh })) return true case 'freshAgent.session.metadata': + if (isObject(event.codex)) dispatch(codexMetadataReceived({ ...locator, metadata: event.codex })) dispatch(sessionMetadataReceived({ ...locator, cliSessionId: event.cliSessionId as string | undefined, diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index 99fa62bfd..05344f259 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -1,6 +1,8 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { isLiveBlockLanded, landedTranscript, type LandedTranscript } from '@/lib/fresh-agent-landed-transcript' +import { applyFreshAgentTranscriptPatch, bindTranscriptProjection, freezeTranscriptProjection, hydrateTranscriptProjection, readTranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import type { FreshAgentSnapshot, FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' import { markSessionLost, materializeSession, @@ -66,6 +68,9 @@ function clearTail(session: FreshAgentLiveSession): void { if (session.blocks.length > 0) session.blocks = [] if (session.expectedTurnIds.length > 0) session.expectedTurnIds = [] if (session.retry) delete session.retry + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + if (session.projection) session.projection = freezeTranscriptProjection(session.projection) } function clearTailsFor(state: FreshAgentLiveState, payload: SessionMutationPayload): void { @@ -83,7 +88,7 @@ function isViewed(state: FreshAgentLiveState, key: string): boolean { */ function endTurnsFor(state: FreshAgentLiveState, payload: SessionMutationPayload): void { for (const key of tailKeysFor(state, payload)) { - if (!isViewed(state, key)) { + if (!isViewed(state, key) && !state.bySession[key].projection) { delete state.bySession[key] continue } @@ -151,6 +156,78 @@ const freshAgentLiveSlice = createSlice({ name: 'freshAgentLive', initialState, reducers: { + /** Authoritative init/snapshot binding; arbitrary patches cannot change incarnation. */ + transcriptProjectionBound(state, action: PayloadAction) { + const { projectionId, historyGeneration, ...locator } = action.payload + const session = sessionFor(state, locator) + const previous = session.projection + // A later incarnation is accepted through the view's fenced snapshot + // request. Delayed init frames cannot replace an already-bound source. + if (previous && previous.id !== projectionId) return + if (previous?.id === projectionId && previous.historyGeneration >= historyGeneration) return + session.projection = bindTranscriptProjection(previous, projectionId, historyGeneration) + for (const patch of session.unboundTranscriptPatches ?? []) { + if (patch.projectionId === projectionId) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + } + const watermark = session.unboundTranscriptWatermarks?.[JSON.stringify([projectionId, historyGeneration])] + if (watermark?.gap) { + session.projection.needsResnapshot = true + session.projection.eventRevision = Math.max(session.projection.eventRevision, watermark.revision) + } + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + }, + transcriptPatchApplied(state, action: PayloadAction) { + const { patch, ...locator } = action.payload + const session = sessionFor(state, locator) + if (session.projection?.id === patch.projectionId) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + else { + // Buffer a bounded reconnect race without adopting its source. The + // authoritative init/snapshot chooses which incarnation may replay. + const pending = session.unboundTranscriptPatches ??= [] + const watermarks = session.unboundTranscriptWatermarks ??= {} + const key = JSON.stringify([patch.projectionId, patch.historyGeneration]) + const watermark = watermarks[key] ??= { revision: 0, gap: false } + watermark.revision = Math.max(watermark.revision, ...patch.ops.map((op) => op.revision)) + if (pending.length < 64 && JSON.stringify(pending).length + JSON.stringify(patch).length <= 64 * 1024) pending.push(patch) + else watermark.gap = true + } + }, + transcriptProjectionSnapshotReceived(state, action: PayloadAction) { + const { snapshot, ...locator } = action.payload + const metadata = readTranscriptProjection(snapshot) + if (!metadata) return + const session = sessionFor(state, locator) + if (!session.projection || session.projection.id !== metadata.id || session.projection.historyGeneration < metadata.historyGeneration) { + session.projection = bindTranscriptProjection(session.projection, metadata.id, metadata.historyGeneration) + } + session.projection = hydrateTranscriptProjection(session.projection, snapshot) + for (const patch of session.unboundTranscriptPatches ?? []) { + if (patch.projectionId === metadata.id) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + } + const watermark = session.unboundTranscriptWatermarks?.[JSON.stringify([metadata.id, metadata.historyGeneration])] + if (watermark?.gap && metadata.eventRevision < watermark.revision) { + session.projection.needsResnapshot = true + session.projection.eventRevision = Math.max(session.projection.eventRevision, watermark.revision) + } + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + const projection = session.projection + if (projection.historyGeneration !== metadata.historyGeneration) return + const accepted = new Set([...metadata.eventOwnedTurnIds, ...metadata.nativePreviewTurnIds, ...metadata.nativeFinalTurnIds]) + for (const id of Object.keys(projection.frozenTurns ?? {})) if (!accepted.has(id)) delete projection.frozenTurns![id] + for (const id of metadata.nativeFinalTurnIds ?? []) { + (projection.settledTurns ??= {})[id] = true + const rows = new Set(projection.turnRows?.[id] ?? []) + projection.turns = projection.turns.filter((row) => !rows.has(row.turnId)) + if (projection.turnRows) delete projection.turnRows[id] + if (metadata.turnOutputStates[id]?.diagnostic !== 'interrupted' && projection.frozenTurns) delete projection.frozenTurns[id] + } + }, + codexMetadataReceived(state, action: PayloadAction }>) { + const { metadata, ...locator } = action.payload + sessionFor(state, locator).codexMetadata = metadata + }, /** A batch of stream events for one session, in arrival order. */ streamOpsApplied(state, action: PayloadAction) { const { ops, ...locator } = action.payload @@ -297,6 +374,10 @@ const freshAgentLiveSlice = createSlice({ }) export const { + transcriptProjectionBound, + transcriptPatchApplied, + transcriptProjectionSnapshotReceived, + codexMetadataReceived, assistantBlockBound, liveNoticeReceived, liveRetrySet, diff --git a/src/store/freshAgentLiveTypes.ts b/src/store/freshAgentLiveTypes.ts index cd81f9f0a..7a4786b24 100644 --- a/src/store/freshAgentLiveTypes.ts +++ b/src/store/freshAgentLiveTypes.ts @@ -1,9 +1,9 @@ /** * The live tail of a fresh-agent session (transcript-live plan §4.4): what a - * running turn shows before its transcript lines land in a snapshot. Durable - * content always comes from the snapshot (principle P1); this state holds - * only what the transcript cannot hold yet — the blocks being streamed, the - * API retry in progress, and the provider notices that never end a turn. It + * running turn shows before its transcript lines land in a snapshot. Claude + * blocks and Codex event-owned bodies stay here, with explicit provenance + * when native saving differs or is unavailable. API retries and provider + * notices never end a turn. This state * lives under its own root key so token-rate updates never touch * `state.freshAgent.sessions` (P3). */ @@ -82,7 +82,14 @@ export type LiveNotice = { rateLimit?: LiveRateLimit } +import type { TranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import type { FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' + export type FreshAgentLiveSession = { + projection?: TranscriptProjection + codexMetadata?: Record + unboundTranscriptPatches?: FreshAgentTranscriptPatch[] + unboundTranscriptWatermarks?: Record blocks: LiveBlock[] retry?: LiveRetry /** Transcript line uuids announced by the CLI and not yet seen in a snapshot. */ diff --git a/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts new file mode 100644 index 000000000..8c23f317d --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } 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 source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +test('Freshcodex shows native text and command progress before completion, then one saved answer after reload', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-live-')) + const wrapper = path.join(root, 'codex.mjs') + const release = path.join(root, 'release') + const nativeLog = path.join(root, 'native.jsonl') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, liveTranscript: true, waitForTurnReleasePath: release, appendNativeNotificationLogPath: nativeLog }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + await input.fill('Show native live progress') + await pane.getByRole('button', { name: 'Send' }).click() + await expect.poll(async () => (await fs.readFile(nativeLog, 'utf8')).includes('item/agentMessage/delta')).toBe(true) + expect(await fs.readFile(nativeLog, 'utf8')).not.toContain('turn/completed') + await expect(pane.getByText('Native streaming fragment', { exact: true })).toBeVisible({ timeout: 20_000 }) + await expect(pane.getByText('Inspect progress', { exact: true })).toBeVisible() + await expect(pane.getByText('Old plan', { exact: true })).toHaveCount(0) + await expect(pane.getByText('Latest usage 20 tokens', { exact: true })).toBeVisible() + await expect(pane.getByRole('meter', { name: 'Context window used' })).toHaveCount(0) + await pane.getByRole('button', { name: 'Diff: Turn changes' }).click() + await expect(pane.getByText('+replacement', { exact: true })).toBeVisible() + await expect(pane.getByText('+previous', { exact: true })).toHaveCount(0) + await pane.getByRole('button', { name: 'Toggle activity details' }).first().click() + await pane.getByRole('button', { name: 'Bash tool call' }).click() + await expect(pane.getByText('command progress before completion', { exact: true })).toBeVisible() + const reasoning = pane.getByText('Native reasoning in progress', { exact: true }) + if (!await reasoning.isVisible()) await pane.getByRole('button', { name: 'Thinking', exact: true }).last().click() + await expect(reasoning).toBeVisible() + expect(await fs.readFile(nativeLog, 'utf8')).not.toContain('turn/completed') + await expect(pane.getByRole('button', { name: 'Stop' })).toBeVisible() + await fs.writeFile(release, 'release') + await expect(pane.getByText('Native streaming fragment and final answer', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + await expect(pane.getByRole('button', { name: 'Stop' })).toHaveCount(0, { timeout: 30_000 }) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(page.getByText('Native streaming fragment and final answer', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + } finally { + await fs.writeFile(release, 'release').catch(() => {}) + await context?.close() + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index f0f9c752b..d899948eb 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -380,6 +380,10 @@ function successResult(method, params) { ? params.input.map((part) => (part && typeof part.text === 'string' ? part.text : '')).filter(Boolean).join('\n') : '' const turn = makeRecordedTurn(`turn-${turns.length + 1}`, promptText, params) + if (behavior.liveTranscript) { + turn.status = 'inProgress' + turn.items = [turn.items[0]] + } turns.push(turn) recordedTurnsByThread.set(threadId, turns) persistRecordedTurns(threadId, turns) @@ -620,6 +624,9 @@ const paginatedThreadIds = new Set() let forkCounter = 0 function broadcastNotification(method, params) { + if (behavior.appendNativeNotificationLogPath) { + fs.appendFileSync(behavior.appendNativeNotificationLogPath, JSON.stringify({ method, threadId: params?.threadId, turnId: params?.turnId ?? params?.turn?.id }) + '\n') + } const payload = JSON.stringify({ jsonrpc: '2.0', method, @@ -958,6 +965,45 @@ wss.on('connection', (socket) => { threadId: message.params?.threadId, turn: { id: result.turn.id, status: 'inProgress' }, }) + if (behavior.liveTranscript) { + const threadId = message.params.threadId + const turnId = result.turn.id + const user = result.turn.items[0] + broadcastNotification('item/started', { threadId, turnId, item: user }) + broadcastNotification('item/completed', { threadId, turnId, item: user }) + const item = { type: 'agentMessage', id: `${turnId}:answer`, text: '' } + broadcastNotification('item/started', { threadId, turnId, item }) + broadcastNotification('item/agentMessage/delta', { threadId, turnId, itemId: item.id, delta: 'Native streaming fragment' }) + const command = { type: 'commandExecution', id: `${turnId}:command`, command: 'echo progress', status: 'inProgress', aggregatedOutput: '' } + broadcastNotification('item/started', { threadId, turnId, item: command }) + broadcastNotification('item/commandExecution/outputDelta', { threadId, turnId, itemId: command.id, delta: 'command progress before completion' }) + const reasoning = { type: 'reasoning', id: `${turnId}:reasoning`, summary: [], content: [] } + broadcastNotification('item/started', { threadId, turnId, item: reasoning }) + broadcastNotification('item/reasoning/summaryTextDelta', { threadId, turnId, itemId: reasoning.id, summaryIndex: 0, delta: 'Native reasoning in progress' }) + broadcastNotification('turn/plan/updated', { threadId, turnId, plan: [{ step: 'Old plan', status: 'pending' }] }) + broadcastNotification('turn/plan/updated', { threadId, turnId, explanation: 'Latest native plan', plan: [{ step: 'Inspect progress', status: 'inProgress' }, { step: 'Verify saved result', status: 'pending' }] }) + broadcastNotification('turn/diff/updated', { threadId, turnId, diff: '@@ -1 +1 @@\n-old\n+previous' }) + broadcastNotification('turn/diff/updated', { threadId, turnId, diff: '@@ -1 +1 @@\n-old\n+replacement' }) + broadcastNotification('thread/tokenUsage/updated', { threadId, turnId, tokenUsage: { last: { totalTokens: 20, inputTokens: 15, cachedInputTokens: 8, outputTokens: 5, reasoningOutputTokens: 3 }, total: { totalTokens: 200, inputTokens: 150, cachedInputTokens: 80, outputTokens: 50 }, modelContextWindow: 258400 } }) + const release = behavior.waitForTurnReleasePath + const deadline = Date.now() + 120_000 + while (release && !fs.existsSync(release)) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for test release: ${threadId}/${turnId}`) + await new Promise((resolve) => setTimeout(resolve, 25)) + } + item.text = 'Native streaming fragment and final answer' + command.status = 'completed' + command.aggregatedOutput = 'command progress before completion' + reasoning.summary = ['Native reasoning in progress'] + broadcastNotification('item/completed', { threadId, turnId, item }) + broadcastNotification('item/completed', { threadId, turnId, item: command }) + broadcastNotification('item/completed', { threadId, turnId, item: reasoning }) + const turns = loadRecordedTurns(threadId) + const turn = turns.find((candidate) => candidate.id === turnId) + turn.items.push(item, command, reasoning) + turn.status = 'completed' + persistRecordedTurns(threadId, turns) + } const prompt = (message.params?.input ?? []).filter((part) => part.type === 'text').map((part) => part.text).join('\n') const userRequest = behavior.serverRequestsByPrompt?.[prompt] let interrupted = false diff --git a/test/fixtures/fresh-agent/codex/native-projection-0.164.json b/test/fixtures/fresh-agent/codex/native-projection-0.164.json new file mode 100644 index 000000000..03ce8b7da --- /dev/null +++ b/test/fixtures/fresh-agent/codex/native-projection-0.164.json @@ -0,0 +1 @@ +{"cases":[{"scenario":"approval-policy-restricted-exec","parameters":{"approvalControl":"stdin"},"source":{"file":"13-approval-policy-restricted-exec.json","sha256":"15fad3a674315290dc50722da031d93a6198c72fdae89c8e8f8f9151ce314b9d","version":"0.164.0"},"notifications":[{"method":"turn/started","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-59f5-7a31-ba4b-14174b0019cc","rootTurnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1792955013,"completedAt":null,"durationMs":null}}},{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a17472-5a50-7060-a5a9-88ca4bbe21e7","clientId":null,"content":[{"type":"text","text":"Grant the narrow owned directory and open a real sandboxed PTY.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955013714}},{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a17472-5a50-7060-a5a9-88ca4bbe21e7","clientId":null,"content":[{"type":"text","text":"Grant the narrow owned directory and open a real sandboxed PTY.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","completedAtMs":1792955013714}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/started","params":{"item":{"type":"commandExecution","id":"call_open_first","pluginId":null,"scriptPath":null,"command":"/usr/bin/sh -c '/bin/bash --noprofile --norc'","cwd":"/probe-state/codex-contract-aFAvDr/workspace","processId":"88913","source":"unifiedExecStartup","status":"inProgress","commandActions":[{"type":"unknown","command":"/bin/bash --noprofile --norc"}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955014085}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":30,"inputTokens":20,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":10,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_contract_2","text":"","phase":null,"memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955014385}},{"method":"item/agentMessage/delta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"msg_contract_2","delta":"First fragment"}},{"method":"item/agentMessage/delta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"msg_contract_2","delta":" and final fragment"}},{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_contract_2","text":"First fragment and final fragment","phase":null,"memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","completedAtMs":1792955014385}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":45,"inputTokens":30,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":15,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"turn/completed","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-59f5-7a31-ba4b-14174b0019cc","rootTurnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","items":[{"type":"agentMessage","id":"msg_contract_2","text":"First fragment and final fragment","phase":null,"memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1792955013,"completedAt":1792955014,"durationMs":774}}},{"method":"turn/started","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-5d09-7b80-bbc0-742127c507f0","rootTurnId":"01a17472-5d09-7b80-bbc0-742127c507f0","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1792955014,"completedAt":null,"durationMs":null}}},{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a17472-5d1d-7452-91d4-cf12c705cd42","clientId":null,"content":[{"type":"text","text":"Poll the terminal, deny one input, then approve a separate input.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","startedAtMs":1792955014430}},{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a17472-5d1d-7452-91d4-cf12c705cd42","clientId":null,"content":[{"type":"text","text":"Poll the terminal, deny one input, then approve a separate input.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","completedAtMs":1792955014431}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","tokenUsage":{"total":{"totalTokens":60,"inputTokens":40,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":20,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","tokenUsage":{"total":{"totalTokens":75,"inputTokens":50,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":25,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/commandExecution/outputDelta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"call_open_first","delta":"\r { expect(screen.getByRole('button', { name: 'Diff: src/app.tsx' })).toBeInTheDocument() }) + it('shows supplied native diff and later replacement without a cwd/path fetch', async () => { + const { rerender } = render() + await userEvent.click(screen.getByRole('button', { name: 'Diff: Turn changes' })) + expect(await screen.findByText('+two')).toBeVisible() + expect(screen.getByText('diff --git a/one.ts b/one.ts')).toBeVisible() + expect(screen.getByText('diff --git a/two.ts b/two.ts')).toBeVisible() + expect(screen.queryByText(/Diff unavailable/)).toBeNull() + rerender() + expect(screen.getByText('+three')).toBeVisible() + expect(screen.queryByText('+two')).toBeNull() + expect(screen.queryByText('diff --git a/one.ts b/one.ts')).toBeNull() + expect(apiGet).not.toHaveBeenCalled() + }) + it('renders the shared diff view with data-file-path copy target metadata', () => { const { container } = render( , diff --git a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx index 5019fdbe3..b5cf0f7cb 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx @@ -13,6 +13,12 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentItemCard', () => { afterEach(() => cleanup()) + it('shows native file-change output progress in its tool disclosure', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit tool call' })) + expect(screen.getByText('Applying patch')).toBeVisible() + }) + it('renders markdown text while preserving XSS defenses through the markdown renderer', () => { const { container } = render( { + const props: any = { worktrees: [], childThreads: [], codexPlan: { explanation: 'Work in order', plan: [{ step: 'Inspect', status: 'inProgress' }, { step: 'Verify', status: 'pending' }] } } + const { rerender } = render() + expect(screen.getByText('Plan')).toBeVisible() + expect(screen.getByText('Inspect').closest('li')).toHaveTextContent('In progress') + expect(screen.getByText('Verify').closest('li')).toHaveTextContent('Pending') + rerender() + expect(screen.getByText('Inspect').closest('li')).toHaveTextContent('Completed') + expect(screen.queryByText('Verify')).toBeNull() + expect(screen.queryByText('Work in order')).toBeNull() +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index ef690173b..1ff006a4a 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -23,6 +23,26 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentTranscript', () => { afterEach(() => cleanup()) + it.each([ + [{ provenance: 'saved', diagnostic: 'interrupted', refreshPending: false }, 'Only saved output remains.'], + [{ provenance: 'retained', diagnostic: 'unsaved', refreshPending: false }, 'Saved output could not be confirmed.'], + [{ provenance: 'saved-and-retained', diagnostic: 'provider-truncated', refreshPending: false }, 'Codex shortened the saved tool output.'], + [{ provenance: 'preview', refreshPending: false }, 'Native preview.'], + [{ provenance: 'retained', refreshPending: true }, 'Waiting for saved history.'], + ] as const)('renders finite output provenance %j', (outputState, text) => { + render() + expect(screen.getByRole('note', { name: 'Output persistence' })).toHaveTextContent(text) + expect(screen.getAllByText('Available output')).toHaveLength(1) + }) + + it('keeps richer retained output in a disclosure within the saved turn group', async () => { + render() + expect(screen.getAllByLabelText('Assistant transcript turn')).toHaveLength(1) + fireEvent.click(screen.getByText('Retained output', { selector: 'summary' })) + expect(await screen.findByText('Unfinished retained body')).toBeVisible() + expect(screen.getByText('Saved body')).toBeVisible() + }) + it('renders normalized text turns', () => { render( { + expect(latestCodexUsage({ last: { totalTokens: 20, inputTokens: 15, cachedInputTokens: 8, outputTokens: 5, reasoningOutputTokens: 3 }, total: { totalTokens: 200, inputTokens: 150, outputTokens: 50 }, modelContextWindow: 258400 })).toEqual({ observed: true, latestTokens: 20, totalTokens: 200, modelContextWindow: 258400 }) + expect(latestCodexUsage({ last: { totalTokens: 0 }, total: { totalTokens: 0 } })).toEqual({ observed: true, latestTokens: 0, totalTokens: 0 }) + expect(latestCodexUsage(undefined)).toEqual({ observed: false }) +}) + // Fixture style mirrors PaneContainer.test.tsx's makeSession/makeContent // builders: minimal objects carrying only the fields under test, cast to the // imported types. diff --git a/test/unit/client/lib/fresh-agent-transcript-projection.test.ts b/test/unit/client/lib/fresh-agent-transcript-projection.test.ts new file mode 100644 index 000000000..707a54c73 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-transcript-projection.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { applyFreshAgentTranscriptPatch, bindTranscriptProjection, hydrateTranscriptProjection, mergeFreshAgentTranscriptProjection, type TranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import { FreshAgentSnapshotSchema } from '@shared/fresh-agent-contract' + +const row = { id: 't', turnId: 't', role: 'assistant' as const, summary: '', items: [{ id: 'a', kind: 'text' as const, text: '' }] } +const patch = (ops: unknown[], projectionId = 'p') => ({ sessionId: 'thread', projectionId, historyGeneration: 0, ops }) +const opened = (): TranscriptProjection => applyFreshAgentTranscriptPatch({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: 1 }])) +const read = (turns: unknown[], projection: Record = {}) => FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread', revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: {inputTokens: 0, outputTokens: 0, totalTokens: 0}, turns, extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: 0, eventOwnedTurnIds: [], nativeFinalTurnIds: [], nativePreviewTurnIds: [], turnOutputStates: {}, turnRows: { t: turns.map((t: any) => t.turnId) }, ...projection } } } }) +const append = (text: string, revision: number) => ({ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text, revision }) + +describe('independent native reads and event bodies', () => { + it('shows deltas before completion and replaces the complete item exactly once', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'Hel', revision: 2 }])) + expect(live.turns[0]?.items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'Hello' }, complete: true, revision: 3 }])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 4 }])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('does not adopt a projection incarnation from an arbitrary patch', () => { + const live = opened() + expect(applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 'other', turns: [row], revision: 100 }], 'foreign'))).toEqual(live) + }) + + it('renders an event-owned turn missing from a behind snapshot', () => { + const snapshot = FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread', revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: {inputTokens: 0, outputTokens: 0, totalTokens: 0}, turns: [], extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: 0, eventOwnedTurnIds: [], nativeFinalTurnIds: [], nativePreviewTurnIds: [], turnOutputStates: {} } } } }) + expect(mergeFreshAgentTranscriptProjection(snapshot, opened())).toHaveLength(1) + }) + + it('keeps a read-ahead preview separate from the event append baseline', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hel', 2)])) + const snapshot = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hello' }] }], { nativePreviewTurnIds: ['t'] }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + live = applyFreshAgentTranscriptPatch(live, patch([append('lo', 3)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)).toHaveLength(1) + }) + + it('does not lose a newer delta to an event-owned behind read', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hel', 2)])) + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('replaces an entire split native group without comparing coincidental item ids or deduplicating repeated bodies', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('stale', 2)])) + const rows = [{ ...row, id: 't:row-0', turnId: 't:row-0', role: 'user', nativeTurnId: 't', items: [{ id: 'item-1', kind: 'text', text: 'same' }] }, { ...row, id: 't:row-1', turnId: 't:row-1', items: [{ id: 'item-2', kind: 'text', text: 'same' }, { id: 'item-3', kind: 'text', text: 'same' }] }] + const snapshot = read(rows, { nativeFinalTurnIds: ['t'] }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)).toEqual(snapshot.turns) + }) + + it('rejects late item appends after completion even after eight subsequent turns', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'settleTurn', nativeTurnId: 't', revision: 2 }])) + for (let i = 0; i < 9; i++) live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: `new-${i}`, turns: [{ ...row, id: `new-${i}`, turnId: `new-${i}` }], revision: i + 3 }])) + live = applyFreshAgentTranscriptPatch(live, patch([append('late', 12)])) + expect(live.turns[0].items).toEqual(row.items) + }) + + it('an accepted history mutation removes a group permanently for that generation', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, { sessionId: 'thread', projectionId: 'p', historyGeneration: 1, ops: [{ op: 'resetTurn', nativeTurnId: 't', turns: [], revision: 2 }] }) + live = applyFreshAgentTranscriptPatch(live, patch([append('old', 3)])) + expect(live.turns).toEqual([]) + expect(mergeFreshAgentTranscriptProjection(read([], { historyGeneration: 1 }), live)).toEqual([]) + }) + + it('ignores reordered or duplicate revision deliveries and old-incarnation frozen extras', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hello', 3), append('duplicate', 3), append('older', 2)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + expect(mergeFreshAgentTranscriptProjection(read([], { id: 'replacement' }), live)).toEqual([]) + }) + + it('appends indexed reasoning independently, preserving Unicode', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'r', kind: 'reasoning', summary: [], content: [] }, revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([ + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 1, text: '思', revision: 3 }, + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 0, text: '🙂', revision: 4 }, + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 1, text: '考', revision: 5 }, + ])) + expect(live.turns[0].items[1]).toMatchObject({ kind: 'reasoning', summary: ['🙂', '思考'] }) + }) + + it('keeps frozen output only as a finite disclosure for accepted interrupted history after rebind', () => { + const old = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + const live = bindTranscriptProjection(old, 'replacement', 0) + const saved = read([{ ...row, items: [] }], { id: 'replacement', nativeFinalTurnIds: ['t'], turnOutputStates: { t: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } } }) + const turns = mergeFreshAgentTranscriptProjection(saved, live) + expect(turns[0].outputState).toMatchObject({ provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedItems: [{ id: 'a', kind: 'text', text: 'unfinished' }] }) + expect(mergeFreshAgentTranscriptProjection(read([], { id: 'replacement' }), live)).toEqual([]) + const complete = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'new native final' }] }], { id: 'replacement', nativeFinalTurnIds: ['t'], turnOutputStates: { t: { provenance: 'saved', refreshPending: false } } }) + expect(mergeFreshAgentTranscriptProjection(complete, live)[0].outputState?.retainedItems).toBeUndefined() + }) + + it('freeze after host loss does not keep waiting for an item native cannot save', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', status: 'interrupted', revision: 3 }])) + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0]).toMatchObject({ items: [{ id: 'a', kind: 'text', text: 'unfinished' }], outputState: { diagnostic: 'interrupted', refreshPending: false } }) + }) + + it('hydrates a captured event-owned prefix on reconnect, while a native preview stays nonappendable', () => { + const prefix = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hel' }] }], { eventOwnedTurnIds: ['t'], eventRevision: 2 }) + let live = hydrateTranscriptProjection({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, prefix) + live = applyFreshAgentTranscriptPatch(live, patch([append('lo', 3)])) + expect(mergeFreshAgentTranscriptProjection(prefix, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + const preview = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hello' }] }], { nativePreviewTurnIds: ['t'], eventRevision: 2 }) + let cold = hydrateTranscriptProjection({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, preview) + cold = applyFreshAgentTranscriptPatch(cold, patch([append('lo', 3)])) + expect(cold.turns).toEqual([]) + expect(mergeFreshAgentTranscriptProjection(preview, cold)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('a higher revision from another turn cannot replace a newer captured body in this turn', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 'later', turns: [{ ...row, id: 'later', turnId: 'later' }], revision: 10 }])) + const snapshot = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'server complete' }] }], { eventOwnedTurnIds: ['t'], eventRevision: 5 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'server complete' }]) + }) + + it('retains bounded ended payloads while permanently fencing the discarded operations', () => { + let live = opened() + let revision = 2 + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', revision: revision++ }])) + for (let i = 0; i < 40; i++) { + const id = `n-${i}` + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: id, turns: [{ ...row, id, turnId: id }], revision: revision++ }, { op: 'settleTurn', nativeTurnId: id, revision: revision++ }])) + } + expect(live.turns).toHaveLength(32) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: revision++ }, append('late', revision++)])) + expect(live.turns.some((row) => row.turnId === 't')).toBe(false) + }) + + it('preserves a warm interrupted disclosure across a read-only saved-history source without reviving removed membership', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', status: 'interrupted', revision: 3 }])) + const saved = read([{ ...row, items: [], outputState: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } }]) + saved.extensions.codex = { nativeHistoryAvailable: true, ownerKind: 'vacant' } + expect(mergeFreshAgentTranscriptProjection(saved, live)[0].outputState?.retainedItems).toEqual([{ id: 'a', kind: 'text', text: 'unfinished' }]) + expect(mergeFreshAgentTranscriptProjection({ ...saved, turns: [] }, live)).toEqual([]) + expect(mergeFreshAgentTranscriptProjection({ ...saved, threadId: 'different' }, live)[0].outputState?.retainedItems).toBeUndefined() + }) + + it('groups canonical projection metadata without depending on the additive row lookup hint', () => { + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + delete (snapshot.extensions.codex!.projection as any).turnRows + expect(mergeFreshAgentTranscriptProjection(snapshot, opened())).toHaveLength(1) + }) + + it('keeps an older unsaved group before a later saved turn even after its live payload lands', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('older unsaved', 2), { op: 'settleTurn', nativeTurnId: 't', revision: 3 }, { op: 'resetTurn', nativeTurnId: 'later', turns: [{ ...row, id: 'later', turnId: 'later' }], revision: 4 }])) + const snapshot = read([{ ...row, id: 'later', turnId: 'later', items: [{ id: 'answer-later', kind: 'text', text: 'later saved' }] }], { nativeFinalTurnIds: ['later'], turnRows: { later: ['later'] } }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live).map((turn) => turn.turnId)).toEqual(['t', 'later']) + }) + + it('shows file-change output progress on its existing item', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'files', kind: 'file_change', status: 'running', changes: [] }, revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'files', field: 'output', text: 'Applying patch', revision: 3 }])) + expect(live.turns[0].items[1]).toMatchObject({ output: 'Applying patch' }) + }) + + it('never appends to read-only recovery preview items, but admits an actual complete replacement', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [{ ...row, items: [{ id: 'a', kind: 'text', text: 'read preview' }] }], nonAppendableItemIds: ['a'], revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([append('leak', 3)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'read preview' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'actual complete' }, complete: true, revision: 4 }])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'actual complete' }]) + }) + + it('refreshes only read-only prefix fields across an event-owned snapshot fence', () => { + const prior = { id: 'prior', kind: 'text' as const, text: 'older preview' } + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [{ ...row, items: [prior, ...row.items] }], nonAppendableItemIds: ['prior'], revision: 2 }, append('Hel', 3)])) + const snapshot = read([{ ...row, items: [{ ...prior, text: 'newer preview' }, ...row.items] }], { eventOwnedTurnIds: ['t'], eventRevision: 2, nonAppendableItemIds: { t: ['prior'] } }) + live = hydrateTranscriptProjection(live, snapshot) + expect(live.turns[0].items).toEqual([{ ...prior, text: 'newer preview' }, { id: 'a', kind: 'text', text: 'Hel' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { ...prior, text: 'actual complete' }, complete: true, revision: 4 }])) + live = hydrateTranscriptProjection(live, snapshot) + expect(live.turns[0].items).toEqual([{ ...prior, text: 'actual complete' }, { id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('keeps unmatched recovery context available as a saved alternative after an actual item replacement', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'actual complete' }, complete: true, revision: 2 }])) + const saved = read([{ ...row, items: [{ id: 'item-1', kind: 'text', text: 'earlier saved commentary' }, { id: 'item-2', kind: 'text', text: 'native read preview' }] }], { nativePreviewTurnIds: ['t'] }) + const rows = mergeFreshAgentTranscriptProjection(saved, live) + expect(rows).toHaveLength(1) + expect(rows[0].items).toEqual([{ id: 'a', kind: 'text', text: 'actual complete' }]) + expect(rows[0].outputState?.savedItems).toEqual(saved.turns[0].items) + }) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index b092f6aa4..3bb439714 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -58,6 +58,23 @@ function createFreshAgentPaneStore(seenActionTypes: string[] = []) { } describe('fresh-agent-ws', () => { + it('routes native patches outside the session slice and fences complete items and stale init incarnations', () => { + const store = createFreshAgentStore() + const outer = { type: 'freshAgent.event' as const, sessionId: 'native-live', sessionType: 'freshcodex' as const, provider: 'codex' as const } + const send = (event: Record) => handleFreshAgentMessage(store.dispatch, { ...outer, event }) + send({ type: 'freshAgent.session.init', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0 }) + const sessions = store.getState().freshAgent.sessions + const row = { id: 't', turnId: 't', role: 'assistant', summary: '', items: [{ id: 'a', kind: 'text', text: '' }] } + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: 1 }, { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'Hel', revision: 2 }] }) + const key = 'freshcodex:codex:native-live' + expect(store.getState().freshAgentLive.bySession[key].projection?.turns[0].items[0]).toMatchObject({ text: 'Hel' }) + expect(store.getState().freshAgent.sessions).toBe(sessions) + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'foreign', historyGeneration: 0, ops: [{ op: 'resetTurn', nativeTurnId: 'other', turns: [row], revision: 100 }] }) + send({ type: 'freshAgent.session.init', sessionId: outer.sessionId, projectionId: 'late-old-incarnation', historyGeneration: 0 }) + expect(store.getState().freshAgentLive.bySession[key].projection?.id).toBe('p') + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'upsertItem', nativeTurnId: 't', row: { ...row, items: [] }, item: { id: 'a', kind: 'text', text: 'Hello' }, complete: true, revision: 3 }, { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 4 }] }) + expect(store.getState().freshAgentLive.bySession[key].projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) beforeEach(() => { _resetCancelledCreates() }) diff --git a/test/unit/client/store/freshAgentLiveSlice.test.ts b/test/unit/client/store/freshAgentLiveSlice.test.ts index 444f5f22c..0f3ce6963 100644 --- a/test/unit/client/store/freshAgentLiveSlice.test.ts +++ b/test/unit/client/store/freshAgentLiveSlice.test.ts @@ -19,7 +19,9 @@ import freshAgentLiveReducer, { transcriptAnnounced, transcriptExpectationsDropped, transcriptLanded, + transcriptProjectionSnapshotReceived, } from '@/store/freshAgentLiveSlice' +import { FreshAgentSnapshotSchema } from '@shared/fresh-agent-contract' import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' import { LIVE_STREAM_FLUSH_MS, streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' @@ -58,6 +60,38 @@ function streamTurn(store: Store) { } describe('freshAgentLive slice (transcript-live D3)', () => { + const codexLocator = { sessionId: 'codex-reconnect', sessionType: 'freshcodex' as const, provider: 'codex' as const } + const codexKey = 'freshcodex:codex:codex-reconnect' + const codexSnapshot = (revision = 2) => FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: codexLocator.sessionId, revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, turns: [{ id: 't', turnId: 't', role: 'assistant', summary: '', items: [{ id: 'a', kind: 'text', text: 'Hel' }] }], extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: revision, eventOwnedTurnIds: ['t'], nativePreviewTurnIds: [], nativeFinalTurnIds: [], turnRows: { t: ['t'] }, turnOutputStates: {} } } } }) + + it('buffers a reconnect delta without adopting its source, then applies it to the captured event-owned prefix', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 3 }] } }) + expect(tail(store, codexKey).projection).toBeUndefined() + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot() })) + expect(tail(store, codexKey).projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('flags a bounded reconnect-buffer gap until a current authoritative event prefix arrives', () => { + const store = createStore() + for (let revision = 3; revision < 100; revision++) handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'x'.repeat(2048), revision }] } }) + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot() })) + expect(tail(store, codexKey).projection?.needsResnapshot).toBe(true) + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot(99) })) + expect(tail(store, codexKey).projection?.needsResnapshot).not.toBe(true) + expect(tail(store, codexKey).projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('host loss preserves a prior failure and freezes completed output as unconfirmed rather than changing it to an interruption', () => { + const store = createStore() + const snapshot = codexSnapshot() + snapshot.turns[0].error = { name: 'ProviderError', message: 'The native turn failed' } + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot })) + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'settleTurn', nativeTurnId: 't', status: 'failed', revision: 3 }] } }) + expect(tail(store, codexKey).projection?.turns[0].outputState?.diagnostic).not.toBe('interrupted') + store.dispatch(markSessionLost(codexLocator)) + expect(tail(store, codexKey).projection?.turns[0]).toMatchObject({ error: { message: 'The native turn failed' }, outputState: { diagnostic: 'unsaved', refreshPending: false } }) + }) it('binds each assistant message to the first unbound streamed block of its message id and kind', () => { const store = createStore() streamTurn(store) diff --git a/test/unit/shared/fresh-agent-contract.test.ts b/test/unit/shared/fresh-agent-contract.test.ts index f7a6ace7b..b9891d44f 100644 --- a/test/unit/shared/fresh-agent-contract.test.ts +++ b/test/unit/shared/fresh-agent-contract.test.ts @@ -9,6 +9,7 @@ import { FreshAgentSessionCommandSchema, FreshAgentSnapshotSchema, FreshAgentTranscriptItemSchema, + FreshAgentTranscriptPatchSchema, FreshAgentTurnBodySchema, FreshAgentTurnPageSchema, FreshAgentTurnSchema, @@ -25,6 +26,21 @@ import { } from '../../fixtures/fresh-agent/codex/contract-fixtures.js' describe('fresh-agent shared contract schemas', () => { + it('validates native delta patches and rejects unsafe revisions or malformed operations', () => { + const patch = { type: 'freshAgent.transcript.patch', sessionId: 'thread', projectionId: 'runtime', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 'turn', rowId: 'turn:row-1', itemId: 'answer', field: 'text', text: '🙂', revision: 1 }] } + expect(FreshAgentTranscriptPatchSchema.parse(patch).ops[0]).toMatchObject({ text: '🙂', revision: 1 }) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], revision: Number.MAX_SAFE_INTEGER + 1 }] }).success).toBe(false) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], field: 'credentials' }] }).success).toBe(false) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], op: 'replaceEverything' }] }).success).toBe(false) + }) + + it('preserves observed zero usage, native model limit, supplied diff and finite interrupted provenance', () => { + const parsed = FreshAgentSnapshotSchema.parse({ ...codexContractSnapshot, tokenUsage: { observed: true, inputTokens: 0, outputTokens: 0, totalTokens: 0, modelContextWindow: 258400 }, diffs: [{ id: 'turn:t', title: 'Turn changes', diff: '+native' }], turns: [{ id: 't', turnId: 't', summary: '', items: [], outputState: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } }] }) + expect(parsed.tokenUsage.observed).toBe(true) + expect(parsed.tokenUsage.modelContextWindow).toBe(258400) + expect(parsed.diffs[0].diff).toBe('+native') + expect(parsed.turns[0].outputState?.diagnostic).toBe('interrupted') + }) it('parses Claude and Codex snapshots through one shared durable contract', () => { const claudeSnapshot = FreshAgentSnapshotSchema.parse(claudeContractSnapshot) expect(claudeSnapshot.sessionType).toBe('freshclaude') From 5e46b98c502a479a5ec471d44fafaa129af31778 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:47:31 -0700 Subject: [PATCH 10/92] fix(codex): recognize captured MCP truncation markers --- .../src/codex/projection.rs | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index f8f735cb9..f3dc247f3 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -1040,11 +1040,29 @@ fn provider_truncated(turn: &Value) -> bool { Some("commandExecution") => item["aggregatedOutput"] .as_str() .is_some_and(|s| s.contains("... command output truncated for persistence ...")), - Some("mcpToolCall") => item["result"].to_string().contains(" chars truncated"), + Some("mcpToolCall") => item["result"]["content"] + .as_array() + .into_iter() + .flatten() + .filter(|content| content["type"] == "text") + .filter_map(|content| content["text"].as_str()) + .any(has_native_mcp_truncation_marker), _ => false, }) } +fn has_native_mcp_truncation_marker(text: &str) -> bool { + // Codex 0.164 inserts an inline …N chars truncated… marker into saved MCP + // text. A mention in prose or non-text result metadata is not that marker. + text.split_inclusive('…').skip(1).any(|segment| { + segment + .strip_suffix(" chars truncated…") + .is_some_and(|count| { + !count.is_empty() && count.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1266,6 +1284,132 @@ mod tests { ); } + #[test] + fn mcp_truncation_mentions_do_not_end_persistence_refresh_or_mislabel_saved_output() { + for result in [ + json!({"content":[{"type":"text","text":"The report says 12 chars truncated by the old formatter."}]}), + json!({"content":[{"type":"text","text":"before… chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"before…12 chars truncated after"}]}), + json!({"content":[{"type":"text","text":"before…12x chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"before12 chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"ordinary output"}],"structuredContent":{"note":"…12 chars truncated…"}}), + json!({"content":[{"type":"text","text":"ordinary output"}],"_meta":{"note":"…12 chars truncated…"}}), + json!({"content":[{"type":"resource","resource":{"text":"…12 chars truncated…"}}]}), + ] { + let saved_item = json!({"type":"mcpToolCall","id":"mcp","server":"fixture","tool":"report","status":"completed","arguments":{},"result":result}); + let saved_turn = json!({"id":"t","status":"completed","itemsView":"full","items":[saved_item.clone()]}); + for retained_differs in [false, true] { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + let mut retained_item = saved_item.clone(); + if retained_differs { + retained_item["result"] = + json!({"content":[{"type":"text","text":"retained complete result"}]}); + } + item(&mut p, "t", retained_item.clone(), true); + ended(&mut p, "t", "completed"); + for attempt in 1..=3 { + let view = snapshot(&mut p, json!([saved_turn.clone()])); + let state = &view.metadata["turnOutputStates"]["t"]; + assert_eq!(view.raw["thread"]["turns"][0]["items"][0], retained_item); + assert_eq!( + state["refreshPending"], + retained_differs && attempt < 3, + "ordinary MCP result must not end persistence refresh: {result}" + ); + if retained_differs { + assert_eq!(state["provenance"], "retained"); + assert_eq!(state["savedTurns"][0]["items"][0]["result"], result); + assert_eq!( + state["diagnostic"], + if attempt < 3 { + Value::Null + } else { + json!("unsaved") + } + ); + } else { + assert_eq!(state["provenance"], "saved"); + assert!( + state["diagnostic"].is_null(), + "ordinary MCP result was labeled truncated: {result}" + ); + } + } + } + } + } + + #[test] + fn captured_0164_mcp_truncation_preserves_exact_saved_and_richer_retained_results() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-projection-0.164.json" + )) + .unwrap(); + let case = corpus["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| { + case["parameters"]["largeOutput"] == true + && case["parameters"]["historyMode"] == "paginated" + }) + .unwrap(); + let mut native = case["read"].clone(); + // Isolate MCP so a command's separate persistence marker cannot make + // this test pass without recognizing the captured MCP representation. + native["thread"]["turns"][0]["items"] + .as_array_mut() + .unwrap() + .retain(|item| item["type"] == "mcpToolCall"); + let thread = native["thread"]["id"].as_str().unwrap(); + let turn = native["thread"]["turns"][0]["id"].as_str().unwrap(); + let mut p = CodexProjection::new("actual-mcp-capture".into(), thread.into()); + let mut retained_result = Value::Null; + for notification in case["notifications"].as_array().unwrap() { + let method = notification["method"].as_str().unwrap(); + let params = ¬ification["params"]; + if matches!(method, "turn/started" | "turn/completed") + || params["item"]["type"] == "mcpToolCall" + { + p.apply(method, params).unwrap(); + } + if method == "item/completed" && params["item"]["type"] == "mcpToolCall" { + retained_result = params["item"]["result"].clone(); + } + } + let saved_result = &native["thread"]["turns"][0]["items"][0]["result"]; + assert!(saved_result["content"][0]["text"].as_str().unwrap().len() > 64_000); + assert!( + retained_result["content"][0]["text"] + .as_str() + .unwrap() + .len() + > 128_000 + ); + let view = p + .accept_native_history(&native, &p.begin_history_read()) + .unwrap(); + assert_eq!(view.raw["thread"]["turns"], native["thread"]["turns"]); + let state = &view.metadata["turnOutputStates"][turn]; + assert_eq!(state["provenance"], "saved-and-retained"); + assert_eq!(state["diagnostic"], "provider-truncated"); + assert_eq!(state["refreshPending"], false); + assert_eq!( + state["retainedTurns"][0]["items"][0]["result"], + retained_result + ); + let mut cold = CodexProjection::new("cold-mcp-capture".into(), thread.into()); + let saved = cold + .accept_native_history(&native, &cold.begin_history_read()) + .unwrap(); + assert_eq!(saved.raw["thread"]["turns"], native["thread"]["turns"]); + assert_eq!( + saved.metadata["turnOutputStates"][turn], + json!({"provenance":"saved","diagnostic":"provider-truncated","refreshPending":false}) + ); + } + #[test] fn reads_crossing_history_or_owner_mutation_are_rejected_and_removed_turns_never_reopen() { let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); From 8326525148d971c58a62ba29a363e11285f54500 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:48:00 -0700 Subject: [PATCH 11/92] feat(codex): preserve native tool and child activity --- .../freshell-agent-runtime/src/host_actor.rs | 40 ++ .../src/host_actor_tests.rs | 47 ++ crates/freshell-freshagent/src/codex.rs | 171 ++++-- .../freshell-freshagent/src/codex/children.rs | 507 ++++++++++++++++++ .../freshell-freshagent/src/codex/metadata.rs | 28 +- crates/freshell-freshagent/src/rest_lane.rs | 14 + crates/freshell-freshagent/src/snapshot.rs | 89 +++ crates/freshell-runtime-client/src/lib.rs | 82 ++- crates/freshell-runtime-protocol/src/lib.rs | 46 ++ .../src/fresh_agent_proxy_rest.rs | 81 +++ .../src/fresh_agent_proxy_tests.rs | 125 +++++ crates/freshell-session-host/src/main.rs | 108 +++- .../src/providers/fresh_agent.rs | 15 + crates/freshell-supervisor/src/service.rs | 108 +++- docs/index.html | 2 +- shared/fresh-agent-contract.ts | 32 +- .../fresh-agent/FreshAgentChildObserver.tsx | 83 +++ .../fresh-agent/FreshAgentItemCard.tsx | 68 ++- .../fresh-agent/FreshAgentSidebar.tsx | 6 +- .../fresh-agent/FreshAgentTranscript.tsx | 11 +- src/components/fresh-agent/FreshAgentView.tsx | 13 +- .../fresh-agent/shared/child-activity.ts | 10 + src/store/paneTypes.ts | 2 + src/store/panesSlice.ts | 3 + src/store/persistedState.ts | 6 + .../freshcodex-child-observer-rust.spec.ts | 88 +++ .../freshcodex-live-transcript-rust.spec.ts | 11 +- .../codex-app-server/fake-app-server.mjs | 6 +- .../codex/native-children-0.164.json | 381 +++++++++++++ .../FreshAgentChildObserver.test.tsx | 85 +++ .../fresh-agent/FreshAgentItemCard.test.tsx | 34 +- .../fresh-agent/FreshAgentSidebar.test.tsx | 14 +- .../fresh-agent/FreshAgentTranscript.test.tsx | 21 + .../fresh-agent/FreshAgentView.test.tsx | 30 ++ test/unit/client/store/persistedState.test.ts | 11 + test/unit/shared/fresh-agent-contract.test.ts | 13 + 36 files changed, 2303 insertions(+), 88 deletions(-) create mode 100644 crates/freshell-freshagent/src/codex/children.rs create mode 100644 src/components/fresh-agent/FreshAgentChildObserver.tsx create mode 100644 src/components/fresh-agent/shared/child-activity.ts create mode 100644 test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts create mode 100644 test/fixtures/fresh-agent/codex/native-children-0.164.json create mode 100644 test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 0c21cfd9c..9874ffa96 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -305,6 +305,10 @@ pub trait FreshAgentTransport: Send + Sync { Err("provider does not expose a hosted snapshot".into()) } + async fn read_child(&self, _child_thread_id: &str) -> Result { + Err("provider does not expose child observation".into()) + } + /// A registered zero-turn identity is transport-owned, not a durable native identity. /// Only transports that can prove their current local registration opt in. async fn registered_snapshot_identity(&self) -> Option { @@ -1129,6 +1133,42 @@ impl FreshAgentHostActor { Ok(snapshot) } + pub async fn read_child(&self, child_thread_id: &str) -> Result { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.is_none() + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + let value = self + .transport + .read_child(child_thread_id) + .await + .map_err(ActorError::Transport)?; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || !self.transport.is_live().await + || value["parentThreadId"].as_str() != before.native_session_id.as_deref() + || value["childThreadId"].as_str() != Some(child_thread_id) + || (value.get("snapshot").is_some() + && (value["snapshot"]["threadId"].as_str() != Some(child_thread_id) + || value["snapshot"]["provider"] != "codex")) + { + return Err(ActorError::NativeIdentityMismatch); + } + if serde_json::to_vec(&value) + .map_err(|error| ActorError::Transport(error.to_string()))? + .len() + > freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES - 4096 + { + return Err(ActorError::Transport( + "child observation exceeds history reply frame limit".into(), + )); + } + Ok(value) + } + pub async fn record_event(&self, event: AgentEvent) -> Result { let mut state = self.state.lock().await; let sequence = diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 1c6ac07fd..763498ed2 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -125,6 +125,53 @@ impl FreshAgentTransport for SnapshotTransport { } Ok(self.value.clone()) } + async fn read_child(&self, _: &str) -> Result { + self.snapshot().await + } +} + +#[tokio::test] +async fn child_observation_preserves_actor_state_and_rejects_cross_parent_child_and_liveness() { + for scenario in [ + "saved", + "live", + "wrong-parent", + "wrong-child", + "exit-during-read", + "oversized", + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"parentThreadId":if scenario == "wrong-parent" {"foreign"} else {"parent-native"},"childThreadId":if scenario == "wrong-child" {"foreign"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","turns":if scenario == "oversized" {"x".repeat(freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES)} else {"Exact observed body".into()}}}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: scenario == "exit-during-read", + initial_native: Some("parent-native".into()), + registered: None, + pause: None, + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "child-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let result = actor.read_child("child-native").await; + assert_eq!( + result.is_ok(), + matches!(scenario, "saved" | "live"), + "{scenario}" + ); + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before + ); + assert_eq!( + actor.profile().await.native_session_id.as_deref(), + Some("parent-native") + ); + } } #[tokio::test] diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 3f0dba853..31cacd6f7 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -82,6 +82,7 @@ use crate::summary::{ }; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; +pub mod children; mod controls; mod metadata; pub(crate) mod native_history; @@ -320,6 +321,7 @@ pub struct FreshCodexState { broadcast_tx: Arc>, /// threadId → live codex session (client + settings + owned sidecar). sessions: Arc>>, + child_reads: Arc>>, /// The `settings.freshAgent.enabled` gate the WS `freshAgent.create` requires /// (default off; flipped true by `PATCH /api/settings`, as a real freshcodex user does). fresh_agent_enabled: Arc, @@ -778,6 +780,7 @@ impl FreshCodexState { sidecar_launch_context: CodexSidecarLaunchContext::default(), broadcast_tx, sessions: Arc::new(TokioMutex::new(HashMap::new())), + child_reads: Arc::new(TokioMutex::new(HashMap::new())), fresh_agent_enabled: Arc::new(AtomicBool::new(enabled)), settings: Arc::new(TokioMutex::new(settings)), auth_token, @@ -9319,7 +9322,8 @@ fn codex_normalize_command_status(status: Option<&str>) -> &'static str { Some("inProgress") => "running", Some("declined") => "declined", Some("failed") => "failed", - _ => "completed", + Some("completed") => "completed", + _ => "running", } } @@ -9328,7 +9332,8 @@ fn codex_normalize_tool_status(status: Option<&str>) -> &'static str { match status { Some("inProgress") => "running", Some("failed") => "failed", - _ => "completed", + Some("completed") => "completed", + _ => "running", } } @@ -9370,6 +9375,18 @@ fn codex_user_message_text_parts(item: &Value) -> Vec<(usize, String)> { .and_then(Value::as_str) .unwrap_or("") .to_string() + } else if matches!(part_type, Some("image" | "localImage" | "input_image")) { + let reference = part["path"] + .as_str() + .or(part["url"].as_str()) + .or(part["image_url"].as_str()) + .unwrap_or(""); + // Never place inline image bytes in a transcript string. + if reference.is_empty() || reference.starts_with("data:") { + "Image attachment".to_string() + } else { + format!("Image: {reference}") + } } else { format!("[{}]", part_type.unwrap_or("input")) }; @@ -9387,29 +9404,10 @@ fn codex_user_message_text_parts(item: &Value) -> Vec<(usize, String)> { vec![(0, String::new())] } -/// `normalizeCodexItem` (`normalize.ts:238-473`): map ONE raw codex thread item into its -/// `FreshAgentTranscriptItemSchema`-shaped item(s). Every `CodexThreadItemTypeSchema` variant -/// (`protocol.ts:113-129`) is covered. -/// -/// DELIBERATE DEVIATION from legacy for an unrecognized `type`: the reference's -/// `readCodexThreadItemType`/`assertNever` both throw `Unsupported Codex thread item type: -/// ${value}` (`normalize.ts:141-147,123-125`), which `router.ts`'s catch-all turns into a bare -/// 500 for the ENTIRE thread (`router.ts:165-166`) -- legacy would 500 here too, so this is not -/// a port regression, but it is a real bug either way. The real codex CLI (observed: 0.144.5) -/// emits `subAgentActivity`, an item type absent from BOTH the frozen legacy protocol -/// (`protocol.ts:113-129`, 16 variants) and current `origin/main`. Hard-failing an entire -/// historical thread over one item type neither codebase has caught up to yet makes the -/// snapshot endpoint unusable for real transcripts -- proven against real staging data (a -/// genuinely readable thread 500'd solely because of one `subAgentActivity` item). So an -/// unrecognized item type returns `Ok(vec![])` (the item is silently omitted; every other item -/// in the thread still renders) instead of `Err`. This mirrors the opencode side's existing -/// precedent: an unrecognized opencode part also degrades to `[]`, not a hard error (see -/// `opencode_item_from_part`). -/// -/// Unlike the reference, a missing `item.id` does NOT throw (`readCodexItemId`, -/// `normalize.ts:149-156`) -- the caller ([`build_codex_turn_json`]) already falls back to a -/// synthetic `{turnId}:item-{index}` id, matching this module's existing tolerant-read -/// convention elsewhere (documented divergence, not a silent one). +/// Normalize the visible Codex 0.164 thread items into the shared transcript. +/// Unknown future items emit a structured diagnostic and are omitted before +/// role bookkeeping, so they cannot make a blank conversation row or fail +/// otherwise readable history. Missing IDs use the caller's stable synthetic ID. fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result, String> { match item_type { "userMessage" => Ok(codex_user_message_text_parts(item) @@ -9424,7 +9422,11 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result { let summary = codex_string_array(item.get("summary")); @@ -9515,6 +9517,24 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result { + let Some(child) = item["agentThreadId"].as_str().filter(|id| !id.is_empty()) else { + tracing::warn!(provider=PROVIDER, native_item_id=item_id, item_type, reason="missing_child_identity", "freshagent.codex.item_unavailable"); + return Ok(vec![]); + }; + let status = match item["kind"].as_str() { + Some("completed") => "completed", + Some("interrupted") => "interrupted", + _ => "running", + }; + let mut mapped = json!({"id":item_id, "kind":"task_delegation", "status":status, "title":format!("Child agent {}", item["agentPath"].as_str().unwrap_or(child)), "childSessionId":child, "provider":"codex", "nativeStatus":item["kind"]}); + for field in ["model", "reasoningEffort"] { + if let Some(value) = item[field].as_str() { mapped[field] = json!(value); } + } + Ok(vec![mapped]) + } + "functionCallOutput" => Ok(vec![json!({"id":item_id, "kind":"tool_result", "toolUseId":item_id, "content":item.get("output").cloned().unwrap_or_else(|| json!("Function output unavailable")), "isError":false})]), + "sleep" => Ok(vec![json!({"id":item_id, "kind":"dynamic_tool", "tool":"Sleep", "status":"completed", "arguments":{"durationMs":item["durationMs"]}})]), "webSearch" => Ok(vec![json!({ "id": item_id, "kind": "web_search", @@ -9555,16 +9575,14 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result skip, don't error the whole thread. // See the doc comment above for the full rationale (real codex 0.144.5's // `subAgentActivity`, unknown to both frozen legacy and current `origin/main`). - _ => Ok(vec![]), + _ => { + tracing::warn!(provider=PROVIDER, native_item_id=item_id, item_type, reason="unsupported_native_item", "freshagent.codex.item_unavailable"); + Ok(vec![]) + }, } } -/// `classifyCodexItemRole(item)` (`normalize.ts:475-501`): every `CodexThreadItemTypeSchema` -/// variant maps to exactly one display role. The caller ([`build_codex_turn_json`]) classifies -/// EVERY raw item, INCLUDING ones [`map_codex_item`] mapped to an empty item list -/// (unrecognized types) -- the catch-all arm IS reachable there and decides whether that item -/// pushes a zero-item display row -- so the catch-all is a runtime path, not just a -/// compile-time safety net (it still matches the reference's `assertNever` default in spirit). +/// Select the display role of a successfully mapped native item. fn classify_codex_item_role(item_type: &str) -> &'static str { match item_type { "userMessage" => "user", @@ -9574,6 +9592,9 @@ fn classify_codex_item_role(item_type: &str) -> &'static str { | "mcpToolCall" | "dynamicToolCall" | "collabAgentToolCall" + | "subAgentActivity" + | "functionCallOutput" + | "sleep" | "webSearch" | "imageView" | "imageGeneration" => "tool", @@ -9887,21 +9908,9 @@ struct CodexPendingRow { items: Vec, } -/// `normalizeCodexDisplayTurns` (`normalize.ts:600-684`), restricted to this committed-turns -/// REST READ path (`getSnapshot`, `adapter.ts:1082-1122`): SPLIT one raw codex `turn` record -/// (`makeThread`/real app-server shape: `{id, status, error?, items:[{type,id,...}], ...}`) -/// into MULTIPLE `FreshAgentTurnSchema`-shaped display turns, one per maximal run of -/// contiguous-same-role raw items (`classifyCodexItemRole`, `normalize.ts:475-501` -- -/// ported as [`classify_codex_item_role`]). Every raw item is mapped via [`map_codex_item`] -/// (the full `normalizeCodexItem` switch, `normalize.ts:238-473`) before being folded into its -/// row. DELIBERATE DEVIATION: an unrecognized item type no longer fails the turn -- per -/// [`map_codex_item`]'s doc comment, it maps to an empty item list. The loop does NOT skip it: -/// its role is still classified (the catch-all arm of [`classify_codex_item_role`] yields -/// `assistant`) and folded into `has_assistant_output`/`has_user_output`/`all_items_are_user`, -/// and when that role differs from the previous row's a new row is pushed whose `items` is the -/// empty list -- a ZERO-ITEM display row with a BLANK summary (`summarize_codex_items(&[])` -/// returns `""`). When the role matches the previous row, `row.items.extend(mapped)` extends by -/// nothing and no empty row appears. Every other item in the turn still renders normally. +/// Split each raw native turn into maximal runs of contiguous display roles. +/// Unknown or unsupported items are omitted before role bookkeeping; they +/// never manufacture an empty assistant or user row. /// /// A turn-level `error` (`normalize.ts:509-519,640-641`) or a completed turn whose only items /// are `user`-role with no `assistant` output (`normalize.ts:642-652`) each APPEND A NEW @@ -9952,6 +9961,9 @@ fn build_codex_turn_json(raw_turn: &Value, ordinal: usize) -> Result, .map(str::to_string) .unwrap_or_else(|| format!("{turn_id}:item-{index}")); let mapped = map_codex_item(&item_id, item, item_type)?; + if mapped.is_empty() { + continue; + } let role = classify_codex_item_role(item_type); match role { "assistant" => has_assistant_output = true, @@ -26923,6 +26935,67 @@ pub(crate) mod tests { // -- Batch D PR-6: rich transcript items for the codex snapshot endpoint -- + #[test] + fn native_child_activity_function_output_and_sleep_remain_visible() { + for (native, status) in [ + ("started", "running"), + ("interacted", "running"), + ("interrupted", "interrupted"), + ("completed", "completed"), + ] { + let mapped = map_codex_item("activity", &json!({"type":"subAgentActivity", "id":"activity", "agentPath":"/reviewer", "agentThreadId":"exact-child", "kind":native, "model":"native-model"}), "subAgentActivity").unwrap(); + assert_eq!( + mapped.len(), + 1, + "native activity must remain visible: {native}" + ); + assert_eq!(mapped[0]["childSessionId"], "exact-child"); + assert_eq!(mapped[0]["status"], status); + assert_eq!(mapped[0]["model"], "native-model"); + } + for output in [ + json!("Exact function output"), + json!([{"type":"input_text","text":"Typed function output"},{"type":"input_image","image_url":"/tmp/output.png"},{"type":"future","result":"visible value"}]), + ] { + let mapped = map_codex_item( + "function-output", + &json!({"output":output}), + "functionCallOutput", + ) + .unwrap(); + assert_eq!(mapped[0]["kind"], "tool_result"); + assert_eq!(mapped[0]["toolUseId"], "function-output"); + assert_eq!(mapped[0]["content"], output); + } + let sleep = map_codex_item("sleep", &json!({"durationMs":1250}), "sleep").unwrap(); + assert_eq!(sleep[0]["tool"], "Sleep"); + assert_eq!(sleep[0]["arguments"]["durationMs"], 1250); + } + + #[test] + fn native_images_keep_paths_and_unknown_items_never_create_blank_rows() { + let mapped = map_codex_item("image-user", &json!({"content":[{"type":"localImage","path":"/tmp/upload.png"},{"type":"image","url":"https://example.test/image.png"}]}), "userMessage").unwrap(); + assert!(mapped[0]["text"] + .as_str() + .unwrap() + .contains("/tmp/upload.png")); + assert!(mapped[1]["text"] + .as_str() + .unwrap() + .contains("https://example.test/image.png")); + let turns = build_codex_turn_json( + &json!({"id":"unknown-turn","items":[{"type":"futureVariant","id":"future"}]}), + 0, + ) + .unwrap(); + assert!( + turns.is_empty() + || turns + .iter() + .all(|turn| !turn["items"].as_array().unwrap().is_empty()) + ); + } + #[test] fn map_codex_item_command_execution_renders_command_kind_with_exact_schema_keys() { let item = json!({ @@ -27047,7 +27120,7 @@ pub(crate) mod tests { ); assert_eq!( mapped[1], - json!({ "id": "item-6:part:1", "kind": "text", "text": "[image]" }) + json!({ "id": "item-6:part:1", "kind": "text", "text": "Image attachment" }) ); } diff --git a/crates/freshell-freshagent/src/codex/children.rs b/crates/freshell-freshagent/src/codex/children.rs new file mode 100644 index 000000000..2e1e5a82d --- /dev/null +++ b/crates/freshell-freshagent/src/codex/children.rs @@ -0,0 +1,507 @@ +//! Parent-scoped child history reads. This path never acquires a child writer. +use super::*; + +#[derive(Debug)] +pub enum ChildReadError { + InvalidFence, + UnrelatedChild, + SourceUnavailable, +} + +impl FreshCodexState { + pub async fn read_child( + &self, + parent_session_id: &str, + child_thread_id: &str, + observed_epoch: Option, + observed_generation: Option, + ) -> Result { + crate::ownership_lane::wire_fence(observed_epoch, observed_generation) + .map_err(|_| ChildReadError::InvalidFence)?; + let parent = self.resolve_ownership_key(parent_session_id); + let owner = self.ownership_snapshot(PROVIDER, &parent); + if observed_epoch.is_some_and(|value| value != owner.epoch) + || observed_generation.is_some_and(|value| value != owner.generation) + { + return Err(ChildReadError::SourceUnavailable); + } + // Resolve only the parent's existing registration. No child lifecycle, + // provider startup, alternate HOME or gateway-side saved-history fallback. + let (client, _) = self + .snapshot_runtime_for(&parent) + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + let parent_snapshot = self + .get_snapshot(&parent, None) + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + if !parent_snapshot["childThreads"] + .as_array() + .into_iter() + .flatten() + .any(|child| child["threadId"] == child_thread_id) + { + return Err(ChildReadError::UnrelatedChild); + } + let projection = self + .sessions + .lock() + .await + .get(&parent) + .filter(|session| Arc::ptr_eq(&session.client, &client)) + .map(|session| session.projection.clone()) + .ok_or(ChildReadError::SourceUnavailable)?; + let fence = projection + .lock() + .expect("codex projection lock") + .begin_history_read(); + let read = client.read_thread(child_thread_id, true).await; + let (raw, materialized, unmaterialized) = match read { + Ok(raw) => (raw, true, false), + Err(error) => { + let unmaterialized = + is_codex_include_turns_unavailable(&error) || is_codex_thread_not_found(&error); + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, error=%error, "freshagent.codex.child_history_unavailable"); + match client.read_thread(child_thread_id, false).await { + Ok(raw) => (raw, false, unmaterialized), + Err(_) => { + self.check_child_source(&parent, &client, &projection, &fence, &owner) + .await?; + if !unmaterialized { + return Err(ChildReadError::SourceUnavailable); + } + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + )); + } + } + } + }; + self.check_child_source(&parent, &client, &projection, &fence, &owner) + .await?; + if raw["thread"]["id"].as_str() != Some(child_thread_id) + || raw["thread"]["parentThreadId"] + .as_str() + .is_some_and(|id| id != parent) + { + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, reason="native_identity_mismatch", "freshagent.codex.child_read_rejected"); + return Err(ChildReadError::UnrelatedChild); + } + if raw["thread"]["ephemeral"] == true { + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "ephemeral_history_unavailable", + false, + )); + } + if !materialized && !unmaterialized { + return Err(ChildReadError::SourceUnavailable); + } + if !materialized || raw["thread"]["turns"].as_array().is_none_or(Vec::is_empty) { + let live = + normalize_codex_thread_status(&raw["thread"]["status"]) == CodexStatus::Running; + let mut attempts = self.child_reads.lock().await; + // The registration/projection id changes at a parent source replacement. + let source = projection.lock().expect("codex projection lock").id.clone(); + let count = attempts + .entry((parent.clone(), child_thread_id.to_owned(), source)) + .or_default(); + *count = count.saturating_add(1); + return Ok(if live && *count < 3 { + observation( + &parent, + child_thread_id, + "pending", + "history_unmaterialized", + true, + ) + } else { + observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + ) + }); + } + let mut snapshot = + build_codex_snapshot_json(child_thread_id, &raw, false, None, None, false) + .map_err(|_| ChildReadError::SourceUnavailable)?; + if snapshot["turns"].as_array().is_none_or(Vec::is_empty) { + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, reason="no_supported_history_items", "freshagent.codex.child_history_unavailable"); + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + )); + } + stamp_readonly_child(&mut snapshot, &parent); + // Availability does not imply a turn completed or persisted every token. + snapshot["extensions"]["codex"]["childHistory"] = json!({ + "threadStatus":raw["thread"]["status"], "ephemeral":raw["thread"]["ephemeral"], + "turns":raw["thread"]["turns"].as_array().into_iter().flatten().map(|turn| json!({"turnId":turn["id"],"status":turn["status"],"itemsView":turn["itemsView"]})).collect::>() + }); + let status = + if normalize_codex_thread_status(&raw["thread"]["status"]) == CodexStatus::Running { + "live" + } else { + "saved" + }; + Ok( + json!({"parentThreadId":parent, "childThreadId":child_thread_id, "status":status, "snapshot":snapshot}), + ) + } + + async fn check_child_source( + &self, + parent: &str, + client: &Arc, + projection: &Arc>, + fence: &projection::SnapshotReadFence, + owner: &freshell_ownership::OwnershipSnapshot, + ) -> Result<(), ChildReadError> { + let current = self.ownership_snapshot(PROVIDER, parent); + let same = self + .sessions + .lock() + .await + .get(parent) + .is_some_and(|session| { + Arc::ptr_eq(&session.client, client) && Arc::ptr_eq(&session.projection, projection) + }); + if !same + || current.epoch != owner.epoch + || current.generation != owner.generation + || current.state != owner.state + || !projection + .lock() + .expect("codex projection lock") + .matches_fence(fence) + { + tracing::warn!( + provider = PROVIDER, + session_id = parent, + "freshagent.codex.child_source_changed" + ); + return Err(ChildReadError::SourceUnavailable); + } + Ok(()) + } +} + +pub fn stamp_readonly_child(snapshot: &mut Value, parent: &str) { + if let Some(capabilities) = snapshot["capabilities"].as_object_mut() { + for value in capabilities.values_mut() { + if value.is_boolean() { + *value = json!(false); + } + } + capabilities.remove("settingScopes"); + } + snapshot["pendingApprovals"] = json!([]); + snapshot["pendingQuestions"] = json!([]); + snapshot["extensions"]["codex"]["observer"] = json!({"parentThreadId":parent, "readOnly":true}); +} + +pub fn observation( + parent: &str, + child: &str, + status: &str, + reason: &str, + retryable: bool, +) -> Value { + json!({"parentThreadId":parent,"childThreadId":child,"status":status,"reason":reason,"retryable":retryable}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parent() -> Value { + json!({"thread":{"id":"parent","status":{"type":"idle"},"ephemeral":false,"turns":[{"id":"parent-turn","status":"completed","items":[{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"parent","receiverThreadIds":["child"],"agentsStates":{"child":{"status":"running"}}}]}]}}) + } + + async fn fixture( + child: Value, + ) -> ( + FreshCodexState, + tokio::task::JoinHandle>, + ) { + fixture_for(parent(), child).await + } + + async fn fixture_for( + parent: Value, + child: Value, + ) -> ( + FreshCodexState, + tokio::task::JoinHandle>, + ) { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let (state, _) = super::super::tests::state_with_bus(); + state + .insert_session_for_test( + parent["thread"]["id"].as_str().unwrap(), + Arc::new(client), + None, + ) + .await; + let driver = tokio::spawn(async move { + let mut reads = vec![]; + loop { + let request = + tokio::time::timeout(Duration::from_secs(2), peer.expect_request()).await; + let Ok((id, method, params)) = request else { + break; + }; + reads.push((method.clone(), params.clone())); + if method == "initialize" { + peer.respond(&id, json!({"userAgent":"fixture", "codexHome":"/owned", "platformFamily":"unix", "platformOs":"linux"})); + let _ = peer.expect_notification().await; + } else if method == "thread/read" { + if params["threadId"] != parent["thread"]["id"] + && params["includeTurns"] == true + && child["_historyUnavailable"] == true + { + peer.respond_error( + &id, + -32600, + child["_historyError"] + .as_str() + .unwrap_or("includeTurns is unavailable before first user message"), + ); + continue; + } + peer.respond( + &id, + if params["threadId"] == parent["thread"]["id"] { + parent.clone() + } else { + child.clone() + }, + ); + } else { + panic!("observer dispatched a writer operation: {method}"); + } + } + reads + }); + (state, driver) + } + + #[tokio::test] + async fn child_read_replays_captured_0164_active_final_and_cold_saved_histories() { + let capture: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-children-0.164.json" + )) + .unwrap(); + let parent = capture["parent"]["thread"]["id"].as_str().unwrap(); + for phase in ["active", "final", "saved"] { + let raw = capture[phase].clone(); + let child = raw["thread"]["id"].as_str().unwrap().to_owned(); + let (state, driver) = fixture_for(capture["parent"].clone(), raw.clone()).await; + let result = state.read_child(parent, &child, None, None).await.unwrap(); + assert_eq!(result["parentThreadId"], parent); + assert_eq!(result["childThreadId"], child); + assert_eq!( + result["snapshot"]["extensions"]["codex"]["childHistory"]["threadStatus"], + raw["thread"]["status"] + ); + assert_eq!(result["snapshot"]["capabilities"]["send"], false); + if phase != "active" { + assert!(result["snapshot"] + .to_string() + .contains("Exact child final answer.")); + } + state.shutdown().await; + assert!(driver + .await + .unwrap() + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + } + } + + #[tokio::test] + async fn child_read_uses_only_parent_client_and_preserves_exact_saved_body() { + let (state, driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"ephemeral":false,"turns":[{"id":"child-turn","status":"completed","items":[{"id":"answer","type":"agentMessage","text":"Exact child answer"}]}]}})).await; + let observed = state.read_child("parent", "child", Some(0), Some(0)).await; + state.shutdown().await; + let reads = driver.await.unwrap(); + let observed = observed.expect("available parent-scoped native history"); + assert_eq!(observed["status"], "saved"); + assert_eq!(observed["parentThreadId"], "parent"); + assert_eq!(observed["childThreadId"], "child"); + assert_eq!(observed["snapshot"]["threadId"], "child"); + assert_eq!( + observed["snapshot"]["turns"][0]["items"][0]["text"], + "Exact child answer" + ); + for flag in [ + "send", + "interrupt", + "fork", + "approvals", + "questions", + "undo", + "redo", + ] { + assert_eq!(observed["snapshot"]["capabilities"][flag], false); + } + assert!(reads + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + assert!(reads + .iter() + .any(|(_, params)| params["threadId"] == "child" && params["includeTurns"] == true)); + } + + #[tokio::test] + async fn child_read_rejects_unrelated_identity_without_reading_it() { + let (state, driver) = fixture(json!({})).await; + let result = state.read_child("parent", "unrelated", None, None).await; + state.shutdown().await; + let reads = driver.await.unwrap(); + assert!(matches!(result, Err(ChildReadError::UnrelatedChild))); + assert!(!reads + .iter() + .any(|(_, params)| params["threadId"] == "unrelated")); + } + + #[tokio::test] + async fn child_read_pending_materialization_is_bounded_and_ephemeral_is_terminal() { + for ephemeral in [false, true] { + let (state, driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","ephemeral":ephemeral,"status":{"type":"active"},"turns":[]}})).await; + for attempt in 1..=4 { + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + assert!(value.get("snapshot").is_none()); + if ephemeral { + assert_eq!(value["status"], "unavailable"); + assert_eq!(value["reason"], "ephemeral_history_unavailable"); + assert_eq!(value["retryable"], false); + } else if attempt < 3 { + assert_eq!(value["status"], "pending"); + assert_eq!(value["reason"], "history_unmaterialized"); + } else { + assert_eq!(value["status"], "unavailable"); + } + } + state.shutdown().await; + assert!(driver + .await + .unwrap() + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + } + } + + #[tokio::test] + async fn child_read_ephemeral_metadata_refusal_has_no_empty_success_snapshot() { + let (state,driver) = fixture(json!({"_historyUnavailable":true,"thread":{"id":"child","parentThreadId":"parent","ephemeral":true,"status":{"type":"active"},"turns":[]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + assert_eq!(value["reason"], "ephemeral_history_unavailable"); + assert!(value.get("snapshot").is_none()); + assert_eq!(value["retryable"], false); + state.shutdown().await; + let reads = driver.await.unwrap(); + assert!(reads + .iter() + .any(|(_, params)| params["threadId"] == "child" && params["includeTurns"] == false)); + } + + #[tokio::test] + async fn child_read_unknown_only_history_does_not_return_an_empty_readable_snapshot() { + let (state,driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"turns":[{"id":"future-turn","items":[{"id":"future","type":"futureVariant"}]}]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + state.shutdown().await; + let _ = driver.await.unwrap(); + assert_eq!(value["status"], "unavailable"); + assert_eq!(value["reason"], "history_unavailable"); + assert!(value.get("snapshot").is_none()); + } + + #[tokio::test] + async fn child_read_temporary_source_failure_is_retryable_source_unavailable() { + let (state,driver) = fixture(json!({"_historyUnavailable":true,"_historyError":"temporary provider read failure","thread":{"id":"child","parentThreadId":"parent","ephemeral":false,"status":{"type":"active"},"turns":[]}})).await; + let result = state.read_child("parent", "child", None, None).await; + state.shutdown().await; + let _ = driver.await.unwrap(); + assert!(matches!(result,Err(ChildReadError::SourceUnavailable)),"temporary source failure must not become pending or permanently missing history: {result:?}"); + } + + #[tokio::test] + async fn child_read_rejects_replaced_parent_source_during_paused_read() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let (state, _) = super::super::tests::state_with_bus(); + state + .insert_session_for_test("parent", Arc::new(client), None) + .await; + let read = { + let state = state.clone(); + tokio::spawn(async move { state.read_child("parent", "child", None, None).await }) + }; + let (id, method, _) = tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("parent initialization must be requested"); + assert_eq!(method, "initialize"); + peer.respond(&id,json!({"userAgent":"fixture","codexHome":"/owned","platformFamily":"unix","platformOs":"linux"})); + let _ = tokio::time::timeout(Duration::from_secs(3), peer.expect_notification()) + .await + .expect("initialized notification"); + let (id, method, params) = + tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("parent history read"); + assert_eq!(method, "thread/read"); + assert_eq!(params["threadId"], "parent"); + peer.respond(&id, parent()); + let (id, method, params) = + tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("child history read after parent membership"); + assert_eq!(method, "thread/read"); + assert_eq!(params["threadId"], "child"); + state + .sessions + .lock() + .await + .get_mut("parent") + .unwrap() + .projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + "replacement".into(), + "parent".into(), + ))); + peer.respond(&id,json!({"thread":{"id":"child","parentThreadId":"parent","turns":[{"id":"t","items":[{"id":"a","type":"agentMessage","text":"Stale body"}]}]}})); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(3), read) + .await + .expect("child source refusal must settle") + .unwrap(), + Err(ChildReadError::SourceUnavailable) + )); + // ChannelTransport's reader owns the receive lock while awaiting the + // peer. End that owned peer before asking the client to close it. + drop(peer); + state.shutdown().await; + } +} diff --git a/crates/freshell-freshagent/src/codex/metadata.rs b/crates/freshell-freshagent/src/codex/metadata.rs index 252bb8460..1369473bc 100644 --- a/crates/freshell-freshagent/src/codex/metadata.rs +++ b/crates/freshell-freshagent/src/codex/metadata.rs @@ -43,9 +43,35 @@ pub(super) fn transcript_metadata(turns: &[Value]) -> (Vec, Vec) { .filter(|id| !id.is_empty()) { // Keep the first origin (spawn), not a later wait/send operation. - children.entry(id.to_owned()).or_insert_with(|| json!({ + let child = children.entry(id.to_owned()).or_insert_with(|| json!({ "id": id, "threadId": id, "origin": item.get("tool").and_then(Value::as_str).unwrap_or("codex"), })); + let state = &item["agentsStates"][id]; + for field in ["status", "model", "result", "error"] { + let value = state.get(field).or_else(|| match field { + "result" => state.get("message"), + "model" => item.get("model"), + _ => None, + }); + if let Some(value) = value.filter(|value| !value.is_null()) { + child[field] = json!(value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string())); + } + } + } + } + Some("task_delegation") => { + if let Some(id) = item["childSessionId"].as_str().filter(|id| !id.is_empty()) { + let child = children.entry(id.to_owned()).or_insert_with( + || json!({"id":id,"threadId":id,"origin":"codex","title":item["title"]}), + ); + for field in ["status", "model", "result"] { + if let Some(value) = item[field].as_str() { + child[field] = json!(value); + } + } } } _ => {} diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index 73df0e26f..e3a80f00c 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -200,6 +200,14 @@ pub struct RestLaneSnapshot { pub session_type: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneChildRead { + pub parent_session_id: String, + pub child_thread_id: String, + pub observed_epoch: Option, + pub observed_generation: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RestLaneSnapshotError { OwnershipUnavailable, @@ -208,6 +216,12 @@ pub enum RestLaneSnapshotError { #[async_trait] pub trait FreshAgentRestLane: Send + Sync { + async fn read_child( + &self, + _request: RestLaneChildRead, + ) -> Result, crate::codex::children::ChildReadError> { + Err(crate::codex::children::ChildReadError::SourceUnavailable) + } /// 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( diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 721a3aba1..d8144b7e3 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -189,9 +189,98 @@ pub fn router(state: SnapshotState) -> Router { "/api/fresh-agent/threads/{sessionType}/{provider}/{threadId}", get(get_snapshot), ) + .route( + "/api/fresh-agent/threads/{sessionType}/{provider}/{threadId}/children/{childThreadId}", + get(get_child), + ) .with_state(state) } +async fn get_child( + State(state): State, + Path((session_type, provider, parent, child)): Path<(String, String, String, String)>, + Query(query): Query>, + headers: HeaderMap, +) -> Response { + use crate::codex::children::{observation, ChildReadError}; + if !authorized(&headers, &state.auth_token) { + return fail(StatusCode::UNAUTHORIZED, "unauthorized".into()); + } + if session_type != "freshcodex" || provider != "codex" { + return fail( + StatusCode::BAD_REQUEST, + "Child observation is supported for Freshcodex".into(), + ); + } + let parse = |field: &str| { + query + .get(field) + .map(|value| value.parse::()) + .transpose() + }; + let (Ok(epoch), Ok(generation)) = (parse("observedEpoch"), parse("observedGeneration")) else { + return fail(StatusCode::BAD_REQUEST, "Invalid owner fence".into()); + }; + if crate::ownership_lane::wire_fence(epoch, generation).is_err() { + return fail(StatusCode::BAD_REQUEST, "Incomplete owner fence".into()); + } + let result = if state + .local_owner(&session_type, &provider, &parent) + .await + .is_none() + { + if let Some(gateway) = state.opencode.hosted_rest_gateway() { + match gateway + .read_child(crate::rest_lane::RestLaneChildRead { + parent_session_id: parent.clone(), + child_thread_id: child.clone(), + observed_epoch: epoch, + observed_generation: generation, + }) + .await + { + Ok(Some(value)) => return Json(value).into_response(), + Ok(None) => { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + } + Err(error) => Err(error), + } + } else { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + } + } else { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + }; + match result { + Ok(value) => Json(value).into_response(), + Err(ChildReadError::UnrelatedChild) => fail_with_code( + StatusCode::NOT_FOUND, + "Child is not part of this parent conversation".into(), + "FRESH_AGENT_CHILD_NOT_FOUND", + ), + Err(ChildReadError::InvalidFence) => { + fail(StatusCode::BAD_REQUEST, "Invalid owner fence".into()) + } + Err(ChildReadError::SourceUnavailable) => Json(observation( + &parent, + &child, + "unavailable", + "source_unavailable", + true, + )) + .into_response(), + } +} + async fn get_snapshot( State(state): State, Path((session_type, provider, thread_id)): Path<(String, String, String)>, diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index c80bb985d..c78724e10 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -890,6 +890,38 @@ impl RuntimeClient { } } + pub async fn fresh_agent_read_child( + &self, + soul_id: SoulId, + child_thread_id: String, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentReadChildRequest { + soul_id, + child_thread_id, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request(RequestId::new(), AdminCommand::FreshAgentReadChild(request)) + .await + { + Ok(AdminResult::FreshAgentChildSnapshot(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request(RequestId::new(), AdminCommand::FreshAgentReadChild(request)) + .await? + { + AdminResult::FreshAgentChildSnapshot(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -1145,7 +1177,9 @@ impl RuntimeClient { let mut stream = UnixStream::connect(self.socket_path.as_ref()).await?; let reply_limit = if matches!( body, - AdminCommand::FreshAgentReadHistory(_) | AdminCommand::FreshAgentReadSnapshot(_) + AdminCommand::FreshAgentReadHistory(_) + | AdminCommand::FreshAgentReadSnapshot(_) + | AdminCommand::FreshAgentReadChild(_) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -1185,6 +1219,7 @@ mod tests { enum FreshAgentReadKind { Snapshot, History, + Child, } impl FreshAgentReadKind { @@ -1196,6 +1231,11 @@ mod tests { match self { Self::Snapshot => client.fresh_agent_snapshot(soul).await, Self::History => client.fresh_agent_history(soul).await, + Self::Child => { + client + .fresh_agent_read_child(soul, "native-child".into()) + .await + } } } @@ -1203,6 +1243,7 @@ mod tests { match self { Self::Snapshot => AdminResult::FreshAgentSnapshot(value), Self::History => AdminResult::FreshAgentHistory(value), + Self::Child => AdminResult::FreshAgentChildSnapshot(value), } } @@ -1214,6 +1255,10 @@ mod tests { (AdminCommand::FreshAgentReadHistory(read), Self::History) => { (&read.soul_id, read.expected_control_epoch) } + (AdminCommand::FreshAgentReadChild(read), Self::Child) => { + assert_eq!(read.child_thread_id, "native-child"); + (&read.soul_id, read.expected_control_epoch) + } _ => panic!("expected the original exact read operation: {envelope:?}"), }; assert_eq!(soul, &SoulId::parse("owned-read-soul").unwrap()); @@ -1442,9 +1487,18 @@ mod tests { assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::History).await; } + #[tokio::test] + async fn fresh_agent_child_read_refresh_preserves_exact_parent_and_child_and_large_body() { + assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::Child).await; + } + #[tokio::test] async fn fresh_agent_reads_stop_after_second_stale_epoch() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let (actual, requests, cached) = scripted_fresh_agent_read( kind, vec![ @@ -1470,7 +1524,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_do_not_retry_other_runtime_errors() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { for code in [ RuntimeErrorCode::HostUnreachable, RuntimeErrorCode::OwnershipMismatch, @@ -1490,7 +1548,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_propagate_health_error_without_retrying_the_read() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let (actual, requests, cached) = scripted_fresh_agent_read( kind, vec![ @@ -1517,7 +1579,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_reject_unexpected_initial_health_and_retry_results() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { for stage in 0..3 { let mut replies = Vec::new(); if stage > 0 { @@ -1547,7 +1613,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_keep_healthy_live_and_history_provenance_without_refresh() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let expected = source_snapshot(kind); let (actual, requests, cached) = scripted_fresh_agent_read(kind, vec![Ok(kind.reply(expected.clone()))]).await; diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index bd66e705e..733511fb3 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -2663,6 +2663,15 @@ pub struct FreshAgentReadSnapshotRequest { pub expected_control_epoch: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentReadChildRequest { + pub soul_id: SoulId, + pub child_thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalResizeRequest { @@ -2959,6 +2968,7 @@ pub enum AdminCommand { FreshAgentReadEvents(FreshAgentReadEventsRequest), FreshAgentReadHistory(FreshAgentReadHistoryRequest), FreshAgentReadSnapshot(FreshAgentReadSnapshotRequest), + FreshAgentReadChild(FreshAgentReadChildRequest), RuntimeMetrics(RuntimeMetricsRequest), ProbeRecovery(RecoveryProbeRequest), Recover(RecoverRequest), @@ -3473,6 +3483,7 @@ pub enum AdminResult { FreshAgentEvents(AgentEventBatch), FreshAgentHistory(serde_json::Value), FreshAgentSnapshot(serde_json::Value), + FreshAgentChildSnapshot(serde_json::Value), RuntimeMetrics(RuntimeMetrics), RecoveryProbe(RecoveryProbe), Recovery(RecoveryResult), @@ -3591,6 +3602,10 @@ pub enum HostCommand { FreshAgentReadSnapshot { incarnation_id: IncarnationId, }, + FreshAgentReadChild { + incarnation_id: IncarnationId, + child_thread_id: String, + }, FreshAgentResolve { incarnation_id: IncarnationId, decision_id: String, @@ -3655,6 +3670,7 @@ pub enum HostResult { FreshAgentEvents(AgentEventBatch), RuntimeMetrics(RuntimeMetrics), FreshAgentSnapshot(serde_json::Value), + FreshAgentChildSnapshot(serde_json::Value), Status { host_boot_id: HostBootId, worker_pid: Option, @@ -4467,6 +4483,36 @@ mod tests { )); } + #[test] + fn child_read_wire_retains_parent_soul_and_exact_child_without_writer_parameters() { + let request = FreshAgentReadChildRequest { + soul_id: SoulId::parse("parent-soul").unwrap(), + child_thread_id: "native-child".into(), + expected_control_epoch: Some(8), + }; + let encoded = + serde_json::to_value(AdminCommand::FreshAgentReadChild(request.clone())).unwrap(); + let decoded: AdminCommand = serde_json::from_value(encoded).unwrap(); + assert!(matches!(decoded,AdminCommand::FreshAgentReadChild(value) if value == request)); + let host = HostCommand::FreshAgentReadChild { + incarnation_id: IncarnationId::parse("parent-incarnation").unwrap(), + child_thread_id: "native-child".into(), + }; + let decoded: HostCommand = + serde_json::from_value(serde_json::to_value(host).unwrap()).unwrap(); + assert!( + matches!(decoded,HostCommand::FreshAgentReadChild {child_thread_id,..} if child_thread_id == "native-child") + ); + let observation = serde_json::json!({"parentThreadId":"parent-native","childThreadId":"native-child","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}); + let decoded: HostResult = serde_json::from_value( + serde_json::to_value(HostResult::FreshAgentChildSnapshot(observation.clone())).unwrap(), + ) + .unwrap(); + assert!( + matches!(decoded,HostResult::FreshAgentChildSnapshot(value) if value == observation) + ); + } + #[test] fn fresh_agent_fork_is_a_same_runtime_session_transition_on_the_wire() { let request = FreshAgentForkRequest { diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 31f86d8b9..7c62f5d11 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -4,6 +4,87 @@ use super::*; #[async_trait::async_trait] impl FreshAgentRestLane for HostedFreshAgentProxy { + async fn read_child( + &self, + request: freshell_freshagent::rest_lane::RestLaneChildRead, + ) -> Result, freshell_freshagent::codex::children::ChildReadError> + { + use freshell_freshagent::codex::children::{stamp_readonly_child, ChildReadError}; + let ownership = self.ownership.get().map(|registry| { + let id = registry.resolve_canonical("codex", &request.parent_session_id); + registry.observe("codex", &id) + }); + if ownership.as_ref().is_some_and(|owner| { + request + .observed_epoch + .is_some_and(|epoch| epoch != owner.epoch) + || request + .observed_generation + .is_some_and(|generation| generation != owner.generation) + }) { + return Err(ChildReadError::SourceUnavailable); + } + let inventory = self + .client + .inventory() + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + // The child is deliberately never looked up as a root soul. + let Some(view) = inventory.iter().rev().find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && (view.fresh_agent_session_id.as_deref() == Some(&request.parent_session_id) + || view.native_session_id.as_deref() == Some(&request.parent_session_id)) + }) else { + return Ok(None); + }; + let parent = view + .native_session_id + .as_deref() + .ok_or(ChildReadError::SourceUnavailable)?; + let mut value = self.client.fresh_agent_read_child(view.soul_id.clone(), request.child_thread_id.clone()).await.map_err(|error| { + tracing::warn!(provider="codex", soul_id=%view.soul_id, session_id=parent, child_thread_id=%request.child_thread_id, code=?error.runtime_code(), "fresh_agent.hosted_child_unavailable"); + ChildReadError::SourceUnavailable + })?; + let inventory_after = self + .client + .inventory() + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + let latest = inventory_after + .iter() + .rev() + .find(|row| row.soul_id == view.soul_id) + .ok_or(ChildReadError::SourceUnavailable)?; + let owner_changed = + self.ownership + .get() + .zip(ownership.as_ref()) + .is_some_and(|(registry, before)| { + let id = registry.resolve_canonical("codex", &request.parent_session_id); + let after = registry.observe("codex", &id); + after.epoch != before.epoch + || after.generation != before.generation + || after.state != before.state + }); + if owner_changed + || !same_snapshot_source(view, latest) + || value["parentThreadId"].as_str() != Some(parent) + || value["childThreadId"].as_str() != Some(request.child_thread_id.as_str()) + { + return Err(ChildReadError::SourceUnavailable); + } + if let Some(snapshot) = value.get_mut("snapshot") { + if snapshot["threadId"].as_str() != Some(request.child_thread_id.as_str()) + || snapshot["provider"] != "codex" + || snapshot["sessionType"] != "freshcodex" + { + return Err(ChildReadError::SourceUnavailable); + } + stamp_readonly_child(snapshot, parent); + } + Ok(Some(value)) + } async fn snapshot( &self, request: freshell_freshagent::rest_lane::RestLaneSnapshot, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 8f854c356..394282cc3 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -6,6 +6,131 @@ use freshell_runtime_protocol::{ }; use tokio::net::UnixListener; +#[tokio::test] +async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_sources() { + use tower::ServiceExt; + let mut env = crate::test_env_lock::TestEnv::lock().await; + let dir = tempfile::tempdir().unwrap(); + isolate_snapshot_env(&mut env, dir.path()); + let local = dir + .path() + .join("configured-codex/sessions/rollout-child-native.jsonl"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + let gateway_history = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"child-native\",\"cwd\":\"/gateway\"}}\n{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Wrong gateway child\"}]}}\n"; + std::fs::write(&local, gateway_history).unwrap(); + for scenario in [ + "saved", + "live", + "replaced-owner", + "wrong-child", + "unrelated", + ] { + let socket = dir.path().join(format!("child-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let mut inventories = 0; + let mut methods = vec![]; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let mut done = false; + let result = match envelope.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }), + AdminCommand::Inventory => { + inventories += 1; + done = inventories == 2; + Ok(AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"parent-root-soul","incarnationId":if scenario == "replaced-owner" && inventories == 2 {"new-owner"} else {"original-owner"}, + "launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0, + "provider":"codex","nativeSessionId":"parent-native","freshAgentSessionId":"parent-alias","freshAgentSessionType":"freshcodex" + })).unwrap()])) + } + AdminCommand::FreshAgentReadChild(request) => { + methods.push("read_child"); + assert_eq!(request.soul_id.as_str(), "parent-root-soul"); + assert_eq!(request.child_thread_id, "child-native"); + assert_eq!(request.expected_control_epoch, Some(7)); + if scenario == "unrelated" { + done = true; + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::InvalidRequest, + "child is unrelated", + )) + } else { + Ok(AdminResult::FreshAgentChildSnapshot( + serde_json::json!({"parentThreadId":"parent-native","childThreadId":if scenario == "wrong-child" {"foreign-child"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","sessionType":"freshcodex","turns":[{"text":"Provider volume child"}],"capabilities":{"send":true,"interrupt":true,"fork":true,"undo":true},"pendingApprovals":[],"pendingQuestions":[],"extensions":{}}}), + )) + } + } + other => panic!("observer attempted an unexpected root operation: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result, + }, + ) + .await + .unwrap(); + if done { + return methods; + } + } + }); + let (broadcast, _) = broadcast::channel(16); + let broadcast = Arc::new(broadcast); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast.clone()); + owner + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); + let app = freshell_freshagent::snapshot::router( + freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({}), + ), + owner, + freshell_freshagent::FreshClaudeState::new(broadcast), + ), + ); + let response = app.oneshot(axum::http::Request::builder().uri("/api/fresh-agent/threads/freshcodex/codex/parent-alias/children/child-native").header("x-auth-token","tok").body(axum::body::Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 32 * 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + let methods = tokio::time::timeout(Duration::from_secs(3), server) + .await + .unwrap() + .unwrap(); + assert_eq!(methods, vec!["read_child"]); + if matches!(scenario, "saved" | "live") { + assert_eq!( + body["snapshot"]["turns"][0]["text"], + "Provider volume child" + ); + assert_eq!(body["parentThreadId"], "parent-native"); + assert_eq!(body["status"], scenario); + assert_eq!(body["snapshot"]["capabilities"]["send"], false); + assert_eq!(body["snapshot"]["capabilities"]["fork"], false); + } else { + assert_eq!(body["reason"], "source_unavailable"); + assert!(body.get("snapshot").is_none()); + } + assert!(!body.to_string().contains("Wrong gateway child")); + } + assert_eq!(std::fs::read_to_string(local).unwrap(), gateway_history); +} + #[test] fn managed_provider_fresh_claude_retains_validated_mcp_recipe() { let args = vec!["--mcp-config".into(), ".claude/freshell-mcp.json".into()]; diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index bfeb86df3..542bf6dd6 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -300,7 +300,10 @@ async fn handle_connection(mut stream: UnixStream, state: Arc) -> Res serde_json::json!({"errorCode":error.code,"message":error.message}), ); } - let limit = if matches!(result, Ok(HostResult::FreshAgentSnapshot(_))) { + let limit = if matches!( + result, + Ok(HostResult::FreshAgentSnapshot(_) | HostResult::FreshAgentChildSnapshot(_)) + ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES @@ -733,6 +736,24 @@ async fn dispatch( actor.snapshot().await.map_err(map_actor_error)?, )) } + HostCommand::FreshAgentReadChild { + incarnation_id, + child_thread_id, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentChildSnapshot( + actor + .read_child(&child_thread_id) + .await + .map_err(map_actor_error)?, + )) + } HostCommand::FreshAgentResolve { incarnation_id, decision_id, @@ -10258,6 +10279,13 @@ mod tests { ) } + async fn read_child(&self, child_thread_id: &str) -> Result { + assert_eq!(child_thread_id, "fixture-child"); + Ok( + serde_json::json!({"parentThreadId":"fixture-native-thread","childThreadId":child_thread_id,"status":"saved","snapshot":{"threadId":child_thread_id,"provider":"codex","sessionType":"freshcodex","turns":[{"text":"x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}}), + ) + } + async fn stop(self: Arc) -> Result<(), String> { self.stops.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -10456,6 +10484,84 @@ mod tests { } } + #[tokio::test] + async fn authenticated_child_observation_uses_parent_actor_and_large_read_reply_without_dispatch( + ) { + let state = test_host_state(); + let transport = Arc::new(RpcFixtureTransport { + dispatches: AtomicUsize::new(0), + checkpoint_attempts: std::sync::Mutex::new(Vec::new()), + decisions: AtomicUsize::new(0), + operations: AtomicUsize::new(0), + captures: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + }); + let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( + state.state_dir.join("child-observer"), + FreshAgentProfile { + provider: FreshProvider::Codex, + runtime_variant: "freshcodex".into(), + cwd: "/workspace".into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "parent-volume".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }, + transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + *state.fresh_agent.lock().await = Some(actor.clone()); + let (mut client_stream, server_stream) = UnixStream::pair().unwrap(); + let serving = tokio::spawn(handle_connection(server_stream, state.clone())); + write_frame( + &mut client_stream, + &authenticated_host_envelope( + &state, + HostCommand::FreshAgentReadChild { + incarnation_id: state.incarnation_id.clone(), + child_thread_id: "fixture-child".into(), + }, + ), + ) + .await + .unwrap(); + let reply: HostReply = freshell_runtime_protocol::read_frame_with_limit( + &mut client_stream, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + serving.await.unwrap().unwrap(); + let HostResult::FreshAgentChildSnapshot(value) = reply.result.unwrap() else { + panic!("expected child observation"); + }; + assert_eq!(value["parentThreadId"], "fixture-native-thread"); + assert_eq!( + value["snapshot"]["turns"][0]["text"] + .as_str() + .unwrap() + .len(), + 2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + ); + assert_eq!( + actor.profile().await.native_session_id, + before.native_session_id + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0); + assert_eq!(transport.operations.load(Ordering::SeqCst), 0); + assert_eq!(transport.stops.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn authenticated_fresh_agent_rpc_keeps_one_host_owned_transport_until_explicit_stop() { let state = test_host_state(); diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index a7212a668..a8f1e1258 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1033,6 +1033,21 @@ impl FreshAgentTransport for HostedTransport { Ok(snapshot) } + async fn read_child(&self, child_thread_id: &str) -> Result { + #[cfg(test)] + if let Some(delegate) = self.test_delegate.as_ref() { + return delegate.read_child(child_thread_id).await; + } + let parent = self.current_native_id().await?; + match &self.state { + ProviderState::Codex(state) => state + .read_child(&parent, child_thread_id, None, None) + .await + .map_err(|error| format!("codex child observation unavailable: {error:?}")), + _ => Err("provider does not expose child observation".into()), + } + } + async fn registered_snapshot_identity(&self) -> Option { if self.provider != FreshProvider::Opencode || self.native_rx.lock().await.borrow().is_some() diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index e5e79d3e3..86edfefc2 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -586,6 +586,15 @@ impl Supervisor { self.fresh_agent_snapshot(request.soul_id).await?, )) } + AdminCommand::FreshAgentReadChild(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentChildSnapshot( + self.fresh_agent_read_child(request.soul_id, request.child_thread_id) + .await?, + )) + } AdminCommand::FreshAgentResolve(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -2020,6 +2029,71 @@ impl Supervisor { Ok(snapshot) } + async fn fresh_agent_read_child( + &self, + soul_id: SoulId, + child_thread_id: String, + ) -> Result { + let lifecycle_lock = self.lifecycle_lock(&soul_id).await; + let _guard = lifecycle_lock.lock().await; + let handle = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::UnsupportedWorkload, + "soul is not a hosted fresh-agent", + ) + })?; + let provider = if agent.provider == freshell_runtime_protocol::FreshProvider::Kilroy { + "claude" + } else { + agent.provider.as_str() + }; + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + let result = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentReadChild { + child_thread_id: child_thread_id.clone(), + incarnation_id: handle.incarnation_id().clone(), + }, + ) + .await?; + let HostResult::FreshAgentChildSnapshot(snapshot) = result else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected fresh-agent snapshot reply", + )); + }; + let current = self + .registry + .active_handle_for_soul(soul_id) + .await + .map_err(map_registry)?; + if current.incarnation_id() != handle.incarnation_id() { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent snapshot owner changed", + )); + } + if provider != "codex" + || snapshot["childThreadId"].as_str() != Some(child_thread_id.as_str()) + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent child snapshot identity mismatch", + )); + } + Ok(snapshot) + } + async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -3242,7 +3316,9 @@ where drop(supervisor); let limit = if matches!( reply.result, - Ok(AdminResult::FreshAgentHistory(_) | AdminResult::FreshAgentSnapshot(_)) + Ok(AdminResult::FreshAgentHistory(_) + | AdminResult::FreshAgentSnapshot(_) + | AdminResult::FreshAgentChildSnapshot(_)) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -3474,7 +3550,10 @@ pub(crate) async fn request_host_reply( write_frame(&mut stream, envelope) .await .map_err(|error| unreachable(error.to_string()))?; - let reply_limit = if matches!(envelope.body, HostCommand::FreshAgentReadSnapshot { .. }) { + let reply_limit = if matches!( + envelope.body, + HostCommand::FreshAgentReadSnapshot { .. } | HostCommand::FreshAgentReadChild { .. } + ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES @@ -3681,8 +3760,8 @@ mod host_ipc_timeout_tests { use tokio::net::UnixListener; #[tokio::test] - async fn snapshot_host_reply_preserves_large_history_and_controls_stay_bounded() { - for snapshot_read in [true, false] { + async fn snapshot_and_child_host_replies_preserve_large_history_and_controls_stay_bounded() { + for (snapshot_read, child_read) in [(true, false), (true, true), (false, false)] { let dir = tempfile::tempdir().unwrap(); let socket = dir.path().join("host.sock"); let listener = UnixListener::bind(&socket).unwrap(); @@ -3698,16 +3777,23 @@ mod host_ipc_timeout_tests { &mut stream, &freshell_runtime_protocol::HostReply { request_id: envelope.request_id, - result: Ok(freshell_runtime_protocol::HostResult::FreshAgentSnapshot( - sent, - )), + result: Ok(if child_read { + freshell_runtime_protocol::HostResult::FreshAgentChildSnapshot(sent) + } else { + freshell_runtime_protocol::HostResult::FreshAgentSnapshot(sent) + }), }, freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, ) .await }); let id = IncarnationId::parse("large-incarnation").unwrap(); - let command = if snapshot_read { + let command = if child_read { + HostCommand::FreshAgentReadChild { + incarnation_id: id, + child_thread_id: "native-child".into(), + } + } else if snapshot_read { HostCommand::FreshAgentReadSnapshot { incarnation_id: id } } else { HostCommand::Status { incarnation_id: id } @@ -3719,7 +3805,11 @@ mod host_ipc_timeout_tests { ) .await; let _ = server.await.unwrap(); - if snapshot_read { + if child_read { + assert!( + matches!(reply.unwrap().result.unwrap(), freshell_runtime_protocol::HostResult::FreshAgentChildSnapshot(value) if value == expected) + ); + } else if snapshot_read { assert!( matches!(reply.unwrap().result.unwrap(), freshell_runtime_protocol::HostResult::FreshAgentSnapshot(value) if value == expected) ); diff --git a/docs/index.html b/docs/index.html index 4cc28a20f..24d11e106 100644 --- a/docs/index.html +++ b/docs/index.html @@ -976,7 +976,7 @@

Task Board

~/code/freshell
-
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
+
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Codex child conversations open as read-only observations inside their parent pane. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
Diffs
src/components/fresh-agent/FreshAgentView.tsx
diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index b32c58bce..75c4ce419 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -73,6 +73,7 @@ export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ id: z.string().min(1), kind: z.literal('text'), text: z.string(), + extensions: z.record(z.string(), z.unknown()).optional(), }).strict(), z.object({ id: z.string().min(1), @@ -148,7 +149,11 @@ export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [ z.object({ id: z.string().min(1), kind: z.literal('task_delegation'), - status: z.enum(['running', 'completed', 'failed']), + status: z.enum(['running', 'completed', 'failed', 'interrupted']), + provider: FreshAgentRuntimeProviderSchema.optional(), + nativeStatus: z.string().nullable().optional(), + model: z.string().optional(), + reasoningEffort: z.string().optional(), title: z.string(), description: z.string().optional(), subagent: z.string().optional(), @@ -371,6 +376,10 @@ export const FreshAgentChildThreadSchema = z.object({ threadId: z.string().min(1), origin: z.string(), title: z.string().optional(), + status: z.string().optional(), + model: z.string().optional(), + result: z.string().optional(), + error: z.string().optional(), receiverThreadIds: z.array(z.string().min(1)).optional(), }).strict() @@ -428,6 +437,27 @@ export const FreshAgentSnapshotSchema = FreshAgentThreadLocatorSchema.extend({ commands: z.array(FreshAgentSessionCommandSchema).readonly().optional(), }).strict() +export const FreshAgentChildReadSchema = z.object({ + parentSessionId: z.string().min(1), + childThreadId: z.string().min(1), + observedEpoch: z.number().int().nonnegative().optional(), + observedGeneration: z.number().int().nonnegative().optional(), +}).strict() + +const ChildIdentity = { parentThreadId: z.string().min(1), childThreadId: z.string().min(1) } +export const FreshAgentChildObservationSchema = z.discriminatedUnion('status', [ + z.object({ ...ChildIdentity, status: z.literal('live'), snapshot: FreshAgentSnapshotSchema }).strict(), + z.object({ ...ChildIdentity, status: z.literal('saved'), snapshot: FreshAgentSnapshotSchema }).strict(), + z.object({ ...ChildIdentity, status: z.literal('pending'), reason: z.literal('history_unmaterialized'), retryable: z.literal(true) }).strict(), + z.object({ ...ChildIdentity, status: z.literal('unavailable'), reason: z.enum(['ephemeral_history_unavailable', 'history_unavailable', 'source_unavailable']), retryable: z.boolean() }).strict(), +]).superRefine((value, context) => { + if ('snapshot' in value && (value.snapshot.threadId !== value.childThreadId || value.snapshot.provider !== 'codex' || value.snapshot.sessionType !== 'freshcodex')) { + context.addIssue({ code: 'custom', message: 'Child snapshot identity mismatch', path: ['snapshot'] }) + } +}) +export type FreshAgentChildRead = z.infer +export type FreshAgentChildObservation = z.infer + export const FreshAgentTurnPageSchema = FreshAgentThreadLocatorSchema.extend({ revision: z.number().int().nonnegative(), nextCursor: z.string().nullable(), diff --git a/src/components/fresh-agent/FreshAgentChildObserver.tsx b/src/components/fresh-agent/FreshAgentChildObserver.tsx new file mode 100644 index 000000000..79e21240f --- /dev/null +++ b/src/components/fresh-agent/FreshAgentChildObserver.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { FreshAgentChildObservationSchema, type FreshAgentChildRead, type FreshAgentChildObservation, type FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import { api } from '@/lib/api' +import { createLogger } from '@/lib/client-logger' +import { FreshAgentTranscript } from './FreshAgentTranscript.js' + +export type FreshAgentChildParent = Omit & { provider: 'codex'; sessionType: 'freshcodex' } + +const MAX_READS = 5 +const log = createLogger('FreshAgentChildObserver') + +export function FreshAgentChildObserver({ parent, childThreadId, onClose }: { parent: FreshAgentChildParent; childThreadId: string; onClose: () => void }) { + const [observation, setObservation] = useState() + const [snapshot, setSnapshot] = useState() + const [refresh, setRefresh] = useState(0) + const [stale, setStale] = useState(false) + const close = useRef(null) + const scope = `${parent.parentSessionId}:${childThreadId}` + const previousScope = useRef(scope) + useEffect(() => { + const previous = document.activeElement + close.current?.focus() + return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus() } + }, []) + useEffect(() => { + const controller = new AbortController() + let timer: ReturnType | undefined + let reads = 0 + if (previousScope.current !== scope) { setSnapshot(undefined); previousScope.current = scope } + setObservation(undefined) + setStale(true) + const unavailable = (): FreshAgentChildObservation => ({ parentThreadId: parent.parentSessionId, childThreadId, status: 'unavailable', reason: 'source_unavailable', retryable: true }) + const read = async () => { + reads++ + let next: FreshAgentChildObservation + try { + const params = new URLSearchParams() + if (parent.observedEpoch !== undefined) params.set('observedEpoch', String(parent.observedEpoch)) + if (parent.observedGeneration !== undefined) params.set('observedGeneration', String(parent.observedGeneration)) + const query = params.size ? `?${params}` : '' + const response = await api.get(`/api/fresh-agent/threads/freshcodex/codex/${encodeURIComponent(parent.parentSessionId)}/children/${encodeURIComponent(childThreadId)}${query}`, { signal: controller.signal }) + next = FreshAgentChildObservationSchema.parse(response) + // The parent supplies its canonical native identity and current fence. + if (next.parentThreadId !== parent.parentSessionId || next.childThreadId !== childThreadId) throw new Error('Child observation identity mismatch') + } catch (error) { + if (controller.signal.aborted) return + log.warn('Child observation source unavailable', { provider: 'codex', parentSessionId: parent.parentSessionId, childThreadId, observedEpoch: parent.observedEpoch, observedGeneration: parent.observedGeneration, errorName: error instanceof Error ? error.name : 'unknown' }) + next = unavailable() + } + if (controller.signal.aborted) return + if (next.status === 'pending' && reads >= MAX_READS) next = { ...next, status: 'unavailable', reason: 'history_unavailable', retryable: true } + setObservation(next) + if ('snapshot' in next) { setSnapshot(next.snapshot); setStale(false) } else { setStale(true) } + const retry = next.status === 'live' || next.status === 'pending' || (next.status === 'unavailable' && next.retryable) + if (retry && reads < MAX_READS) timer = setTimeout(() => { void read() }, 1000) + } + void read() + return () => { controller.abort(); if (timer) clearTimeout(timer) } + }, [scope, parent.parentSessionId, parent.observedEpoch, parent.observedGeneration, childThreadId, refresh]) + const ephemeral = observation?.status === 'unavailable' && observation.reason === 'ephemeral_history_unavailable' + const availability = ephemeral ? 'Codex does not expose history for this ephemeral child. Its activity remains in the parent conversation.' : observation?.status === 'unavailable' ? 'Child history is unavailable.' : observation?.status === 'pending' ? 'Waiting for child history to be saved.' : observation?.status === 'live' ? 'Observed live child history. Observation does not control the child.' : observation?.status === 'saved' ? 'Saved child history.' : 'Reading child history.' + return createPortal( +
+
{ + if (event.key === 'Escape') { event.stopPropagation(); onClose() } + if (event.key === 'Tab') { + const nodes = Array.from(event.currentTarget.querySelectorAll('button, a[href], [tabindex="0"]')).filter((node) => !node.hasAttribute('disabled')) + const first = nodes[0], last = nodes[nodes.length - 1] + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() } + } + }}> +

Child conversation

Read-only · {childThreadId}

+
+ {snapshot && stale ? `Previously available content. ${availability}` : availability} + {!ephemeral && observation && observation.status !== 'pending' ? : null} +
+ {snapshot ?
: null} +
+
, document.body, + ) +} diff --git a/src/components/fresh-agent/FreshAgentItemCard.tsx b/src/components/fresh-agent/FreshAgentItemCard.tsx index 36c8fa837..0a861f94d 100644 --- a/src/components/fresh-agent/FreshAgentItemCard.tsx +++ b/src/components/fresh-agent/FreshAgentItemCard.tsx @@ -1,11 +1,13 @@ import { createContext, memo, useContext, useMemo, useState } from 'react' -import { Check, ChevronRight, Loader2, X } from 'lucide-react' +import { Ban, Check, ChevronRight, Loader2, X } from 'lucide-react' import DiffView from '@/components/fresh-agent/shared/DiffView' import { formatThoughtDuration } from '@/components/fresh-agent/shared/format-duration' import { getToolPreview } from '@/components/fresh-agent/shared/tool-preview' import { LazyMarkdown } from '@/components/markdown/LazyMarkdown' import type { FreshAgentTranscriptItem } from '@shared/fresh-agent-contract' import { cn } from '@/lib/utils' +import { childActivitySummary } from './shared/child-activity.js' +import type { FreshAgentChildParent } from './FreshAgentChildObserver.js' function formatJson(value: unknown): string { if (typeof value === 'string') return value @@ -55,7 +57,10 @@ export interface FreshAgentToolDisplay { input?: Record output?: string isError?: boolean - status: 'running' | 'complete' + status: 'running' | 'complete' | 'failed' | 'declined' | 'interrupted' + exitCode?: number | null + nativeOutcome?: boolean + references?: Array<{ label: string; href: string }> /** Display-first preview: when present, wins over getToolPreview(name, input). */ previewOverride?: string } @@ -66,6 +71,28 @@ export interface FreshAgentToolDisplay { * transcript/pane wiring. */ export const FreshAgentOpenSessionContext = createContext<(sessionId: string, title?: string) => void>(() => {}) +export const FreshAgentObserveChildContext = createContext<{ + parent: FreshAgentChildParent + onObserve: (action: { type: 'observe-child'; parent: FreshAgentChildParent; childThreadId: string }) => void +} | undefined>(undefined) + +export function FreshAgentChildLink({ childThreadId }: { childThreadId: string }) { + const observer = useContext(FreshAgentObserveChildContext) + return observer ? : null +} + +export function outputReferences(value: unknown): Array<{ label: string; href: string }> { + const values = Array.isArray(value) ? value : [value] + return values.flatMap((entry) => { + const record = asRecord(entry) + if (!record) return [] + const path = record.path ?? record.image_url ?? record.url ?? record.savedPath + if (typeof path !== 'string') return Array.isArray(record.content) ? outputReferences(record.content) : [] + if (/^https?:\/\//.test(path)) return [{ label: path, href: path }] + if (path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)) return [{ label: path, href: `/api/files/raw?path=${encodeURIComponent(path)}` }] + return [] + }) +} export function stripSystemReminders(text: string): string { return text.replace(/[\s\S]*?<\/system-reminder>/g, '').trim() @@ -83,7 +110,7 @@ export function FreshAgentToolBlock({ () => tool.previewOverride ?? getToolPreview(tool.name, tool.input), [tool.previewOverride, tool.input, tool.name], ) - const resultSummary = summarizeResult(tool.name, tool.output, tool.isError) + const resultSummary = tool.status === 'declined' ? 'declined' : summarizeResult(tool.name, tool.output, tool.isError) const hasEditDiff = tool.name === 'Edit' && typeof tool.input?.old_string === 'string' && typeof tool.input?.new_string === 'string' @@ -112,12 +139,16 @@ export function FreshAgentToolBlock({ ) : null} {tool.status === 'running' ? : null} + {tool.status === 'declined' ? : null} + {tool.status === 'interrupted' ? : null} {tool.status === 'complete' && !tool.isError ? : null} - {tool.status === 'complete' && tool.isError ? : null} + {(tool.status === 'failed' || (tool.status === 'complete' && tool.isError)) ? : null} {expanded ? (
+ {tool.exitCode != null ?
Exit code: {tool.exitCode}
: null} + {tool.references?.map((reference) => {reference.label})} {hasEditDiff ? ( : null} {item.status === 'completed' ? : null} {item.status === 'failed' ? : null} + {item.status === 'interrupted' ? : null} {item.title} {item.durationMs !== undefined ? ( {formatThoughtDuration(item.durationMs)} ) : null}
+ {item.provider === 'codex' ?
{childActivitySummary(item)}
: null} {activity.length > 0 ? (
{activity.map((row, index) => ( @@ -203,7 +236,7 @@ export function FreshAgentDelegationBlock({ item }: { item: Extract
) : null} - {item.childSessionId ? ( + {item.childSessionId && item.provider === 'codex' ?
{item.childSessionId}
: item.childSessionId ? (
{formatJson(item.content)}
+ {outputReferences(item.content).map((reference) => {reference.label})}
) } @@ -432,7 +480,7 @@ export const FreshAgentItemCard = memo(function FreshAgentItemCard({
From {item.senderThreadId}
-
To {item.receiverThreadIds.join(', ')}
+ {item.receiverThreadIds.map((id) =>
To {id}

{childActivitySummary({ model: item.model, reasoningEffort: item.reasoningEffort, ...asRecord(item.agentsStates[id]) })}

)} {item.prompt ?

{item.prompt}

: null}
) diff --git a/src/components/fresh-agent/FreshAgentSidebar.tsx b/src/components/fresh-agent/FreshAgentSidebar.tsx index c465f7664..93b2f8e56 100644 --- a/src/components/fresh-agent/FreshAgentSidebar.tsx +++ b/src/components/fresh-agent/FreshAgentSidebar.tsx @@ -1,4 +1,6 @@ import type { CodexLatestUsage } from '@/lib/fresh-agent-context-usage' +import { FreshAgentChildLink } from './FreshAgentItemCard.js' +import { childActivitySummary } from './shared/child-activity.js' type CodexPlan = { explanation?: string; plan: Array<{ step: string; status: 'pending' | 'inProgress' | 'completed' }> } @@ -18,7 +20,7 @@ export function FreshAgentSidebar({ codexUsage, }: { worktrees: Array<{ id: string; path: string; branch?: string }> - childThreads: Array<{ id: string; threadId: string; origin?: string; title?: string }> + childThreads: Array<{ id: string; threadId: string; origin?: string; title?: string; status?: string; model?: string; result?: string; error?: string }> codexReview?: { id?: string; status?: string } codexFork?: { parentThreadId?: string } codexPlan?: CodexPlan @@ -85,7 +87,7 @@ export function FreshAgentSidebar({
Child Threads
    {childThreads.map((thread) => ( -
  • {thread.title ?? thread.threadId}
  • +
  • {thread.title ?? thread.threadId}

    {childActivitySummary(thread)}

  • ))}
diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx index bbf91b433..ef74ebc2a 100644 --- a/src/components/fresh-agent/FreshAgentTranscript.tsx +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -12,6 +12,7 @@ import { FreshAgentRetryRow, FreshAgentToolBlock, itemToToolDisplay, + outputReferences, stripSystemReminders, type FreshAgentToolDisplay, } from './FreshAgentItemCard' @@ -149,7 +150,7 @@ function buildActivity( previewOverride: item.title, output: item.result, isError: item.status === 'failed', - status: item.status === 'running' ? 'running' : 'complete', + status: item.status === 'completed' ? 'complete' : item.status, }, }) continue @@ -168,6 +169,7 @@ function buildActivity( tool: { ...existing.tool, output: formatJson(item.content), + references: outputReferences(item.content), isError: item.isError, status: 'complete', }, @@ -181,6 +183,7 @@ function buildActivity( id: item.id, name: 'Result', output: formatJson(item.content), + references: outputReferences(item.content), isError: item.isError, status: 'complete', }, @@ -261,7 +264,10 @@ export type TurnLayout = { blocks: RenderBlock[] } /** Mirrors FreshAgentItemCard's null-render path for text items: a text item * that renders nothing must not close an open line (nothing visibly between). */ function rendersVisibly(item: FreshAgentTranscriptItem): boolean { - if (item.kind === 'text') return stripSystemReminders(item.text).trim().length > 0 + if (item.kind === 'text') { + const codex = item.extensions?.codex as { questions?: unknown } | undefined + return stripSystemReminders(item.text).trim().length > 0 || (Array.isArray(codex?.questions) && codex.questions.length > 0) + } return true } @@ -508,6 +514,7 @@ function normalizeActivityRows(rows: ActivityRow[], live: boolean): ActivityRow[ if ( row.type !== 'tool' || row.tool.status !== 'running' + || row.tool.nativeOutcome === true || row.tool.id === activeRunningToolId ) { return row diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index a551231e9..810f8b69b 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -119,7 +119,8 @@ import type { FreshAgentTurn } from '@shared/fresh-agent-contract' import { finalizeCodingAgentSessionName } from '@/store/codingAgentNaming' import { FreshAgentApprovalBanner } from './FreshAgentApprovalBanner' import { FreshAgentApprovalCard } from './FreshAgentApprovalCard' -import { FreshAgentOpenSessionContext } from './FreshAgentItemCard' +import { FreshAgentOpenSessionContext, FreshAgentObserveChildContext } from './FreshAgentItemCard' +import { FreshAgentChildObserver, type FreshAgentChildParent } from './FreshAgentChildObserver.js' import FreshAgentQuestionBanner from './FreshAgentQuestionBanner' import { FreshAgentTranscript, type FreshAgentTranscriptHandle, type FreshAgentTranscriptProps } from './FreshAgentTranscript' import { mergeFreshAgentTranscriptProjection, readTranscriptProjection } from '@/lib/fresh-agent-transcript-projection' @@ -4557,7 +4558,13 @@ export function FreshAgentView({ }) } + const childParent: FreshAgentChildParent | undefined = paneContent.provider === 'codex' && paneContent.sessionType === 'freshcodex' && contextSessionId ? { + provider: 'codex', sessionType: 'freshcodex', parentSessionId: effectiveSessionRef(paneContent)?.sessionId ?? snapshot?.threadId ?? contextSessionId, + ...(runtimeOwner ? { observedEpoch: runtimeOwner.epoch, observedGeneration: runtimeOwner.generation } : {}), + } : undefined + const closeChild = () => dispatch(mergePaneContent({ tabId, paneId, updates: { observedChildThreadId: undefined } })) return ( + dispatch(mergePaneContent({ tabId, paneId, updates: { observedChildThreadId: action.childThreadId } })) } : undefined}>
+ {childParent && paneContent.observedChildThreadId ? : null}
+ ) }, [ canSend, @@ -5104,6 +5113,8 @@ export function FreshAgentView({ restartStuckSidecar, rewindToTurn, openDelegationSession, + runtimeOwner?.epoch, + runtimeOwner?.generation, runShellCommand, sessionEnded, sessionErrorMessage, diff --git a/src/components/fresh-agent/shared/child-activity.ts b/src/components/fresh-agent/shared/child-activity.ts new file mode 100644 index 000000000..c8b85e20e --- /dev/null +++ b/src/components/fresh-agent/shared/child-activity.ts @@ -0,0 +1,10 @@ +/** Display native child outcomes without substituting tool-call completion. */ +export function childActivitySummary(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return typeof value === 'string' ? value : '' + const state = value as Record + const labels: Record = { nativeStatus: 'Activity', status: 'Status', model: 'Model', reasoningEffort: 'Thinking', message: 'Message', result: 'Result', error: 'Error' } + return ['nativeStatus', 'status', 'model', 'reasoningEffort', 'message', 'result', 'error'] + .filter((key) => state[key] != null && state[key] !== '') + .map((key) => `${labels[key]}: ${typeof state[key] === 'string' ? state[key] : JSON.stringify(state[key])}`) + .join(' · ') +} diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 04f928875..08a311480 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -495,6 +495,8 @@ export type FreshAgentPaneContent = { status: SdkSessionStatus resumeSessionId?: string sessionRef?: SessionLocator + /** Parent-scoped display selection; never a child runtime identity. */ + observedChildThreadId?: string /** Runtime-only server locality for same-server matching; never part of canonical durable identity. */ serverInstanceId?: string /** Explicit restore failure when no canonical durable target exists. */ diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 8e7600c67..077d53666 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -205,6 +205,7 @@ function normalizePaneContent( const modelEffortLevels = normalizeFreshAgentModelEffortLevels(rawFreshAgent.modelEffortLevels) const freshHandoffError = normalizeHandoffError(rawFreshAgent.handoffError) const checkpointWorkspace = normalizeCheckpointWorkspace(rawFreshAgent.checkpointWorkspace) + const observedChildThreadId = input.provider === 'codex' && typeof rawFreshAgent.observedChildThreadId === 'string' && rawFreshAgent.observedChildThreadId.trim() ? rawFreshAgent.observedChildThreadId : undefined const outcomeConversationRequestId = typeof rawFreshAgent.createRequestId === 'string' && rawFreshAgent.createRequestId ? rawFreshAgent.createRequestId @@ -272,6 +273,7 @@ function normalizePaneContent( initialCwd: input.initialCwd, ...(checkpointWorkspace ? { checkpointWorkspace } : {}), ...(checkpointOutcomes ? { checkpointOutcomes } : {}), + ...(observedChildThreadId ? { observedChildThreadId } : {}), createError: input.createError, closeError, modelSelection: normalizeFreshAgentModelSelection( @@ -359,6 +361,7 @@ function normalizePaneContent( initialCwd: input.initialCwd, ...(checkpointWorkspace ? { checkpointWorkspace } : {}), ...(checkpointOutcomes ? { checkpointOutcomes } : {}), + ...(observedChildThreadId ? { observedChildThreadId } : {}), createError: input.createError, modelSelection: normalizeFreshAgentModelSelection( (input as { modelSelection?: unknown }).modelSelection, diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index be8e12c06..c2d31f46b 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -351,6 +351,12 @@ function normalizeTerminalContent(content: Record): Record): Record { + // Only the native child selection is durable. Bodies and ownership fences + // are read again through the parent's current provider after reload. + if ('observedChildThreadId' in content && (content.provider !== 'codex' || typeof content.observedChildThreadId !== 'string' || !content.observedChildThreadId.trim())) { + const { observedChildThreadId: _invalidChild, ...rest } = content + content = rest + } const checkpointOutcomes = normalizeFreshAgentCheckpointOutcomes( content.checkpointOutcomes, typeof content.createRequestId === 'string' ? content.createRequestId : undefined, diff --git a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts new file mode 100644 index 000000000..d97858cd2 --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } 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 source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) +const parentId = '11111111-1111-4111-8111-111111111111' +const childId = '22222222-2222-4222-8222-222222222222' + +test('Freshcodex observes the exact child read-only and restores the selection after reload', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-child-')) + const wrapper = path.join(root, 'codex.mjs') + const operations = path.join(root, 'operations.jsonl') + const outputImage = path.join(root, 'generated-output.png') + const imageBytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1UAAAAASUVORK5CYII=', 'base64') + await fs.writeFile(outputImage, imageBytes) + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const parent = { id: parentId, ephemeral: false, status: { type: 'idle' }, turns: [{ id: 'parent-turn', status: 'completed', items: [ + { id: 'parent-answer', type: 'agentMessage', text: 'Parent conversation remains available' }, + { id: 'denied', type: 'commandExecution', command: 'touch blocked', status: 'declined', aggregatedOutput: 'User declined', exitCode: null }, + { id: 'image-output', type: 'imageGeneration', status: 'completed', result: outputImage, savedPath: outputImage }, + { id: 'spawn', type: 'collabAgentToolCall', tool: 'spawnAgent', status: 'completed', senderThreadId: parentId, receiverThreadIds: [childId], agentsStates: { [childId]: { status: 'completed', message: 'Child finished', model: 'child-model' } } }, + ] }] } + const child = { id: childId, parentThreadId: parentId, ephemeral: false, status: { type: 'idle' }, turns: [{ id: 'child-turn', status: 'completed', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer' }] }] } + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ threadStartThreadId: parentId, appendThreadOperationLogPath: operations, threadReadResults: { [parentId]: parent, [childId]: child }, overrides: { 'thread/read': { result: { thread: parent } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await expect(pane.getByText('Parent conversation remains available', { exact: true })).toBeVisible({ timeout: 30_000 }) + const before = await fs.readFile(operations, 'utf8') + const open = pane.getByRole('button', { name: `Observe child ${childId}`, exact: true }).first() + await open.click({ timeout: 15_000 }) + const observer = page.getByRole('dialog', { name: 'Child conversation' }) + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() + await expect(observer.getByRole('textbox')).toHaveCount(0) + await expect(observer.getByRole('button', { name: /send|stop|configure|resume/i })).toHaveCount(0) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(pane.getByText('Parent conversation remains available', { exact: true })).toHaveCount(1) + await observer.getByRole('button', { name: 'Close child conversation' }).click() + await expect(observer).toHaveCount(0) + await pane.getByRole('button', { name: 'Toggle activity details' }).first().click() + await expect(pane.getByLabel('declined')).toBeVisible() + await pane.getByRole('button', { name: 'ImageGeneration tool call' }).click() + const output = pane.getByRole('link', { name: outputImage, exact: true }) + await expect(output).toBeVisible() + const image = await page.request.get(new URL((await output.getAttribute('href'))!, info.baseUrl).href) + expect(image.status()).toBe(200) + expect(await image.body()).toEqual(imageBytes) + const rows = (await fs.readFile(operations, 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line)) + const prior = before.split('\n').filter(Boolean).map((line) => JSON.parse(line)) + expect(rows.filter((row) => row.method === 'thread/start')).toHaveLength(prior.filter((row) => row.method === 'thread/start').length) + expect(rows.filter((row) => row.params?.threadId === childId && row.method !== 'thread/read')).toEqual([]) + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts index 8c23f317d..de3a67cd7 100644 --- a/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts +++ b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts @@ -46,6 +46,13 @@ test('Freshcodex shows native text and command progress before completion, then const pane = page.locator('[data-context="fresh-agent"]').last() const input = pane.getByRole('textbox', { name: 'Chat message input' }) await expect(input).toBeEnabled({ timeout: 30_000 }) + await pane.locator('input[type="file"]').setInputFiles([ + { name: 'native-upload.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1UAAAAASUVORK5CYII=', 'base64') }, + { name: 'native-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Uploaded fixture notes') }, + ]) + await expect(pane.getByRole('list', { name: 'Attachments' }).getByText('native-upload.png')).toBeVisible() + await expect(pane.getByRole('list', { name: 'Attachments' }).getByText('native-notes.txt')).toBeVisible() + await expect(pane.getByLabel('uploading')).toHaveCount(0) await input.fill('Show native live progress') await pane.getByRole('button', { name: 'Send' }).click() await expect.poll(async () => (await fs.readFile(nativeLog, 'utf8')).includes('item/agentMessage/delta')).toBe(true) @@ -73,9 +80,11 @@ test('Freshcodex shows native text and command progress before completion, then await harness.waitForHarness() await harness.waitForConnection() await expect(page.getByText('Native streaming fragment and final answer', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + await expect(page.getByLabel('You transcript turn').filter({ hasText: 'native-upload.png' })).toHaveCount(1) + await expect(page.getByLabel('You transcript turn').filter({ hasText: 'native-notes.txt' })).toHaveCount(1) } finally { await fs.writeFile(release, 'release').catch(() => {}) - await context?.close() + await context?.close().catch(() => {}) await server.stop() await fs.rm(root, { recursive: true, force: true }) } diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index d899948eb..2e80b3fe1 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -464,6 +464,8 @@ function successResult(method, params) { return {} } if (method === 'thread/read') { + const scriptedThread = behavior.threadReadResults?.[params?.threadId] + if (scriptedThread) return { thread: scriptedThread } const thread = makeThread(params?.threadId, { ...params, includeTurns: params?.includeTurns === true, @@ -920,7 +922,9 @@ wss.on('connection', (socket) => { setTimeout(async () => { await writeBytes(process.stdout, floodStdoutBytes) await writeBytes(process.stderr, floodStderrBytes) - const result = override?.result ?? successResult(method, message.params) + const result = method === 'thread/read' && behavior.threadReadResults?.[message.params?.threadId] + ? successResult(method, message.params) + : override?.result ?? successResult(method, message.params) // Durable witnesses land BEFORE the RPC response (kata rb5h). socket.send copies // the frame into the kernel synchronously, so on a loaded multi-core host a client // on another core can observe the response and assert the witness files while THIS diff --git a/test/fixtures/fresh-agent/codex/native-children-0.164.json b/test/fixtures/fresh-agent/codex/native-children-0.164.json new file mode 100644 index 000000000..73bdbded9 --- /dev/null +++ b/test/fixtures/fresh-agent/codex/native-children-0.164.json @@ -0,0 +1,381 @@ +{ + "source": { + "scenario": "parent-child-read", + "version": "0.164.0", + "receipt": "5768b31f-6e97-4aff-81bc-cb0ded210f0c.json", + "sha256": "6eb99ed11fc98523c961fbcf07aed8ad5c909260e8c8fc6d7ca906eb47b794a1" + }, + "parent": { + "thread": { + "id": "01a17436-80b5-7d81-a429-133d30114b56", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": null, + "preview": "Run the controlled contract scenario.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "idle" + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80b5-7d81-a429-133d30114b56.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-80da-7843-a626-57d4c7c88671", + "clientId": null, + "content": [ + { + "type": "text", + "text": "Run the controlled contract scenario.", + "text_elements": [] + } + ] + }, + { + "type": "collabAgentToolCall", + "id": "call_native_child", + "tool": "spawnAgent", + "status": "completed", + "senderThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "receiverThreadIds": [ + "01a17436-80f3-7f33-84a6-319fdd160006" + ], + "prompt": "freshell-child-native-probe: return the controlled child answer.", + "model": "contract-model", + "reasoningEffort": "low", + "agentsStates": { + "01a17436-80f3-7f33-84a6-319fdd160006": { + "status": "pendingInit", + "message": null + } + } + }, + { + "type": "agentMessage", + "id": "msg_contract_1", + "text": "First fragment and final fragment", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + }, + { + "type": "agentMessage", + "id": "msg_contract_3", + "text": "First fragment and final fragment", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": 1792951091, + "completedAt": 1792951091, + "durationMs": 119 + } + ] + }, + "readState": { + "firstUnread": { + "type": "turn", + "turnId": "01a17436-80c2-7f80-9531-66f31f25b1f9" + }, + "revision": "dc18d800-0e12-45c5-9a59-fe87256b4fe3" + } + }, + "active": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "active", + "activeFlags": [] + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": true, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + } + ], + "itemsView": "full", + "status": "inProgress", + "error": null, + "startedAt": 1792951091, + "completedAt": null, + "durationMs": null + } + ] + }, + "readState": null + }, + "final": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "active", + "activeFlags": [] + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": true, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "msg_contract_2", + "text": "Exact child final answer.", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "inProgress", + "error": null, + "startedAt": 1792951091, + "completedAt": null, + "durationMs": null + } + ] + }, + "readState": null + }, + "saved": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": null, + "extra": null, + "sessionId": "01a17436-80f3-7f33-84a6-319fdd160006", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "notLoaded" + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": null, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "msg_contract_2", + "text": "Exact child final answer.", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": 1792951091, + "completedAt": 1792951091, + "durationMs": 43 + } + ] + }, + "readState": null + } +} diff --git a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx new file mode 100644 index 000000000..78a5c3b9b --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FreshAgentChildObserver } from '@/components/fresh-agent/FreshAgentChildObserver' +import { api } from '@/lib/api' +import { codexContractSnapshot } from '../../../../fixtures/fresh-agent/codex/contract-fixtures.js' + +vi.mock('@/lib/api', () => ({ api: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/components/markdown/LazyMarkdown', () => ({ LazyMarkdown: ({ content }: { content: string }) =>

{content}

})) +const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 4, observedGeneration: 7 } as const +const readable = (status: 'live' | 'saved' = 'saved') => ({ parentThreadId: 'parent', childThreadId: 'child', status, snapshot: { ...codexContractSnapshot, threadId: 'child', turns: [{ id: 'answer', turnId: 'answer', role: 'assistant', summary: '', items: [{ id: 'body', kind: 'text', text: 'Exact observed child' }] }] } }) + +describe('FreshAgentChildObserver', () => { + afterEach(() => { expect(api.post).not.toHaveBeenCalled(); cleanup(); vi.useRealTimers(); vi.clearAllMocks() }) + + it('reads the parent-scoped route with its owner fence and never offers writer controls', async () => { + vi.mocked(api.get).mockResolvedValue(readable()) + render() + expect(await screen.findByText('Exact observed child')).toBeVisible() + expect(api.get).toHaveBeenCalledWith('/api/fresh-agent/threads/freshcodex/codex/parent/children/child?observedEpoch=4&observedGeneration=7', expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(api.post).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /send|stop|configure|resume/i })).not.toBeInTheDocument() + }) + + it('retains already available content during a source rebind, then refreshes it without changing identity', async () => { + vi.mocked(api.get).mockResolvedValueOnce(readable()).mockRejectedValue(new Error('source unavailable')) + const { rerender } = render() + expect(await screen.findByText('Exact observed child')).toBeVisible() + rerender() + expect(await screen.findByText(/previously available content/i)).toBeVisible() + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + + it('makes pending materialization finite and can later read saved content', async () => { + vi.useFakeTimers() + vi.mocked(api.get).mockResolvedValue({ parentThreadId: 'parent', childThreadId: 'child', status: 'pending', reason: 'history_unmaterialized', retryable: true }) + render() + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(api.get).toHaveBeenCalledTimes(5) + expect(screen.getByText(/history is unavailable/i)).toBeVisible() + vi.mocked(api.get).mockResolvedValue(readable()) + fireEvent.click(screen.getByRole('button', { name: 'Refresh child history' })) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + + it('moves from genuinely pending history to the exact materialized child without a writer', async () => { + vi.useFakeTimers() + vi.mocked(api.get).mockResolvedValueOnce({ parentThreadId: 'parent', childThreadId: 'child', status: 'pending', reason: 'history_unmaterialized', retryable: true }).mockResolvedValue(readable()) + render() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText('Waiting for child history to be saved.')).toBeVisible() + expect(screen.queryByText('Exact observed child')).not.toBeInTheDocument() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.get).toHaveBeenCalledTimes(2) + }) + + it('does not poll permanently unavailable ephemeral history or synthesize an empty transcript', async () => { + vi.mocked(api.get).mockResolvedValue({ parentThreadId: 'parent', childThreadId: 'child', status: 'unavailable', reason: 'ephemeral_history_unavailable', retryable: false }) + render() + expect(await screen.findByText(/ephemeral child/i)).toBeVisible() + expect(screen.queryByRole('button', { name: 'Refresh child history' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Assistant transcript turn')).not.toBeInTheDocument() + expect(api.get).toHaveBeenCalledTimes(1) + }) + + it('rejects a response for a different child and returns focus after deliberate close', async () => { + vi.mocked(api.get).mockResolvedValue({ ...readable(), childThreadId: 'unrelated' }) + const onClose = vi.fn() + const opener = document.createElement('button') + document.body.append(opener) + opener.focus() + const { unmount } = render() + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(screen.queryByText('Exact observed child')).not.toBeInTheDocument() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + unmount() + expect(opener).toHaveFocus() + opener.remove() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx index b5cf0f7cb..74ec6585d 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { FreshAgentItemCard, FreshAgentDelegationBlock, FreshAgentOpenSessionContext, FreshAgentToolBlock, stripSystemReminders } from '@/components/fresh-agent/FreshAgentItemCard' +import { FreshAgentItemCard, FreshAgentDelegationBlock, FreshAgentOpenSessionContext, FreshAgentObserveChildContext, FreshAgentToolBlock, stripSystemReminders } from '@/components/fresh-agent/FreshAgentItemCard' import { formatThoughtDuration } from '@/components/fresh-agent/shared/format-duration' vi.mock('@/components/markdown/LazyMarkdown', async () => { @@ -13,6 +13,38 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentItemCard', () => { afterEach(() => cleanup()) + it('shows the actual native sleep duration in the activity preview', () => { + render() + expect(screen.getByText('1250 ms')).toBeVisible() + }) + + it('keeps each collaboration receiver outcome and selects its exact child without a resume action', () => { + const onObserve = vi.fn(), openSession = vi.fn() + const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 8, observedGeneration: 9 } as const + render() + expect(screen.getByText(/First result/)).toHaveTextContent('first-model') + expect(screen.getByText(/Second error/)).toHaveTextContent('second-model') + fireEvent.click(screen.getByRole('button', { name: 'Observe child child-two' })) + expect(onObserve).toHaveBeenCalledWith({ type: 'observe-child', parent, childThreadId: 'child-two' }) + expect(openSession).not.toHaveBeenCalled() + }) + + it('renders saved native choices and keeps image output accessible through authenticated file links', () => { + render(<>) + expect(screen.getByText('Next step?')).toBeVisible() + expect(screen.getByText('Inspect')).toBeVisible() + expect(screen.getByText('Verify')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: 'ImageGeneration tool call' })) + expect(screen.getByRole('link', { name: '/tmp/generated output.png' })).toHaveAttribute('href', '/api/files/raw?path=%2Ftmp%2Fgenerated%20output.png') + }) + + it('renders a declined command as declined rather than completed', () => { + render() + expect(screen.getByLabelText('declined')).toBeInTheDocument() + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + expect(screen.getByText(/declined/i)).toBeInTheDocument() + }) + it('shows native file-change output progress in its tool disclosure', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Edit tool call' })) diff --git a/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx index eb8321d6e..51078f5c6 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx @@ -1,9 +1,19 @@ -import { afterEach, expect, it } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { FreshAgentSidebar } from '@/components/fresh-agent/FreshAgentSidebar' +import { FreshAgentObserveChildContext } from '@/components/fresh-agent/FreshAgentItemCard' afterEach(cleanup) +it('observes the selected Codex identity with its parent fence from the child panel', () => { + const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 1, observedGeneration: 2 } as const + const onObserve = vi.fn() + render() + expect(screen.getByText(/Child failure/)).toHaveTextContent('child-model') + fireEvent.click(screen.getByRole('button', { name: 'Observe child child' })) + expect(onObserve).toHaveBeenCalledWith({ type: 'observe-child', parent, childThreadId: 'child' }) +}) + it('renders the latest plan and step statuses, replacing earlier progress', () => { const props: any = { worktrees: [], childThreads: [], codexPlan: { explanation: 'Work in order', plan: [{ step: 'Inspect', status: 'inProgress' }, { step: 'Verify', status: 'pending' }] } } const { rerender } = render() diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index 1ff006a4a..1a582c7bf 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -23,6 +23,27 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentTranscript', () => { afterEach(() => cleanup()) + it('shows native asynchronous choices when the agent message has no text body', () => { + render() + expect(screen.getByText('Choose next step')).toBeVisible() + expect(screen.getByText('Inspect')).toBeVisible() + expect(screen.getByText('Verify')).toBeVisible() + }) + + it.each(['running', 'completed', 'failed', 'declined'] as const)('preserves %s command and file outcomes in the transcript', (status) => { + render() + const label = { running: 'running', completed: 'complete', failed: 'error', declined: 'declined' }[status] + expect(screen.getAllByLabelText(label)).toHaveLength(2) + if (status !== 'completed') expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + const command = screen.getByRole('button', { name: 'Bash tool call' }) + if (command.getAttribute('aria-expanded') !== 'true') fireEvent.click(command) + expect(screen.getByText('Command output')).toBeVisible() + if (status === 'failed') expect(screen.getByText('Exit code: 17')).toBeVisible() + }) + it.each([ [{ provenance: 'saved', diagnostic: 'interrupted', refreshPending: false }, 'Only saved output remains.'], [{ provenance: 'retained', diagnostic: 'unsaved', refreshPending: false }, 'Saved output could not be confirmed.'], diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 1982c8100..38b0ee666 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15005,3 +15005,33 @@ describe('task delegation open-session link (freshopencode tui parity)', () => { }) }) }) + +describe('Codex child observation in the parent view', () => { + it('persists only the selected child, preserves the parent draft and actions, and reopens without a child lifecycle', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const childId = '22222222-2222-4222-8222-222222222222' + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...native, childThreads: [{ id: childId, threadId: childId, origin: 'spawnAgent' }] }) + apiMock.get.mockImplementation((url: string) => Promise.resolve(url.includes('/children/') ? { + parentThreadId: native.threadId, childThreadId: childId, status: 'saved', snapshot: { ...native, threadId: childId, turns: [{ id: 'child-answer', turnId: 'child-answer', role: 'assistant', summary: '', items: [{ id: 'child-text', kind: 'text', text: 'Read-only child answer' }] }] }, + } : { checkpoints: [] })) + const store = createStore() + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: native.threadId, sessionRef: { provider: 'codex', sessionId: native.threadId }, createRequestId: 'parent-observe', status: 'idle' } })) + const mounted = render() + const input = await screen.findByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(input, { target: { value: 'Parent draft survives observation' } }) + fireEvent.click(await screen.findByRole('button', { name: `Observe child ${childId}` })) + expect(await screen.findByText('Read-only child answer')).toBeVisible() + expect(input).toHaveValue('Parent draft survives observation') + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionId: native.threadId, observedChildThreadId: childId }) + fireEvent.click(screen.getByRole('button', { name: 'Close child conversation' })) + expect(screen.queryByRole('dialog', { name: 'Child conversation' })).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store).observedChildThreadId).toBeUndefined() + fireEvent.click(screen.getByRole('button', { name: `Observe child ${childId}` })) + expect(await screen.findByText('Read-only child answer')).toBeVisible() + mounted.unmount() + render() + expect(await screen.findByText('Read-only child answer')).toBeVisible() + expect(getFreshAgentPaneContent(store).sessionRef).toEqual({ provider: 'codex', sessionId: native.threadId }) + expect(wsMock.send.mock.calls.map(([message]) => message).filter((message) => message.sessionId === childId || message.sessionRef?.sessionId === childId)).toEqual([]) + }) +}) diff --git a/test/unit/client/store/persistedState.test.ts b/test/unit/client/store/persistedState.test.ts index 6339f3239..ab4240aa8 100644 --- a/test/unit/client/store/persistedState.test.ts +++ b/test/unit/client/store/persistedState.test.ts @@ -25,6 +25,17 @@ const codexDurability = { } describe('persistedState parsers', () => { + it('restores a Codex observer selection under the parent identity and drops malformed selections', () => { + for (const selection of ['native-child', [], '', null]) { + const content = { kind: 'fresh-agent', provider: 'codex', sessionType: 'freshcodex', sessionRef: { provider: 'codex', sessionId: '11111111-1111-4111-8111-111111111111' }, createRequestId: 'parent-create', status: 'idle', observedChildThreadId: selection } + const parsed = parsePersistedPanesRaw(JSON.stringify({ version: PANES_SCHEMA_VERSION, layouts: { parent: { type: 'leaf', id: 'parent-pane', content } }, activePane: { parent: 'parent-pane' } })) + const restored = (parsed!.layouts.parent as any).content + expect(restored.sessionRef).toEqual(content.sessionRef) + expect(restored.createRequestId).toBe('parent-create') + expect(restored.observedChildThreadId).toBe(selection === 'native-child' ? 'native-child' : undefined) + expect(Object.keys(parsed!.layouts)).toEqual(['parent']) + } + }) it('uses v2 namespaced storage and broadcast keys', () => { expect(TABS_STORAGE_KEY).toBe('freshell.tabs.v2') expect(PANES_STORAGE_KEY).toBe('freshell.panes.v2') diff --git a/test/unit/shared/fresh-agent-contract.test.ts b/test/unit/shared/fresh-agent-contract.test.ts index b9891d44f..1427b931c 100644 --- a/test/unit/shared/fresh-agent-contract.test.ts +++ b/test/unit/shared/fresh-agent-contract.test.ts @@ -4,6 +4,7 @@ import { FRESH_AGENT_CONTRACT_SCHEMA_NAMES, FreshAgentActionResultSchema, FreshAgentCapabilitiesSchema, + FreshAgentChildObservationSchema, FreshAgentContractErrorSchema, FreshAgentRequestIdSchema, FreshAgentSessionCommandSchema, @@ -26,6 +27,18 @@ import { } from '../../fixtures/fresh-agent/codex/contract-fixtures.js' describe('fresh-agent shared contract schemas', () => { + it('requires the exact child body only for readable observations and preserves finite unavailable states', () => { + const identity = { parentThreadId: 'parent', childThreadId: codexContractSnapshot.threadId } + const saved = FreshAgentChildObservationSchema.parse({ ...identity, status: 'saved', snapshot: codexContractSnapshot }) + expect(saved.status).toBe('saved') + for (const reason of ['ephemeral_history_unavailable', 'history_unavailable', 'source_unavailable']) { + const unavailable = { ...identity, status: 'unavailable', reason, retryable: reason === 'source_unavailable' } + expect(FreshAgentChildObservationSchema.safeParse(unavailable).success).toBe(true) + expect(FreshAgentChildObservationSchema.safeParse({ ...unavailable, snapshot: codexContractSnapshot }).success).toBe(false) + } + expect(FreshAgentChildObservationSchema.safeParse({ ...identity, status: 'pending', reason: 'history_unmaterialized', retryable: true }).success).toBe(true) + expect(FreshAgentChildObservationSchema.safeParse({ ...identity, childThreadId: 'wrong', status: 'live', snapshot: codexContractSnapshot }).success).toBe(false) + }) it('validates native delta patches and rejects unsafe revisions or malformed operations', () => { const patch = { type: 'freshAgent.transcript.patch', sessionId: 'thread', projectionId: 'runtime', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 'turn', rowId: 'turn:row-1', itemId: 'answer', field: 'text', text: '🙂', revision: 1 }] } expect(FreshAgentTranscriptPatchSchema.parse(patch).ops[0]).toMatchObject({ text: '🙂', revision: 1 }) From 7cd28e1c13cfdb854524ef232d65713886a26243 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:16:28 -0700 Subject: [PATCH 12/92] fix(codex): label observed native history previews --- .../freshell-freshagent/src/codex/children.rs | 59 +++++++++++++++++++ .../freshcodex-child-observer-rust.spec.ts | 8 ++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/freshell-freshagent/src/codex/children.rs b/crates/freshell-freshagent/src/codex/children.rs index 2e1e5a82d..ae074fe03 100644 --- a/crates/freshell-freshagent/src/codex/children.rs +++ b/crates/freshell-freshagent/src/codex/children.rs @@ -144,6 +144,7 @@ impl FreshCodexState { false, )); } + stamp_child_history_previews(&mut snapshot, &raw)?; stamp_readonly_child(&mut snapshot, &parent); // Availability does not imply a turn completed or persisted every token. snapshot["extensions"]["codex"]["childHistory"] = json!({ @@ -198,6 +199,36 @@ impl FreshCodexState { } } +fn stamp_child_history_previews(snapshot: &mut Value, raw: &Value) -> Result<(), ChildReadError> { + let preview_ids: std::collections::HashSet = raw["thread"]["turns"] + .as_array() + .into_iter() + .flatten() + .filter(|turn| turn["itemsView"] == "summary") + .map(|turn| build_codex_turn_json(turn, 0)) + .collect::, _>>() + .map_err(|_| ChildReadError::SourceUnavailable)? + .into_iter() + .flatten() + .filter_map(|row| row["id"].as_str().map(str::to_owned)) + .collect(); + for row in snapshot["turns"].as_array_mut().into_iter().flatten() { + if row["id"] + .as_str() + .is_some_and(|id| preview_ids.contains(id)) + { + // Keep native interruption/truncation diagnostics, while never + // presenting a provider summary as the complete saved body. + if row.get("outputState").is_none() { + row["outputState"] = json!({}); + } + row["outputState"]["provenance"] = json!("preview"); + row["outputState"]["refreshPending"] = json!(false); + } + } + Ok(()) +} + pub fn stamp_readonly_child(snapshot: &mut Value, parent: &str) { if let Some(capabilities) = snapshot["capabilities"].as_object_mut() { for value in capabilities.values_mut() { @@ -448,6 +479,34 @@ mod tests { assert!(matches!(result,Err(ChildReadError::SourceUnavailable)),"temporary source failure must not become pending or permanently missing history: {result:?}"); } + #[tokio::test] + async fn child_read_native_summary_is_preview_separate_from_readable_availability() { + let (state,driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"turns":[{"id":"summary-turn","status":"interrupted","itemsView":"summary","items":[{"id":"preview","type":"agentMessage","summary":"Native child preview"}]}]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + state.shutdown().await; + let _ = driver.await.unwrap(); + assert_eq!(value["status"], "saved"); + assert_eq!( + value["snapshot"]["turns"][0]["items"][0]["text"], + "Native child preview" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["provenance"], + "preview" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["diagnostic"], + "interrupted" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["refreshPending"], + false + ); + } + #[tokio::test] async fn child_read_rejects_replaced_parent_source_during_paused_read() { let (transport, peer) = freshell_codex::new_channel_transport(); diff --git a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts index d97858cd2..34772444d 100644 --- a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts +++ b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts @@ -27,7 +27,10 @@ test('Freshcodex observes the exact child read-only and restores the selection a { id: 'image-output', type: 'imageGeneration', status: 'completed', result: outputImage, savedPath: outputImage }, { id: 'spawn', type: 'collabAgentToolCall', tool: 'spawnAgent', status: 'completed', senderThreadId: parentId, receiverThreadIds: [childId], agentsStates: { [childId]: { status: 'completed', message: 'Child finished', model: 'child-model' } } }, ] }] } - const child = { id: childId, parentThreadId: parentId, ephemeral: false, status: { type: 'idle' }, turns: [{ id: 'child-turn', status: 'completed', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer' }] }] } + const child = { id: childId, parentThreadId: parentId, ephemeral: false, status: { type: 'idle' }, turns: [ + { id: 'child-turn', status: 'completed', itemsView: 'full', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer' }] }, + { id: 'preview-turn', status: 'interrupted', itemsView: 'summary', items: [{ id: 'child-preview', type: 'agentMessage', summary: 'Partial child preview' }] }, + ] } const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', @@ -59,12 +62,15 @@ test('Freshcodex observes the exact child read-only and restores the selection a await open.click({ timeout: 15_000 }) const observer = page.getByRole('dialog', { name: 'Child conversation' }) await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() + await expect(observer.getByText('Partial child preview', { exact: true })).toBeVisible() + await expect(observer.getByRole('note', { name: 'Output persistence' })).toHaveText('Native preview. Interrupted.') await expect(observer.getByRole('textbox')).toHaveCount(0) await expect(observer.getByRole('button', { name: /send|stop|configure|resume/i })).toHaveCount(0) await page.reload() await harness.waitForHarness() await harness.waitForConnection() await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(observer.getByRole('note', { name: 'Output persistence' })).toHaveText('Native preview. Interrupted.') await expect(pane.getByText('Parent conversation remains available', { exact: true })).toHaveCount(1) await observer.getByRole('button', { name: 'Close child conversation' }).click() await expect(observer).toHaveCount(0) From d15783e5b35cacb728d0f6431d986a86058db7f8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:31:28 -0700 Subject: [PATCH 13/92] fix(codex): preserve observed native MCP outcomes --- .../fresh-agent/FreshAgentItemCard.tsx | 1 + .../FreshAgentChildObserver.test.tsx | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/components/fresh-agent/FreshAgentItemCard.tsx b/src/components/fresh-agent/FreshAgentItemCard.tsx index 0a861f94d..70458a276 100644 --- a/src/components/fresh-agent/FreshAgentItemCard.tsx +++ b/src/components/fresh-agent/FreshAgentItemCard.tsx @@ -302,6 +302,7 @@ export function itemToToolDisplay(item: FreshAgentTranscriptItem): FreshAgentToo references: outputReferences(item.result), isError: item.status === 'failed', status: item.status === 'running' ? 'running' : 'complete', + nativeOutcome: true, } } if (item.kind === 'dynamic_tool') { diff --git a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx index 78a5c3b9b..0bdff8cea 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx @@ -32,6 +32,23 @@ describe('FreshAgentChildObserver', () => { expect(api.post).not.toHaveBeenCalled() }) + it('preserves explicit running native MCP outcomes when read-only history becomes unavailable', async () => { + const response = readable('live') + const live = { ...response, snapshot: { ...response.snapshot, status: 'running', turns: [{ id: 'tools', turnId: 'tools', role: 'assistant', summary: '', items: [ + { id: 'first', kind: 'mcp_tool', server: 'native', tool: 'first', status: 'running', arguments: {} }, + { id: 'second', kind: 'mcp_tool', server: 'native', tool: 'second', status: 'running', arguments: {} }, + ] }] } } + vi.mocked(api.get).mockResolvedValueOnce(live).mockRejectedValue(new Error('source unavailable')) + const { rerender } = render() + fireEvent.click(await screen.findByRole('button', { name: 'Toggle activity details' })) + expect(screen.getAllByLabelText('running')).toHaveLength(2) + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + rerender() + expect(await screen.findByText(/previously available content/i)).toBeVisible() + expect(screen.getAllByLabelText('running')).toHaveLength(2) + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + }) + it('makes pending materialization finite and can later read saved content', async () => { vi.useFakeTimers() vi.mocked(api.get).mockResolvedValue({ parentThreadId: 'parent', childThreadId: 'child', status: 'pending', reason: 'history_unmaterialized', retryable: true }) From ec0ab3f69002ce8fa3b4074873c67e7b1ccd0cbd Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:02:26 -0700 Subject: [PATCH 14/92] test: bound browser discovery and budget full roster scans --- .../tooling/testing/browser-selection.test.ts | 84 +++++++++++++------ 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/test/unit/tooling/testing/browser-selection.test.ts b/test/unit/tooling/testing/browser-selection.test.ts index aea580c71..adc3621dc 100644 --- a/test/unit/tooling/testing/browser-selection.test.ts +++ b/test/unit/tooling/testing/browser-selection.test.ts @@ -1,7 +1,8 @@ -import { execFileSync, spawnSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { performance } from 'node:perf_hooks' +import { describe, expect, it, vi } from 'vitest' import { LOCAL_ONLY_SPECS } from '../../../e2e-browser/playwright.config.js' import { CLOUD_SKIP_SPECS, CLOUD_SKIP_TITLES } from '../../../e2e-browser/playwright.cloud.config.js' import { createE2eServerHandle } from '../../../e2e-browser/helpers/external-target.js' @@ -31,6 +32,23 @@ function cleanEnvironment(overrides: Record = {}): NodeJS.Proces return { ...env, ...overrides } } +function runDiscovery(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 40_000) { + const started = performance.now() + const result = spawnSync(process.execPath, args, { + cwd: projectRoot, env, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', + }) + const durationMs = Math.round(performance.now() - started) + if (result.error || result.signal) { + const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' + const failure = timedOut ? `timed out after ${timeoutMs}ms` : 'failed' + const context = `CI=${env.CI ?? 'unset'}, elapsed=${durationMs}ms, signal=${result.signal}` + console.error(JSON.stringify({ severity: 'error', event: 'playwright.discovery.failed', + args, ci: env.CI ?? null, timeoutMs, durationMs, signal: result.signal, error: result.error?.message })) + throw new Error(`Playwright discovery ${failure}: ${args.join(' ')} (${context})\n${result.stderr}`, { cause: result.error }) + } + return { ...result, durationMs } +} + function resolvedConfig(configPath: string, env: NodeJS.ProcessEnv): ResolvedProject[] { const script = String.raw` const { loadConfigFromFile } = require(process.argv[1]) @@ -50,12 +68,9 @@ loadConfigFromFile(process.argv[2]).then((config) => { process.exitCode = 1 }) ` - const output = execFileSync(process.execPath, ['-e', script, configLoader, configPath], { - cwd: projectRoot, - env, - encoding: 'utf8', - }) - return JSON.parse(output) as ResolvedProject[] + const result = runDiscovery(['-e', script, configLoader, configPath], env) + if (result.status !== 0) throw new Error(`Playwright config resolution failed:\n${result.stdout}\n${result.stderr}`) + return JSON.parse(result.stdout) as ResolvedProject[] } function listedProjects( @@ -63,25 +78,27 @@ function listedProjects( configPath = playwrightConfig, selectors: string[] = [], ): { output: string; labels: string[]; tests: number; files: number } { - const output = execFileSync(process.execPath, [ + const result = runDiscovery([ playwrightCli, 'test', '--config', configPath, '--list', ...selectors, - ], { - cwd: projectRoot, - env, - encoding: 'utf8', - }) + ], env) + if (result.status !== 0) throw new Error(`Playwright list failed:\n${result.stdout}\n${result.stderr}`) + const output = result.stdout const total = output.match(/Total: (\d+) tests? in (\d+) files?/) if (!total) throw new Error(`Playwright list output did not include a total:\n${output}`) - return { + const listed = { output, labels: [...new Set([...output.matchAll(/^\s*\[([^\]]+)] ›/gm)].map((match) => match[1]))], tests: Number(total[1]), files: Number(total[2]), } + console.info(JSON.stringify({ severity: 'info', event: 'playwright.discovery.listed', + config: path.basename(configPath), ci: env.CI ?? null, selectors, durationMs: result.durationMs, + projects: listed.labels, tests: listed.tests, files: listed.files })) + return listed } // Playwright --list roster lines have the exact shape @@ -96,6 +113,21 @@ function listedSpecFiles(output: string): Set { } describe('browser selection non-vacuity', () => { + it('bounds a stalled discovery subprocess and identifies its command', () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => runDiscovery(['-e', 'setTimeout(() => {}, 1000)'], cleanEnvironment(), 50)) + .toThrow(/Playwright discovery timed out after 50ms.*-e/) + expect(errorLog).toHaveBeenCalledTimes(1) + expect(JSON.parse(errorLog.mock.calls[0][0] as string)).toMatchObject({ + severity: 'error', event: 'playwright.discovery.failed', timeoutMs: 50, + error: expect.stringContaining('ETIMEDOUT'), + }) + } finally { + errorLog.mockRestore() + } + }) + it('discovers the managed external cases only through their paired dedicated Cloud project', () => { const spec = 'managed-provider-environment-external.spec.ts' const ordinary = listedProjects(cleanEnvironment(), cloudConfig) @@ -112,8 +144,8 @@ describe('browser selection non-vacuity', () => { expect(selected.files).toBe(1) expect([...listedSpecFiles(selected.output)]).toEqual([spec]) // The source exists now: ordinary projects still cannot execute it. - const ordinaryExplicit = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, - '--list', '--project=chromium', spec], { cwd: projectRoot, env: paired, encoding: 'utf8' }) + const ordinaryExplicit = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, + '--list', '--project=chromium', spec], paired) expect(ordinaryExplicit.status).toBe(1) }, 90_000) @@ -135,8 +167,8 @@ describe('browser selection non-vacuity', () => { expect(selected.tests).toBe(5) expect(selected.files).toBe(1) expect([...listedSpecFiles(selected.output)]).toEqual([spec]) - const ordinaryExplicit = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, - '--list', '--project=chromium', spec], { cwd: projectRoot, env: paired, encoding: 'utf8' }) + const ordinaryExplicit = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, + '--list', '--project=chromium', spec], paired) expect(ordinaryExplicit.status).toBe(1) }, 90_000) @@ -144,13 +176,15 @@ describe('browser selection non-vacuity', () => { const env = cleanEnvironment({ FRESHELL_E2E_EXTERNAL_FIXTURE_KIND: 'managed-environment', FRESHELL_E2E_TARGET_URL: 'https://fixture.invalid', FRESHELL_E2E_TARGET_TOKEN: 'synthetic-selection-token', ...(pairing === 'mismatched' ? { FRESHELL_CODE_CHECKPOINT_FIXTURE: '{}' } : {}) }) - const listed = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, '--list', - '--project=chromium-external-target', 'managed-provider-environment-external.spec.ts'], - { cwd: projectRoot, env, encoding: 'utf8' }) + const listed = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, '--list', + '--project=chromium-external-target', 'managed-provider-environment-external.spec.ts'], env) expect(listed.status).toBe(1) expect(`${listed.stdout}\n${listed.stderr}`).toContain('matching fixture pair') }) + // Two full discovery scans import every browser spec. Their measured cloud + // runtime exceeds the generic 30s unit-test budget; each child remains bounded + // at 40s, with 90s for both scans and their roster assertions. it('selects a non-vacuous Chromium lane and all CI application projects', () => { const chromium = listedProjects(cleanEnvironment()) expect(chromium.labels).toEqual(['chromium']) @@ -190,7 +224,7 @@ describe('browser selection non-vacuity', () => { expect(ci.labels).toEqual(['chromium', 'firefox', 'webkit']) const retiredProjectNames = [`legacy${'-chromium'}`, `rust${'-chromium'}`] expect(ci.output).not.toMatch(new RegExp(`${retiredProjectNames.join('|')}|Total:\\s*0 tests in`, 'i')) - }) + }, 90_000) it('creates Rust fixtures and selects the supported cloud projects', async () => { const server = await createE2eServerHandle({}) @@ -247,13 +281,13 @@ describe('browser selection non-vacuity', () => { // An ALL-skip focused invocation fails LOUDLY (exit 1, "No tests // found") instead of silently reading as a green zero-test run — a // vacuous focused cloud proof can never masquerade as coverage. - const allSkip = spawnSync(process.execPath, [ + const allSkip = runDiscovery([ playwrightCli, 'test', '--config', cloudConfig, '--list', 'test/e2e-browser/specs/truly-idle-alerting.spec.ts', - ], { cwd: projectRoot, env: cleanEnvironment(), encoding: 'utf8' }) + ], cleanEnvironment()) expect(allSkip.status).toBe(1) expect(`${allSkip.stdout}\n${allSkip.stderr}`).toContain('No tests found') From fa5ff93fe9ff26ec18c268f9529ed5e890a7797c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:35 -0700 Subject: [PATCH 15/92] test: await Claude MCP turn drain before capture closes --- .../providers/claude-cli-contract.test.ts | 9 +++ .../providers/claude-cli-scenarios.mjs | 6 +- .../claude-scenario-drain.test.ts | 77 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 test/unit/provider-fixtures/claude-scenario-drain.test.ts diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index f9baf0f30..59bda460f 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -503,6 +503,15 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p }) expect(results(r, 'S').map((m) => m.subtype)).toEqual(['success']) expect(results(r, 'M').map((m) => m.subtype)).toEqual(['success']) + for (const [session, uuid] of [['S', 'u1'], ['M', 'u2']]) { + const events = eventsOf(r, session) + const close = at(events, event => event.k === 'close') + const terminal = events.slice(0, close).filter(event => event.k === 'msg') + .map(event => event.m).filter(message => isResult(message) || message.type === 'command_lifecycle' || isState('idle')(message)) + .slice(-3) + expect(terminal.map(message => isResult(message) ? 'result' : message.state)).toEqual(['result', 'completed', 'idle']) + expect(terminal[1].command_uuid).toBe(uuid) + } }, SCENARIO_TIMEOUT) }) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index bcbdede8e..f77a8df58 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -364,7 +364,7 @@ function transcriptShape(lines) { })) } -const SCENARIOS = { +export const SCENARIOS = { /** T1 T2 T3 T7 T8: fresh boot with our session id, settings before the first prompt, two turns. */ async boot(ctx) { const X = ctx.uuid('X') @@ -760,6 +760,9 @@ const SCENARIOS = { }) s.push('MCP:call=mcp__freshell__context', 'u1') await s.waitForResults(1, 30_000) + // A result precedes command completion and the queue's idle frame. Closing + // here would truncate the real trace according to pipe delivery timing. + ctx.need(await s.waitForCount(isStateIdle, 1, 10_000, 'MCP connected idle'), 'MCP connected idle') ctx.observe('connected', mcpTurnShape(s)) const seen = JSON.parse(await fs.readFile(report, 'utf8')) delete seen.pid @@ -769,6 +772,7 @@ const SCENARIOS = { const m = ctx.open('M') m.push('MCP:call=mcp__freshell__freshell', 'u2') await m.waitForResults(1, 30_000) + ctx.need(await m.waitForCount(isStateIdle, 1, 10_000, 'MCP missing tool idle'), 'MCP missing tool idle') ctx.observe('missing', mcpTurnShape(m)) await m.close() }, diff --git a/test/unit/provider-fixtures/claude-scenario-drain.test.ts b/test/unit/provider-fixtures/claude-scenario-drain.test.ts new file mode 100644 index 000000000..6768dc586 --- /dev/null +++ b/test/unit/provider-fixtures/claude-scenario-drain.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' +import { SCENARIOS } from '../../integration/providers/claude-cli-scenarios.mjs' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +it('captures each MCP turn through its terminal idle before closing either SDK query', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-scenario-drain-')) + await fs.writeFile(path.join(root, 'mcp-probe-report.json'), '{}') + const sessions = [makeSession(), makeSession()] + let opened = 0 + const capturing = SCENARIOS.mcp({ + fixture: { root }, + open: () => sessions[opened++], + observe: () => {}, + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + try { + for (const session of sessions) { + // The result is already available; only the native drain is withheld. + expect(await session.boundary.promise).toBe('await-idle') + expect(session.closed).toBe(false) + session.releaseIdle() + await session.didClose.promise + expect(session.capturedAtClose).toEqual(['result', 'completed', 'idle']) + } + await capturing + } finally { + for (const session of sessions) session.releaseIdle() + await capturing.finally(() => fs.rm(root, { recursive: true, force: true })) + } +}) + +function makeSession() { + const boundary = deferred() + const idle = deferred() + const didClose = deferred() + const frames: any[] = [{ type: 'result' }] + return { + boundary, + didClose, + closed: false, + capturedAtClose: [] as string[], + push() {}, + messages(predicate: (message: any) => boolean) { return frames.filter(predicate) }, + async waitForResults() { return frames[0] }, + async waitForCount(predicate: (message: any) => boolean, count: number) { + boundary.resolve('await-idle') + await idle.promise + const matches = frames.filter(predicate) + return matches.length >= count ? matches.at(-1) : null + }, + releaseIdle() { + if (frames.length === 1) frames.push( + { type: 'command_lifecycle', state: 'completed' }, + { type: 'system', subtype: 'session_state_changed', state: 'idle' }, + ) + idle.resolve() + }, + async close() { + this.closed = true + this.capturedAtClose = frames.map(message => message.state ?? message.type) + boundary.resolve('closed-before-idle') + didClose.resolve() + }, + } +} From 132d67bc9da9b507d0cf60542e0515bf612c28f6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:40 -0700 Subject: [PATCH 16/92] test: join native Codex stdio during owned process cleanup --- .../providers/codex-cli-contract.mjs | 11 +++- .../providers/codex-cli-contract.test.ts | 1 + .../codex-native-close.test.ts | 61 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 test/unit/provider-fixtures/codex-native-close.test.ts diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs index 5585b5735..a880e0e2b 100644 --- a/test/integration/providers/codex-cli-contract.mjs +++ b/test/integration/providers/codex-cli-contract.mjs @@ -88,7 +88,7 @@ async function roots(options) { return { artifact, metadata, managed, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged, policyClientProbe } } -class NativeClient { +export class NativeClient { constructor(binary, context, capture, args = ['app-server']) { this.capture = capture this.pending = new Map() @@ -125,6 +125,10 @@ class NativeClient { for (const entry of this.pending.values()) entry.reject(new Error(`owned Codex exited: ${code}/${signal}`)) resolve({ code, signal }) })) + this.closed = new Promise(resolve => this.child.once('close', (code, signal) => { + ;(capture.processCloses ??= []).push({ connectionId: this.connectionId, pid: this.child.pid, code, signal }) + resolve({ code, signal }) + })) this.child.stderr.on('data', bytes => { capture.stderr = `${capture.stderr ?? ''}${bytes.toString()}`.slice(-64 * 1024) }) createInterface({ input: this.child.stdout }).on('line', line => { let frame @@ -175,8 +179,11 @@ class NativeClient { } async close() { this.child.stdin.end() - if (this.child.exitCode === null) this.child.kill('SIGTERM') + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGTERM') try { await deadline(this.exit, 5000, 'owned process exit') } catch { this.child.kill('SIGKILL'); await deadline(this.exit, 5000, 'owned process kill') } + // The bubblewrap launcher can exit before its PID namespace finishes + // teardown. Its descendants inherit these pipes; join their closure too. + await deadline(this.closed, 5000, `owned process stdio close pid=${this.child.pid} exit=${this.child.exitCode} signal=${this.child.signalCode}`) } } diff --git a/test/integration/providers/codex-cli-contract.test.ts b/test/integration/providers/codex-cli-contract.test.ts index 537de5a0a..9ffe1e67a 100644 --- a/test/integration/providers/codex-cli-contract.test.ts +++ b/test/integration/providers/codex-cli-contract.test.ts @@ -44,6 +44,7 @@ describe('Codex 0.164.0 release contract through an isolated Responses provider' expect(capture.observations.requirementsGuard.refusedRoutes).toEqual(['create', 'resume', 'fork', 'send']) expect(capture.observations.requirementsGuard.workRequests).toBe(0) expect(capture.observations.requirementsGuard.ownedListenerSettled).toBe(true) + expect(capture.processCloses).toEqual(capture.processExits) expect(capture.fixtureTraffic).toHaveLength(0) }, 180_000) diff --git a/test/unit/provider-fixtures/codex-native-close.test.ts b/test/unit/provider-fixtures/codex-native-close.test.ts new file mode 100644 index 000000000..80114ce64 --- /dev/null +++ b/test/unit/provider-fixtures/codex-native-close.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal(), spawn })) +import { NativeClient } from '../../integration/providers/codex-cli-contract.mjs' + +afterEach(() => { vi.useRealTimers(); vi.clearAllMocks() }) + +function fixture() { + const child = Object.assign(new EventEmitter(), { + pid: 12345, + exitCode: null as number | null, + signalCode: null as string | null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + kill: vi.fn((signal: string) => { + setTimeout(() => { child.signalCode = signal; child.emit('exit', null, signal) }, 0) + return true + }), + }) + spawn.mockReturnValue(child) + const capture: any = { probeOnly: true, environment: {}, observations: {}, frames: [], responses: [], notifications: [] } + const native = new NativeClient('/owned/launcher', { workspace: '/owned/workspace', environment: {} }, capture) + return { child, native, capture } +} + +it('waits beyond launcher exit for inherited native stdio to close, preserving final frames', async () => { + vi.useFakeTimers() + const { child, native, capture } = fixture() + let settled = false + const closing = native.close().then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(child.signalCode).toBe('SIGTERM') + expect(settled).toBe(false) + + child.stdout.write(`${JSON.stringify({ method: 'native/final-frame', params: {} })}\n`) + child.stderr.write('final native shutdown diagnostic') + child.stdout.end(); child.stderr.end() + child.emit('close', null, 'SIGTERM') + await closing + expect(capture.notifications).toEqual([{ method: 'native/final-frame', params: {} }]) + expect(capture.stderr).toBe('final native shutdown diagnostic') + expect(capture.processCloses).toEqual([{ connectionId: 0, pid: child.pid, code: null, signal: 'SIGTERM' }]) + expect(child.kill.mock.calls).toEqual([['SIGTERM']]) + expect(vi.getTimerCount()).toBe(0) +}) + +it('fails within a bounded deadline when an exited launcher leaves native stdio open', async () => { + vi.useFakeTimers() + const { child, native } = fixture() + const closing = native.close() + const rejected = expect(closing).rejects.toThrow(/owned process stdio close.*12345/) + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(5000) + await rejected + expect(child.kill.mock.calls).toEqual([['SIGTERM']]) + child.stdout.end(); child.stderr.end(); child.emit('close', null, 'SIGTERM') + expect(vi.getTimerCount()).toBe(0) +}) From ffe42dabfeac5faee91bd9a693609aeea90fc883 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:18:40 -0700 Subject: [PATCH 17/92] test: await the observed Claude boot assistant in saved history --- .../providers/claude-cli-scenarios.mjs | 40 +++--- .../claude-boot-persistence.test.ts | 119 ++++++++++++++++++ 2 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 test/unit/provider-fixtures/claude-boot-persistence.test.ts diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index f77a8df58..ca59799bd 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -380,7 +380,15 @@ export const SCENARIOS = { await s.waitForResults(1) const resultAt = Date.now() const appeared = await ctx.pollTranscript(X, 4000) - ctx.observe('transcriptAfterFirstResult', { msAfterResult: appeared ? Date.now() - resultAt : null, shape: transcriptShape(appeared) }) + const msAfterResult = appeared ? Date.now() - resultAt : null + const assistantUuid = ctx.need(s.messages(message => message.type === 'assistant' && !message.parent_tool_use_id).at(-1)?.uuid, 'first-turn assistant UUID') + // File appearance can precede the final assistant append. Preserve that + // timing observation, but compare history only once this exact turn saved. + const saved = ctx.need( + await ctx.pollTranscript(X, 4000, 1, lines => lines.some(line => line.type === 'assistant' && line.uuid === assistantUuid)), + `first-turn assistant ${assistantUuid} saved for session ${X} within 4000ms`, + ) + ctx.observe('transcriptAfterFirstResult', { msAfterResult, shape: transcriptShape(saved) }) s.push('second prompt', 'u2') await s.waitForResults(2) await s.waitForCount(isStateIdle, 2, 3000, 'idle') @@ -853,6 +861,21 @@ export const SCENARIOS = { export const SCENARIO_NAMES = Object.keys(SCENARIOS) +/** Poll until the saved lines satisfy the requested count and optional predicate. */ +export async function pollTranscript(fixture, trace, 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 && (!predicate || predicate(lines))) { + trace.add({ k: 'transcript-seen', id, lines: lines.length }) + return lines + } + await sleep(50) + } + trace.add({ k: 'transcript-missing', id }) + return null +} + /** Load the `query` export of the target. */ async function loadTarget(target, repoRoot) { if (target === 'real') { @@ -909,20 +932,7 @@ export async function runScenario(target, name, { repoRoot = DEFAULT_REPO_ROOT } return value }, files: () => fixture.transcripts(), - /** 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 && (!predicate || predicate(lines))) { - trace.add({ k: 'transcript-seen', id, lines: lines.length }) - return lines - } - await sleep(50) - } - trace.add({ k: 'transcript-missing', id }) - return null - }, + pollTranscript: (...args) => pollTranscript(fixture, trace, ...args), } let failure try { diff --git a/test/unit/provider-fixtures/claude-boot-persistence.test.ts b/test/unit/provider-fixtures/claude-boot-persistence.test.ts new file mode 100644 index 000000000..bb88aec40 --- /dev/null +++ b/test/unit/provider-fixtures/claude-boot-persistence.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { pollTranscript, SCENARIOS } from '../../integration/providers/claude-cli-scenarios.mjs' + +afterEach(() => { vi.useRealTimers() }) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +it('keeps first-appearance timing but waits through partial and wrong-assistant disk rows before capturing the first turn', async () => { + vi.useFakeTimers() + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-boot-persistence-')) + const file = path.join(root, 'session.jsonl') + const partial = [{ type: 'user', uuid: 'u1' }, { type: 'attachment', uuid: 'attachment-1' }] + const unrelated = { type: 'assistant', uuid: 'another-turn' } + const expected = { type: 'assistant', uuid: 'first-turn-assistant' } + const write = (rows: unknown[]) => fs.writeFile(file, rows.map(row => JSON.stringify(row)).join('\n') + '\n') + await write(partial) + const boundary = deferred() + const thirdRead = deferred() + const observations: Record = {} + const trace = { add: vi.fn() } + let reads = 0 + let startedSecondTurn = false + const fixture = { + async readTranscript() { + const rows = (await fs.readFile(file, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + reads++ + if (reads === 2) boundary.resolve('waiting-for-assistant-persistence') + if (reads === 3) thirdRead.resolve() + return rows + }, + } + const session = { + frames: [expected], + init: async () => {}, call: async () => {}, + push(_text: string, label: string) { + if (label === 'u2') { startedSecondTurn = true; boundary.resolve('started-second-turn-with-partial-history') } + }, + waitForResults: async () => ({}), waitForCount: async () => ({}), close: async () => {}, + messages(predicate: (message: unknown) => boolean) { return this.frames.filter(predicate) }, + } + const capturing = SCENARIOS.boot({ + uuid: () => 'session-X', open: () => session, files: async () => [], + observe: (key: string, value: unknown) => { observations[key] = value }, + pollTranscript: (...args: any[]) => pollTranscript(fixture, trace, ...args), + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + try { + await vi.advanceTimersByTimeAsync(2000) + expect(await boundary.promise).toBe('waiting-for-assistant-persistence') + expect(startedSecondTurn).toBe(false) + await write([...partial, unrelated]) + await vi.advanceTimersByTimeAsync(50) + await thirdRead.promise + expect(startedSecondTurn).toBe(false) + await write([...partial, unrelated, expected]) + await vi.advanceTimersByTimeAsync(50) + await capturing + expect(observations.transcriptAfterFirstResult).toEqual({ + // This still measures first file appearance, before the delayed assistant. + msAfterResult: 0, + shape: [...partial, unrelated, expected].map(row => ({ type: row.type, uuid: row.uuid, parentUuid: null })), + }) + expect(startedSecondTurn).toBe(true) + } finally { + await write([...partial, expected]) + await vi.advanceTimersByTimeAsync(50) + await capturing.finally(() => fs.rm(root, { recursive: true, force: true })) + } +}) + +it('bounds a missing assistant persistence wait with a transcript-specific trace', async () => { + vi.useFakeTimers() + const trace = { add: vi.fn() } + const fixture = { readTranscript: async () => [{ type: 'user', uuid: 'u1' }] } + const waiting = pollTranscript(fixture, trace, 'session-X', 100, 1, + (rows: any[]) => rows.some(row => row.type === 'assistant' && row.uuid === 'first-turn-assistant')) + await vi.advanceTimersByTimeAsync(150) + await expect(waiting).resolves.toBeNull() + expect(trace.add).toHaveBeenLastCalledWith({ k: 'transcript-missing', id: 'session-X' }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('refuses a boot capture whose observed assistant never saves and identifies both native IDs', async () => { + vi.useFakeTimers() + const trace = { add: vi.fn() } + const fixture = { readTranscript: async () => [{ type: 'user', uuid: 'u1' }] } + const assistant = { type: 'assistant', uuid: 'first-turn-assistant' } + const session = { + frames: [assistant], init: async () => {}, call: async () => {}, push: vi.fn(), + waitForResults: async () => ({}), + messages: (predicate: (message: unknown) => boolean) => [assistant].filter(predicate), + } + const capturing = SCENARIOS.boot({ + uuid: () => 'session-X', open: () => session, files: async () => [], observe: () => {}, + pollTranscript: (...args: any[]) => pollTranscript(fixture, trace, ...args), + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + const rejected = expect(capturing).rejects.toThrow('first-turn assistant first-turn-assistant saved for session session-X within 4000ms') + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(6050) + await rejected + expect(session.push).toHaveBeenCalledExactlyOnceWith('first prompt', 'u1') + expect(trace.add).toHaveBeenLastCalledWith({ k: 'transcript-missing', id: 'session-X' }) + expect(vi.getTimerCount()).toBe(0) +}) From b7fa290926b7f930d949e5ecaf0b7ed7c43453c6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:59:17 -0700 Subject: [PATCH 18/92] test(codex): expose stop acknowledgement and delivery races --- crates/freshell-freshagent/src/codex.rs | 44 +++++++++++++++ .../freshcodex-send-delivery-rust.spec.ts | 54 +++++++++++++++++++ .../fresh-agent/FreshAgentView.test.tsx | 6 ++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 31cacd6f7..c8df331d5 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -19229,6 +19229,50 @@ pub(crate) mod tests { ); } + #[tokio::test] + async fn task4_interrupt_ack_does_not_settle_owed_work() { + let (st, _) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer(&st, "thread-ack", Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), notifs, spawn_sleeper(), "task4-ack").await; + let interrupt = { let st = st.clone(); tokio::spawn(async move { + st.handle_interrupt(FreshAgentInterrupt { session_id: "thread-ack".into(), session_type: Some(SESSION_TYPE.into()), provider: Some(PROVIDER.into()), request_id: None, cwd: None }).await; + }) }; + answer_initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "old"); + peer.respond(&id, json!({})); + interrupt.await.unwrap(); + let owed = st.sessions.lock().await.get("thread-ack").unwrap().turn_in_flight.load(Ordering::SeqCst); + assert!(owed, "interrupt ACK proves submission, never terminal settlement"); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_active_input_steers_expected_turn_and_rejection_preserves_it() { + let (st, _) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer(&st, "thread-steer", Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), notifs, spawn_sleeper(), "task4-steer").await; + let send = { let st = st.clone(); tokio::spawn(async move { st.handle_send(send_msg("thread-steer", "adjust")).await }) }; + answer_initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!(params["expectedTurnId"], "old"); + assert!(params.get("model").is_none(), "steer cannot override turn settings"); + peer.respond_error(&id, -32600, "expected turn does not match active turn"); + send.await.unwrap(); + let guard = st.sessions.lock().await; + let session = guard.get("thread-steer").unwrap(); + assert!(session.turn_in_flight.load(Ordering::SeqCst)); + assert_eq!(session.active_turn.lock().unwrap().as_deref(), Some("old")); + drop(guard); + st.shutdown().await; + } + #[tokio::test] async fn onexit_self_heal_rings_the_edge_for_a_crash_during_the_start_turn_dispatch_window() { // The round-2 review's core crash scenario: the sidecar dies AFTER the diff --git a/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts new file mode 100644 index 000000000..9daad0f8e --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } 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 source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +test('Freshcodex retains a refused prompt with its delivery reason', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-delivery-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ overrides: { 'turn/start': { error: { code: -32602, message: 'This input was refused before admission' } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + await input.fill('Keep the original prompt') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect(input).toHaveValue('Keep the original prompt', { timeout: 15_000 }) + await expect(pane.getByText(/Message not sent:.*refused before admission/)).toBeVisible() + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 38b0ee666..c3dbecea8 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -10252,6 +10252,7 @@ describe('FreshAgentView', () => { ['freshclaude', 'INVALID_SESSION_ID', 'The Claude agent process for this session exited. Resume the session to continue.', 'The Claude agent process for this session exited. Resume the session to continue.'], ['kilroy', 'INTERNAL_ERROR', 'SESSION_NOT_FOUND: claude session not found', 'claude session not found'], + ['freshcodex', 'SESSION_RESERVED', 'Another resume for this session is in flight', 'Another resume for this session is in flight'], ] as const)('puts a refused %s send back into the composer and says why (%s)', async (sessionType, code, message, reason) => { const store = createStore() let onMessage: ((message: Record) => void) | undefined @@ -10266,16 +10267,17 @@ describe('FreshAgentView', () => { turns: [], }) const durable = '0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' + const provider = sessionType === 'freshcodex' ? 'codex' : 'claude' store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', sessionType, - provider: 'claude', + provider, createRequestId: `req-${sessionType}-refused`, sessionId: `${sessionType}-pane-refused`, - sessionRef: { provider: 'claude', sessionId: durable }, + sessionRef: { provider, sessionId: durable }, status: 'idle', }, })) From edd4eb3a583c7851d61cb04300e21b3fc26981f8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:26:29 -0700 Subject: [PATCH 19/92] fix(codex): preserve turn ownership across send and stop races --- crates/freshell-codex/src/app_server.rs | 105 +- crates/freshell-codex/tests/interrupt_rpc.rs | 31 + crates/freshell-freshagent/src/codex.rs | 1166 ++++++++++++++--- .../src/codex/turn_state.rs | 452 +++++++ src/components/fresh-agent/FreshAgentView.tsx | 25 +- .../freshcodex-send-delivery-rust.spec.ts | 17 +- .../codex-app-server/fake-app-server.mjs | 5 + .../fresh-agent/FreshAgentView.test.tsx | 29 +- 8 files changed, 1601 insertions(+), 229 deletions(-) create mode 100644 crates/freshell-freshagent/src/codex/turn_state.rs diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index 7dccc7582..f4036fec9 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -130,6 +130,30 @@ impl std::fmt::Display for CodexAppServerError { impl std::error::Error for CodexAppServerError {} +impl CodexAppServerError { + /// Evidence-backed pre-admission refusals in the supported native protocol. + /// Internal errors, transport failures and lost replies can follow accepted + /// work, so callers must preserve their delivery receipt as ambiguous. + pub fn is_not_submitted(&self) -> bool { + match self { + Self::Rpc { method, error } => { + method == "initialize" + || method == "configRequirements/read" + || matches!(error.code, -32601 | -32602) + || (error.code == -32600 + && (error.message.starts_with("no active turn to ") + || error.message.starts_with("expected active turn id"))) + } + Self::Timeout { method, .. } + | Self::Closed { method } + | Self::Transport { method, .. } + | Self::InvalidResponse { method, .. } => { + matches!(method.as_str(), "initialize" | "configRequirements/read") + } + } + } +} + /// `thread/start` params (`CodexThreadStartInput`, `client.ts:80-83`; adapter create, /// `adapter.ts:825-830`). The client adds `experimentalRawEvents:false` + /// `persistExtendedHistory:true` (`client.ts:170-174`). @@ -584,7 +608,35 @@ impl CodexAppServerClient { Ok(StartedTurn { turn_id }) } - /// `turn/interrupt` (`client.ts:433-439`). + /// Active input belongs to the exact native turn the caller observed. + /// Native steer accepts no setting overrides and returns that same turn ID. + pub async fn steer_turn( + &self, + thread_id: &str, + expected_turn_id: &str, + input: Vec, + ) -> Result { + let result = self + .request( + "turn/steer", + json!({"threadId": thread_id, "expectedTurnId": expected_turn_id, "input": input}), + ) + .await?; + let turn_id = result + .get("turnId") + .and_then(Value::as_str) + .filter(|id| *id == expected_turn_id) + .ok_or_else(|| CodexAppServerError::InvalidResponse { + method: "turn/steer".into(), + detail: "missing or mismatched turnId".into(), + })?; + Ok(StartedTurn { + turn_id: turn_id.into(), + }) + } + + /// `turn/interrupt` (`client.ts:433-439`). A successful reply acknowledges + /// submission only; it is never terminal-turn evidence. pub async fn interrupt_turn( &self, thread_id: &str, @@ -1005,6 +1057,57 @@ pub fn new_channel_transport() -> (Arc, ChannelPeer) { mod tests { use super::*; + #[test] + fn only_native_pre_admission_errors_are_refusals() { + for message in [ + "no active turn to steer", + "no active turn to interrupt", + "expected active turn id `old` but found `new`", + ] { + assert!(CodexAppServerError::Rpc { + method: "turn/steer".into(), + error: RpcError { + code: -32600, + message: message.into(), + data: None + } + } + .is_not_submitted()); + } + for error in [ + CodexAppServerError::Rpc { + method: "turn/start".into(), + error: RpcError { + code: -32603, + message: "internal failure".into(), + data: None, + }, + }, + CodexAppServerError::Timeout { + method: "turn/start".into(), + timeout_ms: 100, + }, + CodexAppServerError::Closed { + method: "turn/steer".into(), + }, + CodexAppServerError::Transport { + method: "turn/interrupt".into(), + message: "lost transport reply".into(), + }, + CodexAppServerError::InvalidResponse { + method: "turn/start".into(), + detail: "missing result".into(), + }, + ] { + assert!(!error.is_not_submitted(), "{error}"); + } + assert!(CodexAppServerError::Timeout { + method: "initialize".into(), + timeout_ms: 100 + } + .is_not_submitted()); + } + #[cfg(feature = "native-policy-qualification")] #[tokio::test] async fn native_requirements_refuse_untrusted_before_work() { diff --git a/crates/freshell-codex/tests/interrupt_rpc.rs b/crates/freshell-codex/tests/interrupt_rpc.rs index bcb60c674..c2096ab7d 100644 --- a/crates/freshell-codex/tests/interrupt_rpc.rs +++ b/crates/freshell-codex/tests/interrupt_rpc.rs @@ -19,6 +19,37 @@ use freshell_codex::{ const THREAD_ID: &str = "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e"; +#[tokio::test] +async fn active_input_uses_expected_id_steer_without_turn_settings_and_returns_the_same_turn() { + let (transport, peer) = new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let driver = tokio::spawn(async move { + client + .steer_turn( + THREAD_ID, + "turn-owned", + vec![json!({"type":"text","text":"follow up"})], + ) + .await + .unwrap() + }); + let (init, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond( + &init, + json!({"userAgent":"fixture","codexHome":"/h","platformFamily":"u","platformOs":"l"}), + ); + peer.expect_notification().await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!( + params, + json!({"threadId":THREAD_ID,"expectedTurnId":"turn-owned","input":[{"type":"text","text":"follow up"}]}) + ); + peer.respond(&id, json!({"turnId":"turn-owned"})); + assert_eq!(driver.await.unwrap().turn_id, "turn-owned"); +} + #[tokio::test] async fn interrupt_turn_rpc_then_interrupted_completion_snapshots_without_chime() { let (transport, peer) = new_channel_transport(); diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index c8df331d5..18d26a4c0 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -87,6 +87,9 @@ mod controls; mod metadata; pub(crate) mod native_history; mod projection; +pub(crate) mod turn_state; +use turn_state::{CodexTurnState, DispatchToken, NativeSettlement, StopDisposition}; +type SharedCodexTurnState = Arc>; /// Unified agent names (Task 2): the ambient `CODEX_HOME` fallback for the /// durability-driven pending bind when the app-server's own initialize @@ -249,6 +252,7 @@ const STATUS_RANG_TURN_IDS_CAP: usize = 8; /// explicit: every lifecycle path must give the reducer, quiet-deadman, compact /// guard, and emission barrier for the same thread to the same task. struct CodexConsumerRuntime { + turn_state: SharedCodexTurnState, thread_id: String, projection: Arc>, active_turn: Arc>>, @@ -302,6 +306,29 @@ struct CodexConsumerRuntime { event_emission_gate: Arc>, } +async fn wait_for_codex_stop_resolution(turns: &SharedCodexTurnState) { + loop { + let changed = turns.lock().expect("codex turn state").changed.clone(); + let notified = changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !turns + .lock() + .expect("codex turn state") + .has_unresolved_stop() + { + return; + } + #[cfg(test)] + turns + .lock() + .expect("codex turn state") + .waiting_for_stop + .notify_one(); + notified.await; + } +} + /// Seed the quiet window from `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS` (positive integer /// milliseconds; missing or unparseable falls back to [`DEFAULT_CODEX_QUIET_WINDOW_MS`]). fn codex_quiet_window_ms_from_env() -> u64 { @@ -479,6 +506,7 @@ struct CodexCreateRecord { /// One live freshcodex session: the app-server client, its owned sidecar, and the /// normalized create-time settings a later `send` re-uses. struct CodexSession { + turn_state: SharedCodexTurnState, projection: Arc>, client: Arc, /// Normalized model (`normalizeFreshcodexModel`), reused verbatim on `send`. @@ -490,21 +518,11 @@ struct CodexSession { sandbox: Option, /// Raw create permissionMode (e.g. `never`) → the turn's `approvalPolicy`. permission_mode: Option, - /// Legacy `activeTurnByThread.get(sessionId)` mirror (adapter.ts:295,980,1009,1027): set - /// immediately after `turn/start` resolves (`handle_send`), cleared on a successful - /// `handle_interrupt` and whenever the notification consumer observes the turn/thread end - /// (`reduce_notification`). Lets `freshAgent.interrupt` target the in-flight turn. + /// Compatibility target for native/compact lifecycle consumers. Owed work + /// and Stop ownership live in `turn_state`; an interrupt ACK never clears it. active_turn: Arc>>, - /// The per-session USER-interrupt marker (opencode's `turn_aborted` - /// precedent): `handle_interrupt` arms it BEFORE issuing `turn/interrupt` - /// (and disarms it again when the RPC fails — the interrupt never landed), - /// the consumer's subscription consumes it at the `interrupted` - /// completion, and the consumer's `turn/started` fold CLEARS it (the - /// full opencode dispatch-clear discipline — a stale armed marker never - /// crosses a turn boundary). ONE `Arc` shared by this session record (the - /// interrupt lane) and the consumer's [`CodexSubscription`] (the guard), - /// so a user interrupt is silent while an automation/rollback-forced - /// `interrupted` still rings the unified attention edge. + /// Compatibility attention marker. Arm before native Stop; disarm only on + /// a proven pre-submission refusal or the matching terminal/new-turn fold. user_interrupt_pending: Arc, /// Delta-r1 F2: the compact-window busy truth. `thread/compact/start`'s answer /// carries no turn id, and the REAL 0.147.0 sequence returns the RPC BEFORE @@ -591,24 +609,11 @@ struct CodexSession { /// into the consumer ([`reduce_notification`]), the exit watcher /// ([`spawn_exit_watcher`]), and the snapshot path ([`FreshCodexState::get_snapshot`]). quiet_deadman: Arc>, - /// The lane's turn-in-flight AUTHORITY (the round-2 review finding): armed at - /// `start_turn` DISPATCH -- BEFORE awaiting the response -- so the - /// independently-running exit watcher and quiet deadman classify the turn as - /// in flight even while the response is still outstanding (a post-response - /// arm would miss a crash in that window). Mirrored at every `active_turn` - /// mutation, so it is a superset of `active_turn` between dispatch and the - /// crash/kill retirement points: SET alongside the consumer's - /// `turn/started` adoption and the snapshot's mid-flight seed, CLEARED at - /// every turn-end observation (the thread leaving running/starting, the - /// active turn's completion, `thread_closed`, a successful user - /// interrupt) and on a failed dispatch the sidecar survived (the - /// kill/crash arms retire the latch WITHOUT clearing `active_turn`, so a - /// post-kill/post-crash `active_turn` can outlive the latch; an - /// exit-induced dispatch failure instead leaves the latch armed for the - /// crash arm's swap-and-mint — a bare Err-arm retirement would land - /// before the crash arm and swallow the crash edge). The crash self-heal - /// arm reads and retires it (the crash IS the turn end it observes); a - /// requested kill retires it silently (the user initiated that turn end). + /// Compatibility in-flight/attention latch for existing crash, compact and + /// quiet-deadman consumers. Dispatch and matching native terminal evidence + /// mirror the identity-owned `turn_state`; interrupt acknowledgement does + /// not retire work. Existing lifecycle-loss attention dedup remains separate + /// from proving terminal delivery/owned-process settlement. turn_in_flight: Arc, /// The-usual SDD focused-r1 FR1-1: the per-session RETIRED-TURN slot. /// A terminal thread status retires the active-turn TRACKER while the @@ -2815,6 +2820,7 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( mint_ownership_id(), thread_id.to_string(), @@ -2823,6 +2829,7 @@ impl FreshCodexState { let consumer = self.spawn_consumer_after( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.clone(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -2847,7 +2854,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior tag (the pid was // captured above for the coordinator commit). let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, thread_id.clone(), @@ -2861,11 +2868,13 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), ); self.sessions.lock().await.insert( thread_id.clone(), CodexSession { + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -3358,6 +3367,11 @@ impl FreshCodexState { Err(EnsureAliveError::Reserved) => { // Task 13 (D8): another create/attach holds this sessionRef -- the // retryable non-lost error channel; the client re-drive converges. + self.send_error( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + ); self.emit_fresh_agent_error( &session_id, "SESSION_RESERVED", @@ -3401,6 +3415,7 @@ impl FreshCodexState { guard.get(&session_id).map(|s| { ( s.client.clone(), + s.turn_state.clone(), s.active_turn.clone(), s.turn_in_flight.clone(), // FR1-1: the install-site void of the retired-turn slot. @@ -3418,6 +3433,7 @@ impl FreshCodexState { }; let Some(( client, + turn_state, active_turn, turn_in_flight, retired_turn_id, @@ -3442,36 +3458,7 @@ impl FreshCodexState { // this lock, the send simply waits, then proceeds and destroys redo // ("send waits, rollback wins, then destroys"). let _turn = turn_lock.lock().await; - - // The checkpoint workspace barrier can park a send outside this - // handler while ownership changes. Recheck the captured wire fence - // after the turn lock so a delayed send cannot reach a replacement - // owner of the same thread id. Keep the coordinator guard through the - // turn/start request; ordinary legacy sends without a fence retain - // their existing behavior. - let ownership_guard = if msg.checkpoint_intent.is_some() || send_fence.is_some() { - let owner_key = self.resolve_ownership_key(&session_id); - match crate::ownership_lane::arm_reclaimless_op_guard( - &self.ownership, - PROVIDER, - &owner_key, - &format!("send-{}", uuid::Uuid::new_v4()), - send_fence, - "freshcodex/send", - ) { - crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { - tracing::warn!(target: "freshell_freshagent::codex", - session_id = %session_id, request_id = ?request_id, - "fresh_agent_send_refused: ownership guard refused"); - self.send_error(&request_id, "SESSION_RESERVED", &message); - return; - } - } - } else { - None - }; + wait_for_codex_stop_resolution(&turn_state).await; // Read defaults after acquiring the turn lock: a queued send inherits the // last accepted settings, while this message's explicit choices win. @@ -3547,74 +3534,126 @@ impl FreshCodexState { approval_policy: permission_mode.as_deref().map(|p| json!(p)), }; - // The app-server can write a terminal notification immediately after its - // turn/start response. Hold the session-local emission gate from before that - // RPC through the accepted broadcast below, so the client first receives the - // request-correlated submittedTurnId and then the completion-derived frames. - let _event_emission = event_emission_gate.lock().await; - - // The turn-in-flight latch + quiet window arm at DISPATCH time -- BEFORE - // awaiting the response: the independently-running exit watcher and the - // quiet-deadman waiter must classify this turn as in flight even while - // the response is still outstanding (a crash in that window ends a real - // dispatched turn; a post-response arm would classify it idle and miss - // the crash edge, or arm the deadman after the sidecar is already - // gone). The response-failure arm below disarms both ONLY when the - // sidecar is not known-dead. + // The emission barrier keeps acceptance before terminal publication. + // Recheck Stop while atomically recording dispatch ownership: a Stop + // arriving after the first wait must either block issuance or latch on + // this exact new dispatch. No consumer lock is held across the wait. + let (_event_emission, expected_turn_id, dispatch) = loop { + let emission = event_emission_gate.lock().await; + let active = active_turn.lock().expect("active_turn mutex").clone(); + let selected = { + let mut turns = turn_state.lock().expect("codex turn state"); + if let Some(active) = active.as_deref() { + turns.observe_started(active); + } + if turns.has_unresolved_stop() { + None + } else { + if turns.has_owned_exit() { + self.send_error(&request_id, "SESSION_RESERVED", "The owned Codex runtime exited while this input waited; refresh before sending again"); + return; + } + let expected = turns.active_turn_id().map(str::to_owned); + if expected.is_none() && turns.has_unknown_delivery() { + self.send_error(&request_id, "CODEX_DELIVERY_AMBIGUOUS", "Earlier input has unresolved native delivery; recover or inspect the conversation before sending again"); + return; + } + Some(( + expected, + turns.begin_dispatch(request_id.as_deref().unwrap_or_default()), + )) + } + }; + if let Some((expected, dispatch)) = selected { + break (emission, expected, dispatch); + } + drop(emission); + wait_for_codex_stop_resolution(&turn_state).await; + }; + // The checkpoint workspace barrier can park a send outside this + // handler while ownership changes. Recheck the captured wire fence + // after the turn lock so a delayed send cannot reach a replacement + // owner of the same thread id. Keep the coordinator guard through the + // turn/start request; ordinary legacy sends without a fence retain + // their existing behavior. + let ownership_guard = if msg.checkpoint_intent.is_some() || send_fence.is_some() { + let owner_key = self.resolve_ownership_key(&session_id); + match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + &owner_key, + &format!("send-{}", uuid::Uuid::new_v4()), + send_fence, + "freshcodex/send", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { + tracing::warn!(target: "freshell_freshagent::codex", + session_id = %session_id, request_id = ?request_id, + "fresh_agent_send_refused: ownership guard refused"); + turn_state + .lock() + .expect("codex turn state") + .reject(dispatch); + self.send_error(&request_id, "SESSION_RESERVED", &message); + return; + } + } + } else { + None + }; + turn_in_flight.store(true, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); - - let submitted_turn_id = match client.start_turn(params).await { + let result = if let Some(expected) = expected_turn_id.as_deref() { + client.steer_turn(&session_id, expected, params.input).await + } else { + client.start_turn(params).await + }; + let (submitted_turn_id, latched_stop) = match result { Ok(started) => { - // adapter.ts:980 -- track the active turn immediately (before any - // turn/started notification), so a fast-follow interrupt has a target. - *active_turn.lock().expect("active_turn mutex") = Some(started.turn_id.clone()); - // FR1-1 (stale-slot protection): the new turn's install VOIDS - // the retired-turn slot a PREVIOUS turn's idle retirement may - // have left — a late completion for that older turn must never - // retire THIS turn's crash latch. The emission gate (held above - // across the RPC) keeps the consumer from folding any - // completion in this window, so the two writes are serialized - // against the reducer. The slot guard is sequenced with (never - // nested inside) the tracker guard. + let mut turns = turn_state.lock().expect("codex turn state"); + let latched_stop = turns.accept(dispatch, &started.turn_id); + let active = turns.active_turn_id().map(str::to_owned); + let owed = turns.has_owed_work(); + drop(turns); + *active_turn.lock().expect("active_turn mutex") = active; + turn_in_flight.store(owed, Ordering::SeqCst); *retired_turn_id.lock().expect("retired_turn_id mutex") = None; - // E3R1 (durable already-rang set): the install deliberately - // does NOT touch the set. The pre-fix install VOID reopened - // the gate for a PRIOR turn's late completion after a - // lifecycle-loss mint — a double bell. A stale set entry is - // safe: the gate matches turn ids, so it can never gate THIS - // new turn's own completion. - // A turn is now in flight: refresh the quiet window off the - // response (the sidecar has provably accepted the turn; the - // deadline resets from this lane-visible proof). note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); - started.turn_id + (started.turn_id, latched_stop) } Err(err) => { - // The dispatch/response failed. Retire the latch + disarm the - // dispatch-time window ONLY when the sidecar is NOT known-dead: - // an exit-induced failure (the production crash shape — the - // sidecar dies mid-dispatch and the transport's read loop fails - // the pending request with a connection-closed error) must - // LEAVE the latch armed so the exit watcher's crash arm owns - // the turn-end classification. This arm has no awaits, so a - // bare retirement would land before the crash arm's swap - // (which sits behind disarm + the `exited` broadcast + an - // awaited kill-and-confirm) and swallow the crash edge with - // near-certainty. The crash arm is guaranteed to run for a - // dead child; if it already ran, its swap minted the edge and - // leaving the now-false latch untouched is harmless. A - // requested kill also retires the latch silently and - // unconditionally, so a close-induced failure can never - // out-live it. Ordinary rejections (a live sidecar's RPC - // error, a timeout) still retire promptly. - let exit_induced = exited.load(Ordering::SeqCst) - || matches!(err, CodexAppServerError::Closed { .. }); - if !exit_induced { - turn_in_flight.store(false, Ordering::SeqCst); + let mut refused = err.is_not_submitted(); + let owed = { + let mut turns = turn_state.lock().expect("codex turn state"); + if refused { + refused = turns.reject(dispatch); + } else { + turns.mark_ambiguous(dispatch); + } + turns.has_owed_work() + }; + // An owned-exit watcher retains crash-attention authority. + if !exited.load(Ordering::SeqCst) { + turn_in_flight.store(owed, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); } - self.send_error(&request_id, "CODEX_TURN_START_FAILED", &err.to_string()); + let code = if refused { + "CODEX_TURN_START_FAILED" + } else { + "CODEX_DELIVERY_AMBIGUOUS" + }; + let receipt = turn_state + .lock() + .expect("codex turn state") + .facts() + .iter() + .find(|fact| fact.token == dispatch) + .map(|fact| fact.request_id.clone()); + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?receipt, dispatch=?dispatch, expected_turn_id=?expected_turn_id, refused, error=%err, "freshagent.codex.send_failed"); + self.send_error(&request_id, code, &err.to_string()); return; } }; @@ -3764,6 +3803,27 @@ impl FreshCodexState { self.broadcast(&accepted); } + if expected_turn_id.is_some() && msg.settings.is_some() { + broadcast_codex_event( + &session_id, + json!({"type":"freshAgent.notice", "level":"info", "code":"CODEX_SETTINGS_STAGED", "message":"Settings are saved for the next turn; active input keeps this turn's settings."}), + &self.broadcast_tx, + ); + } + // Release both serialization locks before Stop RPC work. The consumer + // must remain free to observe terminal evidence while Stop is pending. + drop(_event_emission); + drop(_turn); + if let StopDisposition::Interrupt { dispatch, turn_id } = latched_stop { + self.issue_codex_interrupt( + &session_id, + client.clone(), + turn_state.clone(), + dispatch, + turn_id, + ) + .await; + } if let Some((request_id, logical_attempt_id)) = checkpoint_identity_request { drop( self.spawn_checkpoint_identity_probe(CodexCheckpointIdentityProbe { @@ -4032,81 +4092,109 @@ impl FreshCodexState { // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── - /// Handle a `freshAgent.interrupt` for codex: issue `turn/interrupt` for the tracked - /// active turn (`activeTurnByThread.get(sessionId)`, adapter.ts:1009) and clear it on - /// success (adapter.ts:1027). There is NO wire ack on success — the app-server's - /// resulting `turn/completed{interrupted}` notification flows through the existing - /// STATUS-GUARDED consumer (`reduce_notification` -> `CodexSubscription::on_turn_completed`), - /// which emits the idle `freshAgent.session.snapshot` with NO `freshAgent.turn.complete` - /// edge: the interrupt is USER-initiated, so this lane arms the per-session - /// `user_interrupt_pending` marker BEFORE the RPC (opencode's `turn_aborted` - /// precedent) and disarms it again when the RPC fails (the interrupt never - /// landed — a later NON-user `interrupted` must still ring the unified edge). - /// A completion the marker did not cover (automation/rollback-forced) rings. The - /// marker is bounded to the CURRENT turn: the consumer clears it at the next - /// `turn/started` on the thread (the full opencode dispatch-clear discipline), - /// so an armed marker whose `interrupted` completion never arrived cannot - /// cross a turn boundary and silence a later turn's non-user `interrupted`. - /// Mirrors `ws-handler.ts:3503-3516` (fire-and-forget; `INTERNAL_ERROR` on failure). + /// Stop the exact owned dispatch/native turn, or latch Stop until its + /// native ID is known. An RPC acknowledgement resolves issuance only; + /// matching native terminal evidence settles work. An ambiguous response + /// retains both the intent and the new-start gate until proven recovery. pub async fn handle_interrupt(&self, msg: FreshAgentInterrupt) { - let session_id = msg.session_id.clone(); - - let looked_up = { - let guard = self.sessions.lock().await; - guard.get(&session_id).map(|s| { - ( - s.client.clone(), - s.active_turn.clone(), - s.turn_in_flight.clone(), - s.user_interrupt_pending.clone(), - // FR1-1: the retire helper clears the retired-turn slot - // with the tracker + latch (a user-initiated interrupt - // leaves no idle→completed gap to bridge). (E3R1: the - // durable already-rang set is deliberately NOT cleared - // — a stale entry can only gate its own turn's late - // duplicate.) - s.retired_turn_id.clone(), - ) - }) - }; - let Some((client, active_turn, turn_in_flight, user_interrupt_pending, retired_turn_id)) = - looked_up - else { - self.send_error(&None, "SESSION_NOT_FOUND", "codex session not found"); - return; - }; - - let turn_id = active_turn.lock().expect("active_turn mutex").clone(); - let Some(turn_id) = turn_id else { - // adapter.ts:1017-1019 — no tracked active turn to target. + let session_id = msg.session_id; + let handles = self.sessions.lock().await.get(&session_id).map(|s| { + ( + s.client.clone(), + s.turn_state.clone(), + s.active_turn.clone(), + s.user_interrupt_pending.clone(), + ) + }); + let Some((client, turns, active, marker)) = handles else { self.send_error( - &None, - "CODEX_INTERRUPT_FAILED", - &format!("No active Codex turn is tracked for {session_id}."), + &msg.request_id, + "SESSION_NOT_FOUND", + "codex session not found", ); return; }; + let disposition = { + let active = active.lock().expect("active_turn mutex").clone(); + let mut state = turns.lock().expect("codex turn state"); + if let Some(id) = active.as_deref() { + state.observe_started(id); + } + state.request_stop() + }; + match disposition { + StopDisposition::Interrupt { dispatch, turn_id } => { + marker.store(true, Ordering::SeqCst); + self.issue_codex_interrupt(&session_id, client, turns, dispatch, turn_id) + .await; + } + StopDisposition::Latched => { + tracing::info!(provider=PROVIDER, session_id=%session_id, "freshagent.codex.stop_latched"); + } + StopDisposition::None => { + if !turns.lock().expect("codex turn state").has_owed_work() { + self.send_error( + &msg.request_id, + "CODEX_INTERRUPT_FAILED", + &format!("No active Codex turn is tracked for {session_id}."), + ); + } + } + } + } - // Arm the USER-interrupt marker BEFORE the RPC: the resulting - // `turn/completed{interrupted}` consumes it in the guard and stays - // silent. Set-first closes the race with a completion the app-server - // pushes immediately after processing the interrupt. - user_interrupt_pending.store(true, Ordering::SeqCst); - match client.interrupt_turn(&session_id, &turn_id).await { + async fn issue_codex_interrupt( + &self, + session_id: &str, + client: Arc, + turns: SharedCodexTurnState, + dispatch: DispatchToken, + turn_id: String, + ) { + // Native validates the ID then queues an unqualified core interrupt. + // Keep new-start issuance closed until THIS operation is resolved, even + // if natural completion wins while its reply is held or lost. + let marker = self + .sessions + .lock() + .await + .get(session_id) + .filter(|s| Arc::ptr_eq(&s.client, &client)) + .map(|s| s.user_interrupt_pending.clone()); + if let Some(marker) = &marker { + marker.store(true, Ordering::SeqCst); + } + match client.interrupt_turn(session_id, &turn_id).await { Ok(()) => { - // adapter.ts:1027 — the turn is over from this call's perspective; the - // resulting turn/completed notification also clears it (redundant, harmless). - // The user-initiated interrupt ends the turn SILENTLY: retire the - // in-flight latch too, so a later idle crash cannot ring for a turn - // that already ended at the user's own hand. - retire_observed_turn_end(&active_turn, &turn_in_flight, &retired_turn_id); + turns + .lock() + .expect("codex turn state") + .interrupt_ack(dispatch, &turn_id); + tracing::info!(provider=PROVIDER, session_id, native_turn_id=%turn_id, dispatch=?dispatch, "freshagent.codex.interrupt_acknowledged"); } Err(err) => { - // The interrupt never landed — disarm so a later NON-user - // `interrupted` completion is not silently swallowed (the - // opencode abort-failure discipline). - user_interrupt_pending.store(false, Ordering::SeqCst); - self.send_error(&None, "CODEX_INTERRUPT_FAILED", &err.to_string()); + let refused = err.is_not_submitted(); + let disarmed = refused + && turns + .lock() + .expect("codex turn state") + .interrupt_refused(dispatch, &turn_id); + if disarmed { + // Only this operation's proven refusal can retract the marker. + if let Some(marker) = marker { + marker.store(false, Ordering::SeqCst); + } + } + tracing::warn!(provider=PROVIDER, session_id, native_turn_id=%turn_id, dispatch=?dispatch, refused, error=%err, "freshagent.codex.interrupt_failed"); + self.emit_fresh_agent_error( + session_id, + if refused { + "CODEX_INTERRUPT_FAILED" + } else { + "CODEX_INTERRUPT_AMBIGUOUS" + }, + &err.to_string(), + ); } } } @@ -6839,6 +6927,7 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( mint_ownership_id(), session_id.to_string(), @@ -6847,6 +6936,7 @@ impl FreshCodexState { let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: session_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -6865,7 +6955,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, session_id.to_string(), @@ -6879,6 +6969,7 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), ); // `HashMap::insert` on an existing key overwrites in place, dropping the old (dead @@ -6892,6 +6983,7 @@ impl FreshCodexState { guard.insert( session_id.to_string(), CodexSession { + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -7155,6 +7247,7 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let exited = Arc::new(AtomicBool::new(false)); let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( mint_ownership_id(), @@ -7163,6 +7256,7 @@ impl FreshCodexState { let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: new_thread_id.clone(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -7181,7 +7275,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, new_thread_id.clone(), @@ -7195,6 +7289,7 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), ); { @@ -7203,6 +7298,7 @@ impl FreshCodexState { guard.insert( new_thread_id.clone(), CodexSession { + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -7591,6 +7687,7 @@ impl FreshCodexState { created_gate: Option>, ) -> tokio::task::JoinHandle<()> { let CodexConsumerRuntime { + turn_state, thread_id, active_turn, user_interrupt_pending, @@ -7604,6 +7701,13 @@ impl FreshCodexState { event_emission_gate, projection, } = runtime; + let seeded = active_turn.lock().expect("active_turn mutex").clone(); + if let Some(id) = seeded.as_deref() { + turn_state + .lock() + .expect("codex turn state") + .observe_started(id); + } let broadcast_tx = self.broadcast_tx.clone(); // The deadman feed needs the state handle (window config + waiter spawn); a // clone is all-Arc, cheap, and adds no lifecycle coupling (the state is the @@ -7692,6 +7796,7 @@ impl FreshCodexState { let events = reduce_notification( &mut subscription, notification, + &turn_state, &active_turn, &turn_in_flight, &quiet_deadman, @@ -7776,6 +7881,7 @@ impl FreshCodexState { let events = reduce_notification( &mut subscription, notification, + &turn_state, &active_turn, &turn_in_flight, &quiet_deadman, @@ -7787,7 +7893,18 @@ impl FreshCodexState { &last_turn_complete_at, &broadcast_tx, ); - for event in events { + for mut event in events { + if turn_state + .lock() + .expect("codex turn state") + .has_stopping_work() + { + if let CodexAdapterEvent::StatusSnapshot { status, .. } = &mut event { + if *status == CodexStatus::Idle { + *status = CodexStatus::Running; + } + } + } // DIAG-01: the positive turn-complete chime only -- session_id // alone, never the turn's text/response content. if let CodexAdapterEvent::TurnComplete { session_id, .. } = &event { @@ -7984,13 +8101,13 @@ impl FreshCodexState { // this port previously had no fallback at all, so ANY such rejection became an // unconditional 500. Retry once with `includeTurns:false`, matching the reference // exactly (still a valid, if turn-less, snapshot). - let projection = self + let (projection, turn_state) = self .sessions .lock() .await .get(thread_id) .filter(|session| Arc::ptr_eq(&session.client, &client)) - .map(|session| session.projection.clone()) + .map(|session| (session.projection.clone(), session.turn_state.clone())) .ok_or(CodexSnapshotError::NotFound)?; let mut selected = None; for _ in 0..3 { @@ -8062,6 +8179,27 @@ impl FreshCodexState { generation: self.ownership_snapshot(PROVIDER, thread_id).generation, })?; let raw = projected.raw; + { + let mut turns = turn_state.lock().expect("codex turn state"); + for turn in raw["thread"]["turns"].as_array().into_iter().flatten() { + let Some(id) = turn["id"].as_str() else { + continue; + }; + let terminal = match turn["status"].as_str() { + Some("completed") => Some(NativeSettlement::Completed), + Some("interrupted") => Some(NativeSettlement::Interrupted), + Some("failed") => Some(NativeSettlement::Failed), + Some("inProgress") => { + turns.observe_started(id); + None + } + _ => None, + }; + if let Some(terminal) = terminal { + turns.settle_native(id, terminal); + } + } + } // U-1 repair (wedged-sidecar deadman): a thread restored MID-TURN has no // adapter-observed `turn/started` (this process never saw one), so // `active_turn` is empty and the quiet deadman would never arm. When the FRESH @@ -8152,6 +8290,13 @@ impl FreshCodexState { .lock() .expect("codex projection lock") .overlay(&mut snapshot, projected.metadata); + if snapshot["status"] == "idle" + && turn_state.lock().expect("codex turn state").has_owed_work() + { + snapshot["status"] = json!("running"); + snapshot["capabilities"]["send"] = json!(false); + snapshot["capabilities"]["interrupt"] = json!(true); + } self.overlay_controls(thread_id, &mut snapshot).await; if let Some(session) = self.sessions.lock().await.get(thread_id) { let mut settings = Map::new(); @@ -9045,6 +9190,7 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let exited = Arc::new(AtomicBool::new(false)); let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( mint_ownership_id(), @@ -9053,6 +9199,7 @@ impl FreshCodexState { let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -9071,7 +9218,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, thread_id.to_string(), @@ -9085,10 +9232,12 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + turn_state: turn_state.clone(), client, model, effort, @@ -9149,6 +9298,7 @@ impl FreshCodexState { cmd.kill_on_drop(true); let child = cmd.spawn().expect("spawn sleep fixture"); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let (kill_tx, kill_rx) = oneshot::channel(); let exited = Arc::new(AtomicBool::new(false)); @@ -9165,7 +9315,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = format!("codex-sidecar-test-snapshot-router-{thread_id}"); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, format!("codex-sidecar-test-snapshot-router-{thread_id}"), thread_id.to_string(), @@ -9179,10 +9329,12 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -10096,6 +10248,24 @@ fn watcher_stop_not_confirmed() -> crate::session_handoff::StopResult { } } +fn with_codex_turn_exit_confirmation( + confirmation: std::pin::Pin + Send + 'static>>, + turns: Option, +) -> std::pin::Pin + Send + 'static>> { + Box::pin(async move { + let confirmed = confirmation.await; + if confirmed { + if let Some(turns) = turns { + turns + .lock() + .expect("codex turn state") + .settle_native("", NativeSettlement::OwnedExit); + } + } + confirmed + }) +} + /// Continue an incomplete Codex tree reap after the bounded watcher window. The first /// watcher pass has already stopped the direct child; this continuation keeps probing the /// recorded incarnation and tagged descendants until the complete writer tree is confirmed @@ -10238,8 +10408,60 @@ fn detach_codex_confirmation( Box::pin(async move { settled_rx.await.unwrap_or(false) }) } +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub(crate) fn spawn_exit_watcher( + child: tokio::process::Child, + ownership_id: String, + thread_id: String, + broadcast_tx: Arc>, + kill_rx: oneshot::Receiver<()>, + exited: Arc, + leases: Arc, + quiet_deadman: Arc>, + // The session's turn-in-flight latch (the dispatch-time authority) + its + // shared turn-complete clock: the crash self-heal arm mints the unified + // attention edge for a turn in flight at crash time on the session's ONE + // clock; a requested kill retires the latch silently. + turn_in_flight: Arc, + // FR1-1: the session's retired-turn slot — both terminal arms (the + // requested kill, the crash self-heal) end the turn, so the slot's + // idle→completed bridge dies with the latch: a respawned incarnation's + // fresh notifications must never consult a pre-teardown slot. + // (E3R1: the durable already-rang set is deliberately NOT threaded + // into the watcher — teardown voids would reopen the gate for a + // buffered late completion folding after the exit, and a respawned + // incarnation never consults the pre-teardown set anyway: every + // respawn site constructs FRESH Arcs.) + retired_turn_id: Arc>>, + last_turn_complete_at: Arc>>, + condemned_priors: Arc< + StdMutex>, + >, + // kata b8ke Task 3: release the retained coordinator stamp on exit with + // its fenced claim (None when the lane is unwired — pre-existing tests). + ownership: Option, +) -> tokio::task::JoinHandle { + spawn_exit_watcher_tracking_turns( + child, + ownership_id, + thread_id, + broadcast_tx, + kill_rx, + exited, + leases, + quiet_deadman, + turn_in_flight, + retired_turn_id, + last_turn_complete_at, + condemned_priors, + ownership, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_exit_watcher_tracking_turns( mut child: tokio::process::Child, ownership_id: String, thread_id: String, @@ -10270,6 +10492,7 @@ pub(crate) fn spawn_exit_watcher( // kata b8ke Task 3: release the retained coordinator stamp on exit with // its fenced claim (None when the lane is unwired — pre-existing tests). ownership: Option, + turn_state: Option, ) -> tokio::task::JoinHandle { // wfah: the thread id is fixed by the time the watcher is constructed at // every successful spawn site; enrich the durable record once, here. @@ -10329,6 +10552,7 @@ pub(crate) fn spawn_exit_watcher( false }; if confirmed { + if let Some(turns) = &turn_state { turns.lock().expect("codex turn state").settle_native("", NativeSettlement::OwnedExit); } cleanup_confirmed_codex_teardown( &leases, ownership.as_ref(), @@ -10376,6 +10600,7 @@ pub(crate) fn spawn_exit_watcher( ) }) .unwrap_or_else(|| Box::pin(async { false })); + let confirmation = with_codex_turn_exit_confirmation(confirmation, turn_state.clone()); crate::session_handoff::StopResult::NotConfirmed { confirmation: wrap_codex_confirmation_with_cleanup( confirmation, @@ -10409,6 +10634,7 @@ pub(crate) fn spawn_exit_watcher( false }; let stop_result = if confirmed { + if let Some(turns) = &turn_state { turns.lock().expect("codex turn state").settle_native("", NativeSettlement::OwnedExit); } cleanup_confirmed_codex_teardown( &leases, ownership.as_ref(), @@ -10460,6 +10686,7 @@ pub(crate) fn spawn_exit_watcher( ) }) .unwrap_or_else(|| Box::pin(async { false })); + let confirmation = with_codex_turn_exit_confirmation(confirmation, turn_state.clone()); let confirmation = wrap_codex_confirmation_with_cleanup( confirmation, ownership_id.clone(), @@ -10744,6 +10971,7 @@ fn disarm_codex_quiet( fn reduce_notification( subscription: &mut CodexSubscription, notification: CodexNotification, + turn_state: &SharedCodexTurnState, active_turn: &Arc>>, turn_in_flight: &Arc, quiet_deadman: &Arc>, @@ -10880,6 +11108,7 @@ fn reduce_notification( // `active_turn` guard, and dropped before the // `quiet_deadman` lock below (the file-wide order is // `quiet_deadman` outer / `active_turn`-tier inner). + turn_state.lock().expect("codex turn state").observe_idle(); let retired_tracker_id = active_turn.lock().expect("active_turn mutex").take(); if let Some(id) = retired_tracker_id { @@ -10911,6 +11140,19 @@ fn reduce_notification( // the retired-turn latch each have their narrower FIFO-safe // ownership exceptions below. if event.thread_id == subscription.session_id() { + let terminal = match freshell_codex::turn_status(&event.params).as_deref() { + Some("completed") => Some(NativeSettlement::Completed), + Some("interrupted") => Some(NativeSettlement::Interrupted), + Some("failed") => Some(NativeSettlement::Failed), + Some("inProgress") => return subscription.on_turn_completed(&event, now_ms()), + _ => None, + }; + if let (Some(turn_id), Some(terminal)) = (event.turn_id.as_deref(), terminal) { + turn_state + .lock() + .expect("codex turn state") + .settle_native(turn_id, terminal); + } // Scoped: the `active_turn` guard must be dropped BEFORE the // quiet-deadman disarm below (the file-wide lock order is // `quiet_deadman` outer / `active_turn` inner — never lock @@ -11158,7 +11400,43 @@ fn reduce_notification( // `interrupted` (e.g. the app-server completed the turn // as `completed`) must never cross the turn boundary and // silence a later NON-user `interrupted` (a missed bell). - subscription.clear_user_interrupt(); + let disposition = turn_state + .lock() + .expect("codex turn state") + .observe_started(turn_id); + let user_stopped = turn_state + .lock() + .expect("codex turn state") + .user_stop_turn_id() + == Some(turn_id.as_str()); + if !user_stopped { + subscription.clear_user_interrupt(); + } + if let StopDisposition::Interrupt { dispatch, turn_id } = disposition { + let state = state.clone(); + let turns = turn_state.clone(); + let session_id = event.thread_id.clone(); + tokio::spawn(async move { + let client = state + .sessions + .lock() + .await + .get(&session_id) + .filter(|session| Arc::ptr_eq(&session.turn_state, &turns)) + .map(|session| session.client.clone()); + if let Some(client) = client { + state + .issue_codex_interrupt( + &session_id, + client, + turns, + dispatch, + turn_id, + ) + .await; + } + }); + } // `handle_send` records the provider-returned turn id before its // matching `turn/started` notification necessarily reaches this // consumer. Treat that direct record as authoritative over a @@ -12277,6 +12555,7 @@ pub(crate) mod tests { ownership_id: &str, ) -> tokio::sync::broadcast::Receiver { // no-op consumer: these tests drive the reducer/RPC surfaces directly. + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let (kill_tx, kill_rx) = oneshot::channel(); let exited = Arc::new(AtomicBool::new(false)); @@ -12295,7 +12574,7 @@ pub(crate) mod tests { let last_turn_complete_at: Arc>> = Arc::new(StdMutex::new(None)); let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.to_string(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id.to_string(), thread_id.to_string(), @@ -12309,10 +12588,12 @@ pub(crate) mod tests { last_turn_complete_at.clone(), Arc::clone(&state.condemned_priors), None, + Some(turn_state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -12388,6 +12669,7 @@ pub(crate) mod tests { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( mint_ownership_id(), thread_id.to_string(), @@ -12395,6 +12677,7 @@ pub(crate) mod tests { let consumer = state.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -12413,7 +12696,7 @@ pub(crate) mod tests { let exited = Arc::new(AtomicBool::new(false)); let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.to_string(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id.to_string(), thread_id.to_string(), @@ -12427,10 +12710,12 @@ pub(crate) mod tests { last_turn_complete_at.clone(), Arc::clone(&state.condemned_priors), None, + Some(turn_state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -13888,7 +14173,7 @@ pub(crate) mod tests { } #[tokio::test] - async fn handle_interrupt_issues_rpc_for_tracked_turn_and_clears_it() { + async fn handle_interrupt_issues_rpc_for_tracked_turn_and_retains_it_until_terminal_evidence() { let (transport, peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); let client = Arc::new(client); @@ -13937,9 +14222,9 @@ pub(crate) mod tests { driver.await.expect("handle_interrupt task"); assert_eq!( - *active_turn.lock().unwrap(), - None, - "active turn cleared on a successful interrupt (adapter.ts:1027)" + active_turn.lock().unwrap().as_deref(), + Some("turn-1"), + "successful interrupt acknowledgement cannot settle a native turn" ); } @@ -14441,12 +14726,14 @@ pub(crate) mod tests { // A session whose watcher never resolves (the stalled teardown) and // whose kill_tx is absent — the teardown block parks on the watcher // join forever. + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let watcher = tokio::spawn(std::future::pending::()); let exited = Arc::new(AtomicBool::new(false)); st.sessions.lock().await.insert( "thread-stall".to_string(), CodexSession { + turn_state: turn_state.clone(), client, sidecar_pid: None, sidecar_ownership_id: String::new(), @@ -19229,24 +19516,436 @@ pub(crate) mod tests { ); } + fn task4_stop(session_id: &str) -> FreshAgentInterrupt { + FreshAgentInterrupt { + session_id: session_id.into(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: freshell_protocol::AgentProvider::Codex, + request_id: None, + cwd: None, + } + } + + async fn task4_no_native_frame(peer: &freshell_codex::ChannelPeer) { + let mut next = Box::pin(peer.next_raw_frame()); + let pending = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::future::Future::poll(next.as_mut(), cx).is_pending()) + }) + .await; + assert!( + pending, + "the native issuance gate must have issued no frame" + ); + } + #[tokio::test] - async fn task4_interrupt_ack_does_not_settle_owed_work() { - let (st, _) = state_with_bus(); + async fn task4_held_stop_allows_terminal_consumption_but_blocks_new_start_until_its_reply() { + let (st, mut rx) = state_with_bus(); let (transport, peer) = freshell_codex::new_channel_transport(); let (client, notifs) = CodexAppServerClient::connect(transport); - insert_fake_session_with_real_consumer(&st, "thread-ack", Arc::new(client), - Arc::new(StdMutex::new(Some("old".into()))), notifs, spawn_sleeper(), "task4-ack").await; - let interrupt = { let st = st.clone(); tokio::spawn(async move { - st.handle_interrupt(FreshAgentInterrupt { session_id: "thread-ack".into(), session_type: Some(SESSION_TYPE.into()), provider: Some(PROVIDER.into()), request_id: None, cwd: None }).await; - }) }; - answer_initialize(&peer).await; - let (id, method, params) = peer.expect_request().await; + insert_fake_session_with_real_consumer( + &st, + "thread-held", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-held", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-held")).await }) + }; + answer_initialize(&peer).await; + let (stop_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-held", "turn":{"id":"old","status":"completed"}}), + ); + let terminal = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + assert!( + terminal.matched, + "consumer must progress with Stop RPC held" + ); + let turns = st + .sessions + .lock() + .await + .get("thread-held") + .unwrap() + .turn_state + .clone(); + assert!(!turns.lock().unwrap().has_owed_work()); + assert!(turns.lock().unwrap().has_unresolved_stop()); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let next = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-held", "new input")).await }) + }; + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + task4_no_native_frame(&peer).await; + peer.respond(&stop_id, json!({})); + stop.await.unwrap(); + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&start_id, json!({"turn":{"id":"new"}})); + next.await.unwrap(); + // Adversarial old replies after a supported resolved rollover; this is + // deliberately not described as normal native ACK ordering. + peer.respond(&stop_id, json!({})); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-held", "turn":{"id":"old","status":"interrupted"}}), + ); + peer.emit_notification( + "thread/name/updated", + json!({"threadId":"thread-held","name":"consumer barrier"}), + ); + let stop_new = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-held")).await }) + }; + let (new_stop, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "new"); + assert!(turns.lock().unwrap().has_owed_work()); + peer.respond(&new_stop, json!({})); + stop_new.await.unwrap(); + assert!(turns.lock().unwrap().has_owed_work()); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_stop_before_start_acceptance_interrupts_once_and_preserves_acceptance_order() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-pending", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + spawn_sleeper(), + "task4-pending", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-pending", "first")).await }) + }; + answer_initialize(&peer).await; + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + st.handle_interrupt(task4_stop("thread-pending")).await; + task4_no_native_frame(&peer).await; + peer.respond(&start_id, json!({"turn":{"id":"accepted"}})); + let (stop_id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "accepted"); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-pending","turn":{"id":"accepted","status":"interrupted"}}), + ); + peer.respond(&stop_id, json!({})); + send.await.unwrap(); + let frames = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.session.snapshot" && w["event"]["status"] == "idle" + }) + .await; + assert!(frames.matched); + let accepted = frames + .frames + .iter() + .position(|w| w["type"] == "freshAgent.send.accepted") + .unwrap(); + let terminal = frames + .frames + .iter() + .position(|w| w["event"]["type"] == "freshAgent.session.snapshot") + .unwrap(); + assert!(accepted < terminal); + task4_no_native_frame(&peer).await; + assert!(!st + .sessions + .lock() + .await + .get("thread-pending") + .unwrap() + .turn_state + .lock() + .unwrap() + .has_owed_work()); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_unknown_start_delivery_is_not_replayed_and_native_evidence_can_recover_it() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-unknown", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + spawn_sleeper(), + "task4-unknown", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { + let mut msg = send_msg("thread-unknown", "original"); + msg.request_id = Some("delivery-receipt".into()); + st.handle_send(msg).await; + }) + }; + answer_initialize(&peer).await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond_error(&id, -32603, "internal failure after possible admission"); + send.await.unwrap(); + let failed = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["type"] == "error" && w["requestId"] == "delivery-receipt" + }) + .await; + assert!(failed.matched); + assert!(failed.frames.last().unwrap()["message"] + .as_str() + .unwrap() + .starts_with("CODEX_DELIVERY_AMBIGUOUS:")); + let turns = st + .sessions + .lock() + .await + .get("thread-unknown") + .unwrap() + .turn_state + .clone(); + assert!(turns.lock().unwrap().has_owed_work()); + st.handle_send(send_msg("thread-unknown", "later input")) + .await; + task4_no_native_frame(&peer).await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-unknown","turn":{"id":"recovered","status":"inProgress"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-unknown","turn":{"id":"recovered","status":"completed"}}), + ); + assert!( + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["type"] + == "freshAgent.turn.complete") + .await + .matched + ); + assert!(!turns.lock().unwrap().has_owed_work()); + assert_eq!( + turns.lock().unwrap().facts().front().unwrap().request_id, + "delivery-receipt" + ); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_ambiguous_stop_timeout_keeps_issuance_closed_after_terminal_evidence() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = + CodexAppServerClient::connect_with_timeout(transport, Duration::from_millis(100)); + insert_fake_session_with_real_consumer( + &st, + "thread-timeout", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-timeout", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-timeout")).await }) + }; + answer_initialize(&peer).await; + let (_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + tokio::time::pause(); + tokio::time::advance(Duration::from_millis(200)).await; + stop.await.unwrap(); + tokio::time::resume(); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-timeout","turn":{"id":"old","status":"interrupted"}}), + ); + assert!( + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["type"] + == "freshAgent.session.snapshot" + && w["event"]["status"] == "idle") + .await + .matched + ); + let turns = st + .sessions + .lock() + .await + .get("thread-timeout") + .unwrap() + .turn_state + .clone(); + assert!(!turns.lock().unwrap().has_owed_work()); + assert!(turns.lock().unwrap().has_unresolved_stop()); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-timeout", "later")).await }) + }; + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + task4_no_native_frame(&peer).await; + drop(peer); + st.shutdown().await; + tokio::time::timeout(Duration::from_secs(2), send) + .await + .unwrap() + .unwrap(); + assert!( + !turns.lock().unwrap().has_unresolved_stop(), + "confirmed owned exit resolves the outstanding operation" + ); + } + + #[tokio::test] + async fn task4_ack_and_idle_cannot_make_rest_sendable_before_matching_terminal_evidence() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-rest", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-rest", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-rest")).await }) + }; + answer_initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({})); + stop.await.unwrap(); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"thread-rest","status":{"type":"idle"}}), + ); + let status = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.session.snapshot" + }) + .await; + assert_eq!(status.frames.last().unwrap()["event"]["status"], "running"); + let snapshot = { + let st = st.clone(); + tokio::spawn(async move { st.get_snapshot("thread-rest", None).await.unwrap() }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-rest","status":"idle","turns":[]}}), + ); + let snapshot = snapshot.await.unwrap(); + assert_eq!(snapshot["status"], "running"); + assert_eq!(snapshot["capabilities"]["send"], false); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-rest","turn":{"id":"old","status":"interrupted"}}), + ); + assert!( + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["status"] + == "idle") + .await + .matched + ); + let snapshot = { + let st = st.clone(); + tokio::spawn(async move { st.get_snapshot("thread-rest", None).await.unwrap() }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-rest","status":"idle","turns":[]}}), + ); + assert_eq!(snapshot.await.unwrap()["capabilities"]["send"], true); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_interrupt_ack_does_not_settle_owed_work() { + let (st, _) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-ack", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-ack", + ) + .await; + let interrupt = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_interrupt(FreshAgentInterrupt { + session_id: "thread-ack".into(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: freshell_protocol::AgentProvider::Codex, + request_id: None, + cwd: None, + }) + .await; + }) + }; + answer_initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; assert_eq!(method, "turn/interrupt"); assert_eq!(params["turnId"], "old"); peer.respond(&id, json!({})); interrupt.await.unwrap(); - let owed = st.sessions.lock().await.get("thread-ack").unwrap().turn_in_flight.load(Ordering::SeqCst); - assert!(owed, "interrupt ACK proves submission, never terminal settlement"); + let owed = st + .sessions + .lock() + .await + .get("thread-ack") + .unwrap() + .turn_in_flight + .load(Ordering::SeqCst); + assert!( + owed, + "interrupt ACK proves submission, never terminal settlement" + ); + drop(peer); st.shutdown().await; } @@ -19255,21 +19954,40 @@ pub(crate) mod tests { let (st, _) = state_with_bus(); let (transport, peer) = freshell_codex::new_channel_transport(); let (client, notifs) = CodexAppServerClient::connect(transport); - insert_fake_session_with_real_consumer(&st, "thread-steer", Arc::new(client), - Arc::new(StdMutex::new(Some("old".into()))), notifs, spawn_sleeper(), "task4-steer").await; - let send = { let st = st.clone(); tokio::spawn(async move { st.handle_send(send_msg("thread-steer", "adjust")).await }) }; + insert_fake_session_with_real_consumer( + &st, + "thread-steer", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-steer", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-steer", "adjust")).await }) + }; answer_initialize(&peer).await; let (id, method, params) = peer.expect_request().await; assert_eq!(method, "turn/steer"); assert_eq!(params["expectedTurnId"], "old"); - assert!(params.get("model").is_none(), "steer cannot override turn settings"); - peer.respond_error(&id, -32600, "expected turn does not match active turn"); + assert!( + params.get("model").is_none(), + "steer cannot override turn settings" + ); + peer.respond_error( + &id, + -32600, + "expected active turn id `old` but found `different`", + ); send.await.unwrap(); let guard = st.sessions.lock().await; let session = guard.get("thread-steer").unwrap(); assert!(session.turn_in_flight.load(Ordering::SeqCst)); assert_eq!(session.active_turn.lock().unwrap().as_deref(), Some("old")); drop(guard); + drop(peer); st.shutdown().await; } @@ -28578,12 +29296,20 @@ pub(crate) mod tests { }) .await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + let mut correlated_refusal = false; loop { let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()) .await .expect("a frame within budget") .map(|raw| serde_json::from_str(&raw).expect("json frame")) .expect("the bus stays open"); + if frame["type"] == "error" && frame["requestId"] == "req-r8-stale-send" { + assert!(frame["message"] + .as_str() + .unwrap() + .starts_with("SESSION_RESERVED:")); + correlated_refusal = true; + } if frame["type"] == "freshAgent.event" && frame["event"]["type"] == json!("freshAgent.error") && frame["event"]["code"] == json!("SESSION_RESERVED") @@ -28599,6 +29325,10 @@ pub(crate) mod tests { "the stale send never answered typed: {frame}" ); } + assert!( + correlated_refusal, + "the pane refusal must also release its request-owned composer" + ); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/freshell-freshagent/src/codex/turn_state.rs b/crates/freshell-freshagent/src/codex/turn_state.rs new file mode 100644 index 000000000..a145bb956 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/turn_state.rs @@ -0,0 +1,452 @@ +//! Session-local dispatch ownership. RPC replies describe delivery; only native +//! terminal evidence (or confirmed owned exit) describes the end of work. + +use std::collections::VecDeque; +use std::sync::Arc; +use tokio::sync::Notify; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DispatchToken(u64); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum StopDisposition { + None, + Latched, + Interrupt { + dispatch: DispatchToken, + turn_id: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NativeSettlement { + Completed, + Interrupted, + Failed, + OwnedExit, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Delivery { + Pending, + Accepted, + Ambiguous, + Refused, +} + +#[derive(Clone, Debug)] +pub(crate) struct DispatchFact { + pub token: DispatchToken, + pub request_id: String, + pub turn_id: Option, + pub delivery: Delivery, + pub settlement: Option, +} + +#[derive(Debug)] +struct StopOperation { + dispatch: DispatchToken, + turn_id: Option, + issued: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct CodexTurnState { + next_dispatch: u64, + facts: VecDeque, + active: Option, + stop: Option, + user_stop: Option, + owned_exit: bool, + pub changed: Arc, + #[cfg(test)] + pub waiting_for_stop: Arc, +} + +impl CodexTurnState { + pub fn begin_dispatch(&mut self, request_id: &str) -> DispatchToken { + self.next_dispatch = self + .next_dispatch + .checked_add(1) + .expect("dispatch counter exhausted"); + let token = DispatchToken(self.next_dispatch); + // Keep all unsettled receipts; bound completed history without throwing + // away the current turn's individual same-ID steer deliveries. + while self.facts.len() >= 64 + && self + .facts + .front() + .is_some_and(|f| f.settlement.is_some() || f.delivery == Delivery::Refused) + { + self.facts.pop_front(); + } + self.facts.push_back(DispatchFact { + token, + request_id: request_id.into(), + turn_id: self.active.clone(), + delivery: Delivery::Pending, + settlement: None, + }); + token + } + + pub fn accept(&mut self, token: DispatchToken, turn_id: &str) -> StopDisposition { + let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) else { + return StopDisposition::None; + }; + if fact.delivery == Delivery::Refused { + return StopDisposition::None; + } + fact.delivery = Delivery::Accepted; + fact.turn_id = Some(turn_id.into()); + if fact.settlement.is_none() { + self.active = Some(turn_id.into()); + } + self.issue_latched_stop(token, turn_id) + } + + pub fn reject(&mut self, token: DispatchToken) -> bool { + let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) else { + return false; + }; + // An observed native start is admission evidence even if a contrary + // controller response follows it. + if fact.delivery == Delivery::Accepted { + return false; + } + fact.delivery = Delivery::Refused; + if self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && !s.issued) + { + self.stop = None; + self.changed.notify_waiters(); + } + true + } + + pub fn mark_ambiguous(&mut self, token: DispatchToken) { + if let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) { + if fact.delivery == Delivery::Pending { + fact.delivery = Delivery::Ambiguous; + } + } + } + + pub fn request_stop(&mut self) -> StopDisposition { + if self.stop.is_some() { + return StopDisposition::Latched; + } + if self + .active + .as_ref() + .is_some_and(|id| self.user_stop.as_ref() == Some(id)) + { + return StopDisposition::None; + } + let Some(fact) = self + .facts + .iter() + .rev() + .find(|f| f.settlement.is_none() && f.delivery != Delivery::Refused) + else { + return StopDisposition::None; + }; + let dispatch = fact.token; + let target = self.active.clone().or_else(|| fact.turn_id.clone()); + self.stop = Some(StopOperation { + dispatch, + turn_id: target.clone(), + issued: false, + }); + match target { + Some(id) => self.issue_latched_stop(dispatch, &id), + None => StopDisposition::Latched, + } + } + + fn issue_latched_stop(&mut self, token: DispatchToken, turn_id: &str) -> StopDisposition { + let Some(stop) = self + .stop + .as_mut() + .filter(|s| s.dispatch == token && !s.issued) + else { + return StopDisposition::None; + }; + if !self + .facts + .iter() + .any(|f| f.token == token && f.settlement.is_none() && f.delivery != Delivery::Refused) + { + self.stop = None; + self.changed.notify_waiters(); + return StopDisposition::None; + } + stop.issued = true; + stop.turn_id = Some(turn_id.into()); + self.user_stop = Some(turn_id.into()); + StopDisposition::Interrupt { + dispatch: token, + turn_id: turn_id.into(), + } + } + + pub fn interrupt_ack(&mut self, token: DispatchToken, turn_id: &str) { + if self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && s.turn_id.as_deref() == Some(turn_id)) + { + self.stop = None; + self.changed.notify_waiters(); + } + } + + /// Only a proven pre-submission refusal retracts a user's Stop intent. + pub fn interrupt_refused(&mut self, token: DispatchToken, turn_id: &str) -> bool { + if !self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && s.turn_id.as_deref() == Some(turn_id)) + { + return false; + } + self.interrupt_ack(token, turn_id); + if self.user_stop.as_deref() == Some(turn_id) { + self.user_stop = None; + } + true + } + + pub fn settle_native(&mut self, turn_id: &str, evidence: NativeSettlement) -> bool { + let mut settled = false; + for fact in &mut self.facts { + if fact.settlement.is_none() + && fact.delivery != Delivery::Refused + && (evidence == NativeSettlement::OwnedExit + || fact.turn_id.as_deref() == Some(turn_id)) + { + fact.settlement = Some(evidence); + settled = true; + } + } + if evidence == NativeSettlement::OwnedExit || self.active.as_deref() == Some(turn_id) { + self.active = None; + } + // A completion does not prove that an outstanding native interrupt has + // finished its unqualified core submission. Owned exit does. + if evidence == NativeSettlement::OwnedExit { + self.owned_exit = true; + self.stop = None; + self.user_stop = None; + self.changed.notify_waiters(); + } + settled + } + + pub fn observe_started(&mut self, turn_id: &str) -> StopDisposition { + if self + .facts + .iter() + .any(|f| f.turn_id.as_deref() == Some(turn_id) && f.settlement.is_some()) + { + return StopDisposition::None; + } + if self.active.as_deref().is_some_and(|id| id != turn_id) { + return StopDisposition::None; + } + let token = self + .facts + .iter() + .rev() + .find(|f| { + f.turn_id.is_none() && f.settlement.is_none() && f.delivery != Delivery::Refused + }) + .map(|f| f.token) + .or_else(|| { + self.facts + .iter() + .find(|f| { + f.turn_id.as_deref() == Some(turn_id) + && f.settlement.is_none() + && f.delivery == Delivery::Accepted + }) + .map(|f| f.token) + }) + .unwrap_or_else(|| self.begin_dispatch("")); + self.accept(token, turn_id) + } + + pub fn has_owed_work(&self) -> bool { + self.facts + .iter() + .any(|f| f.settlement.is_none() && f.delivery != Delivery::Refused) + } + pub fn has_unknown_delivery(&self) -> bool { + self.facts.iter().any(|f| { + f.settlement.is_none() + && f.turn_id.is_none() + && matches!(f.delivery, Delivery::Pending | Delivery::Ambiguous) + }) + } + pub fn has_stopping_work(&self) -> bool { + self.user_stop.as_deref().is_some_and(|id| { + self.facts.iter().any(|f| { + f.turn_id.as_deref() == Some(id) + && f.settlement.is_none() + && f.delivery != Delivery::Refused + }) + }) + } + pub fn observe_idle(&mut self) { + self.active = None; + } + pub fn active_turn_id(&self) -> Option<&str> { + self.active.as_deref() + } + pub fn has_unresolved_stop(&self) -> bool { + self.stop.is_some() + } + pub fn has_owned_exit(&self) -> bool { + self.owned_exit + } + pub fn user_stop_turn_id(&self) -> Option<&str> { + self.user_stop.as_deref() + } + pub fn facts(&self) -> &VecDeque { + &self.facts + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejected_initial_dispatch_releases_its_latched_stop_without_owing_work() { + let mut s = CodexTurnState::default(); + let token = s.begin_dispatch("refused"); + assert_eq!(s.request_stop(), StopDisposition::Latched); + assert!(s.reject(token)); + assert!(!s.has_owed_work()); + assert!(!s.has_unresolved_stop()); + assert_eq!(s.accept(token, "never-admitted"), StopDisposition::None); + assert_eq!(s.active_turn_id(), None); + } + + #[test] + fn a_duplicate_start_cannot_accept_a_rejected_or_ambiguous_same_turn_steer() { + let mut s = CodexTurnState::default(); + let first = s.begin_dispatch("start"); + s.accept(first, "old"); + let steer = s.begin_dispatch("steer"); + s.observe_started("old"); + assert!(s.reject(steer)); + let ambiguous = s.begin_dispatch("unknown-steer"); + s.mark_ambiguous(ambiguous); + s.observe_started("old"); + assert_eq!(s.facts().back().unwrap().delivery, Delivery::Ambiguous); + assert!(s.has_owed_work()); + s.settle_native("old", NativeSettlement::Completed); + assert!(!s.has_owed_work()); + assert_eq!( + s.facts()[1].settlement, + None, + "a refusal never acquires a native outcome" + ); + } + + #[test] + fn same_id_steer_receipts_have_one_terminal_settlement_and_refusal_keeps_the_turn() { + let mut s = CodexTurnState::default(); + let first = s.begin_dispatch("first"); + s.accept(first, "old"); + let refused = s.begin_dispatch("refused"); + assert!(s.reject(refused)); + assert!(s.has_owed_work()); + assert_eq!(s.active_turn_id(), Some("old")); + let steer = s.begin_dispatch("steer"); + s.accept(steer, "old"); + assert_eq!( + s.facts() + .iter() + .filter(|f| f.delivery == Delivery::Accepted) + .count(), + 2 + ); + assert!(s.settle_native("old", NativeSettlement::Completed)); + assert!(!s.has_owed_work()); + assert!(!s.settle_native("old", NativeSettlement::Completed)); + } + + #[test] + fn stop_latches_before_acceptance_and_ack_does_not_settle() { + let mut s = CodexTurnState::default(); + let dispatch = s.begin_dispatch("pending"); + assert_eq!(s.request_stop(), StopDisposition::Latched); + assert_eq!( + s.accept(dispatch, "old"), + StopDisposition::Interrupt { + dispatch, + turn_id: "old".into() + } + ); + assert_eq!(s.accept(dispatch, "old"), StopDisposition::None); + s.interrupt_ack(dispatch, "old"); + assert!(s.has_owed_work()); + assert!(!s.has_unresolved_stop()); + assert!(s.settle_native("old", NativeSettlement::Interrupted)); + assert!(!s.has_owed_work()); + } + + #[test] + fn natural_completion_and_ambiguous_stop_do_not_open_the_issuance_gate() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + s.request_stop(); + s.settle_native("old", NativeSettlement::Completed); + assert!(!s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.mark_ambiguous(old); + assert!(s.has_unresolved_stop()); + s.interrupt_ack(old, "old"); + let new = s.begin_dispatch("new"); + s.accept(new, "new"); + s.interrupt_ack(old, "old"); + s.settle_native("old", NativeSettlement::Interrupted); + assert!(s.has_owed_work()); + assert_eq!(s.active_turn_id(), Some("new")); + } + + #[test] + fn ambiguous_dispatch_is_not_replayed_or_settled_by_an_empty_startup_ack() { + let mut s = CodexTurnState::default(); + let dispatch = s.begin_dispatch("receipt"); + s.mark_ambiguous(dispatch); + assert_eq!(s.request_stop(), StopDisposition::Latched); + s.interrupt_ack(dispatch, ""); + assert!(s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.observe_started("native"); + s.settle_native("native", NativeSettlement::Failed); + assert!(!s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.settle_native("", NativeSettlement::OwnedExit); + assert!(!s.has_unresolved_stop()); + assert_eq!(s.facts()[0].request_id, "receipt"); + } + + #[test] + fn pre_acceptance_events_do_not_resurrect_settled_work() { + let mut s = CodexTurnState::default(); + let token = s.begin_dispatch("fast"); + s.observe_started("fast"); + s.settle_native("fast", NativeSettlement::Completed); + s.accept(token, "fast"); + s.observe_started("fast"); + assert!(!s.has_owed_work()); + assert_eq!(s.active_turn_id(), None); + } +} diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 810f8b69b..d76f93b75 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -248,6 +248,7 @@ type PendingSendMetadata = { checkpointOutcome?: CheckpointTurnAvailability submittedTurnId?: string legacyAccepted?: boolean + deliveryAmbiguous?: boolean /** The exact text of the freshAgent.send frame -- retained as the resend * payload for the lost-session retry (Task 10). Never read back from the * local echo. */ @@ -1146,6 +1147,7 @@ export function FreshAgentView({ const [outgoingTurnVersion, refreshOutgoingTurn] = useReducer((value: number) => value + 1, 0) // Transient, self-clearing banner for action feedback (rewind, shell errors). const [notice, setNotice] = useState(null) + const deliveryNoticeRef = useRef(null) const [modelDialogOpen, setModelDialogOpen] = useState(false) const closeModelDialog = useCallback(() => setModelDialogOpen(false), []) const openModelDialog = useCallback(() => setModelDialogOpen(true), []) @@ -3130,8 +3132,17 @@ export function FreshAgentView({ // pending-metadata entry, the stale local echo (dual-write), and the // optimistic `running` status. const failedText = pendingSendMetadataRef.current.get(failedRequestId)?.text - pendingSendMetadataRef.current.delete(failedRequestId) - if (outgoingTurnRef.current?.requestId === failedRequestId) { + const ambiguous = current.provider === 'codex' + && typeof message.message === 'string' && message.message.startsWith('CODEX_DELIVERY_AMBIGUOUS:') + if (ambiguous && pendingSendMetadataRef.current.get(failedRequestId)?.deliveryAmbiguous) return + if (ambiguous) { + // Keep the receipt and queue reservation: a lost response can follow + // accepted native input. Only later lifecycle/history evidence may + // release it. A snapshot read never replays the prompt. + recordPendingSendMetadata(failedRequestId, { deliveryAmbiguous: true }) + requestSnapshotRefresh('poll') + } else pendingSendMetadataRef.current.delete(failedRequestId) + if (!ambiguous && outgoingTurnRef.current?.requestId === failedRequestId) { outgoingTurnRef.current = null refreshOutgoingTurn() } @@ -3144,9 +3155,13 @@ export function FreshAgentView({ // G05 (C09): a claude send the server refused was never sent — for // example its session ended — so the text goes back into the // composer with the reason instead of vanishing with the echo. - if (current.provider === 'claude' && failedText) { + if ((current.provider === 'claude' || current.provider === 'codex') && failedText) { composerRef.current?.restoreText(failedText) - setNotice(`Message not sent: ${sendFailureReason(message.message)}`) + const failureNotice = ambiguous + ? `Delivery uncertain (${failedRequestId}): ${sendFailureReason(message.message)}. Check the conversation before sending again.` + : `Message not sent: ${sendFailureReason(message.message)}` + deliveryNoticeRef.current = ambiguous ? failureNotice : null + setNotice(failureNotice) log.warn('fresh_agent.send_refused', { provider: current.provider, sessionType: current.sessionType, @@ -4194,7 +4209,7 @@ export function FreshAgentView({ }, [effectiveStatus, hidden, isBusy, managedRecoveryPending, ownerDivergence, paneContent.sessionId, requestSnapshotRefresh]) useEffect(() => { - if (!notice) return + if (!notice || notice === deliveryNoticeRef.current) return const timer = window.setTimeout(() => setNotice(null), 6000) return () => window.clearTimeout(timer) }, [notice]) diff --git a/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts index 9daad0f8e..868488afd 100644 --- a/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts +++ b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts @@ -9,7 +9,11 @@ import { openPanePicker } from '../helpers/pane-picker.js' const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) -test('Freshcodex retains a refused prompt with its delivery reason', async ({ browser }) => { +for (const delivery of [ + { name: 'refused', code: -32602, message: 'This input was refused before admission' }, + { name: 'ambiguous', code: -32603, message: 'The native response was lost after possible admission' }, +] as const) { +test(`Freshcodex retains a ${delivery.name} prompt with its delivery reason`, async ({ browser }) => { test.setTimeout(180_000) const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-delivery-')) const wrapper = path.join(root, 'codex.mjs') @@ -18,7 +22,7 @@ test('Freshcodex retains a refused prompt with its delivery reason', async ({ br const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', - FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ overrides: { 'turn/start': { error: { code: -32602, message: 'This input was refused before admission' } } } }), + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ overrides: { 'turn/start': { error: { code: delivery.code, message: delivery.message } } } }), }, setupHome: async (home) => { await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) @@ -45,10 +49,17 @@ test('Freshcodex retains a refused prompt with its delivery reason', async ({ br await input.fill('Keep the original prompt') await pane.getByRole('button', { name: 'Send', exact: true }).click() await expect(input).toHaveValue('Keep the original prompt', { timeout: 15_000 }) - await expect(pane.getByText(/Message not sent:.*refused before admission/)).toBeVisible() + if (delivery.name === 'refused') { + await expect(pane.getByText(/Message not sent:.*refused before admission/)).toBeVisible() + await expect(pane.getByRole('button', { name: 'Send', exact: true })).toBeEnabled() + } else { + await expect(pane.getByText(/Delivery uncertain \(.+\):.*native response was lost/)).toBeVisible() + await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toBeVisible() + } } finally { await context?.close().catch(() => {}) await server.stop() await fs.rm(root, { recursive: true, force: true }) } }) +} diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index 2e80b3fe1..eea001bca 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -372,6 +372,11 @@ function successResult(method, params) { sandbox: params?.sandbox ?? 'danger-full-access', } } + if (method === 'turn/steer') { + // Native 0.164 active input adds to the expected turn; it does not + // manufacture a second turn or apply turn/start setting overrides. + return { turnId: params?.expectedTurnId } + } if (method === 'turn/start') { if (behavior.recordTurns) { const threadId = params?.threadId || 'thread-new-1' diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index c3dbecea8..aec733b2b 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -1396,6 +1396,27 @@ describe('FreshAgentView', () => { expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') }) + it('holds queued Codex input after ambiguous delivery without replaying the original', async () => { + const queue = await setup('idle') + queue.send('Original uncertain input') + queue.send('Later queued input') + const first = sentFreshAgentMessages('freshAgent.send')[0] + const listener = wsMock.onMessage.mock.calls.at(-1)?.[0] as unknown as (message: unknown) => void + act(() => listener({ type: 'error', code: 'INTERNAL_ERROR', requestId: first.requestId, + message: 'CODEX_DELIVERY_AMBIGUOUS: response was lost' })) + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Original uncertain input')) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') + expect(screen.getByText(`Delivery uncertain (${first.requestId}): response was lost. Check the conversation before sending again.`)).toBeInTheDocument() + act(() => listener({ type: 'error', code: 'INTERNAL_ERROR', requestId: first.requestId, + message: 'CODEX_DELIVERY_AMBIGUOUS: response was lost' })) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Original uncertain input') + queue.status('running') + queue.status('idle') + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(2)) + expect(sentFreshAgentMessages('freshAgent.send')[1]).toMatchObject({ text: 'Later queued input' }) + }) + it('advances after a fast interrupted turn whose status updates share one render', async () => { const queue = await setup('idle') queue.send('First message') @@ -10253,6 +10274,7 @@ describe('FreshAgentView', () => { 'The Claude agent process for this session exited. Resume the session to continue.'], ['kilroy', 'INTERNAL_ERROR', 'SESSION_NOT_FOUND: claude session not found', 'claude session not found'], ['freshcodex', 'SESSION_RESERVED', 'Another resume for this session is in flight', 'Another resume for this session is in flight'], + ['freshcodex', 'INTERNAL_ERROR', 'CODEX_DELIVERY_AMBIGUOUS: native response was lost', 'native response was lost'], ] as const)('puts a refused %s send back into the composer and says why (%s)', async (sessionType, code, message, reason) => { const store = createStore() let onMessage: ((message: Record) => void) | undefined @@ -10295,13 +10317,16 @@ describe('FreshAgentView', () => { }) const sendFrame = sentFreshAgentMessages('freshAgent.send').at(-1) expect(composer).toHaveValue('') + if (provider === 'codex') fireEvent.change(composer, { target: { value: 'newer draft' } }) act(() => { onMessage?.({ type: 'error', code, message, requestId: sendFrame?.requestId, timestamp: new Date().toISOString() }) }) - await waitFor(() => expect(composer).toHaveValue('please keep this')) - expect(screen.getByText(`Message not sent: ${reason}`)).toBeInTheDocument() + await waitFor(() => expect(composer).toHaveValue(provider === 'codex' ? 'please keep this\nnewer draft' : 'please keep this')) + expect(screen.getByText(message.startsWith('CODEX_DELIVERY_AMBIGUOUS:') + ? `Delivery uncertain (${sendFrame?.requestId}): ${reason}. Check the conversation before sending again.` + : `Message not sent: ${reason}`)).toBeInTheDocument() expect(getFreshAgentPaneContent(store).pendingLocalEcho).toBeUndefined() }) From 8cbe5428660c05cbd64cf71e6f63cd2807f6275a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:39:43 -0700 Subject: [PATCH 20/92] fix(codex): publish stop refusal under its ownership guard --- crates/freshell-freshagent/src/codex.rs | 91 ++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 18d26a4c0..d82330d8e 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -329,6 +329,23 @@ async fn wait_for_codex_stop_resolution(turns: &SharedCodexTurnState) { } } +fn publish_codex_interrupt_refusal( + turns: &SharedCodexTurnState, + dispatch: DispatchToken, + turn_id: &str, + publish: impl FnOnce(), +) -> bool { + let mut state = turns.lock().expect("codex turn state"); + if !state.interrupt_refused(dispatch, turn_id) { + return false; + } + // Gate waiters may wake during the transition, but cannot acquire state + // and admit a newer Stop until its compatibility publication is complete. + // Production publication only writes the atomic attention marker. + publish(); + true +} + /// Seed the quiet window from `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS` (positive integer /// milliseconds; missing or unparseable falls back to [`DEFAULT_CODEX_QUIET_WINDOW_MS`]). fn codex_quiet_window_ms_from_env() -> u64 { @@ -4174,16 +4191,12 @@ impl FreshCodexState { } Err(err) => { let refused = err.is_not_submitted(); - let disarmed = refused - && turns - .lock() - .expect("codex turn state") - .interrupt_refused(dispatch, &turn_id); - if disarmed { - // Only this operation's proven refusal can retract the marker. - if let Some(marker) = marker { - marker.store(false, Ordering::SeqCst); - } + if refused { + publish_codex_interrupt_refusal(&turns, dispatch, &turn_id, || { + if let Some(marker) = marker { + marker.store(false, Ordering::SeqCst); + } + }); } tracing::warn!(provider=PROVIDER, session_id, native_turn_id=%turn_id, dispatch=?dispatch, refused, error=%err, "freshagent.codex.interrupt_failed"); self.emit_fresh_agent_error( @@ -19516,6 +19529,64 @@ pub(crate) mod tests { ); } + #[test] + fn task4_refusal_marker_publication_cannot_clear_a_newer_stop() { + let turns = Arc::new(StdMutex::new(CodexTurnState::default())); + let marker = Arc::new(AtomicBool::new(true)); + let old = { + let mut state = turns.lock().unwrap(); + let old = state.begin_dispatch("old"); + state.accept(old, "old"); + state.request_stop(); + state.settle_native("old", NativeSettlement::Completed); + old + }; + let (entered, at_publication) = std::sync::mpsc::channel(); + let (release, may_publish) = std::sync::mpsc::channel(); + let publisher = { + let turns = turns.clone(); + let marker = marker.clone(); + std::thread::spawn(move || { + publish_codex_interrupt_refusal(&turns, old, "old", || { + // This is the actual compatibility publication boundary, held + // with deterministic barriers, not a delayed native ACK model. + entered.send(()).unwrap(); + may_publish.recv_timeout(Duration::from_secs(5)).unwrap(); + marker.store(false, Ordering::SeqCst); + }) + }) + }; + at_publication.recv_timeout(Duration::from_secs(5)).unwrap(); + let mut admitted_new = false; + let blocked = match turns.try_lock() { + Ok(mut state) => { + let new = state.begin_dispatch("new"); + state.accept(new, "new"); + state.request_stop(); + marker.store(true, Ordering::SeqCst); + admitted_new = true; + false + } + Err(std::sync::TryLockError::WouldBlock) => true, + Err(error) => panic!("unexpected state-lock failure: {error}"), + }; + release.send(()).unwrap(); + assert!(publisher.join().unwrap()); + if !admitted_new { + let mut state = turns.lock().unwrap(); + let new = state.begin_dispatch("new"); + state.accept(new, "new"); + state.request_stop(); + marker.store(true, Ordering::SeqCst); + } + assert!( + marker.load(Ordering::SeqCst), + "an old refusal publication must never clear the newer Stop marker" + ); + assert!(blocked, "new dispatch/Stop cannot acquire ownership before the old compatibility publication completes"); + assert_eq!(turns.lock().unwrap().user_stop_turn_id(), Some("new")); + } + fn task4_stop(session_id: &str) -> FreshAgentInterrupt { FreshAgentInterrupt { session_id: session_id.into(), From 88763a5803122cab7332eb1a06d185dab9cac90b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:54:21 -0700 Subject: [PATCH 21/92] fix(codex): keep stop intent and send bookkeeping owned --- crates/freshell-freshagent/src/codex.rs | 50 ++++++++++------ .../src/codex/turn_state.rs | 57 ++++++++++++++++--- 2 files changed, 84 insertions(+), 23 deletions(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index d82330d8e..f9532f23d 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -3515,20 +3515,6 @@ impl FreshCodexState { return; } - // Decision 5 (codex/opencode/claude share this helper): any new - // submission permanently destroys redo. AWAITED before the turn goes - // out; a ledger write failure is logged but never blocks the send. - if let Some(err) = crate::rollback_record::destroy_redo_on_submit( - &self.identity_sink(), - PROVIDER, - &session_id, - now_ms(), - ) - .await - { - tracing::warn!(error = %err, session = %session_id, "freshagent.codex.destroy_redo_on_submit_failed"); - } - // Re-normalize model/effort on send (adapter.ts:961-963) — idempotent for stored values. let model = normalize_freshcodex_model(Some(&model)); let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); @@ -3621,6 +3607,20 @@ impl FreshCodexState { None }; + // Decision 5 (codex/opencode/claude share this helper): any new + // submission permanently destroys redo. AWAITED before the turn goes + // out; a ledger write failure is logged but never blocks the send. + if let Some(err) = crate::rollback_record::destroy_redo_on_submit( + &self.identity_sink(), + PROVIDER, + &session_id, + now_ms(), + ) + .await + { + tracing::warn!(error = %err, session = %session_id, "freshagent.codex.destroy_redo_on_submit_failed"); + } + turn_in_flight.store(true, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); let result = if let Some(expected) = expected_turn_id.as_deref() { @@ -29405,11 +29405,24 @@ pub(crate) mod tests { #[tokio::test(flavor = "multi_thread")] async fn checkpoint_send_with_stale_fence_is_refused_for_an_already_running_codex_session() { let _guard = ENV_LOCK.lock().await; - let (mut st, mut rx) = state_with_bus(); + let (mut st, mut rx, sink) = state_with_sink(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(Arc::clone(®istry)); configure_fake_codex_cmd("{}"); let thread_id = create_real_fake_session(&st, &mut rx).await; + let mut rollback = crate::rollback_record::RollbackRecord::empty(1); + rollback.push_entry( + crate::rollback_record::RollbackEntry { + removed_turns: vec![json!({"id":"owned-history"})], + prompt_text: "retained".into(), + at_ms: 2, + epoch: 0, + }, + 3, + ); + sink.record_rollback(PROVIDER, &thread_id, rollback.clone()) + .await + .unwrap(); let before = registry.observe(PROVIDER, &thread_id); let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_handoff( PROVIDER, @@ -29440,7 +29453,7 @@ pub(crate) mod tests { st.handle_send(FreshAgentSend { request_id: Some("req-stale-checkpoint-codex".into()), provider: freshell_protocol::AgentProvider::Codex, - session_id: thread_id, + session_id: thread_id.clone(), session_type: freshell_protocol::SessionType::Freshcodex, text: "delayed checkpoint prompt".into(), images: None, @@ -29462,6 +29475,11 @@ pub(crate) mod tests { saw_reserved |= frame.to_string().contains("SESSION_RESERVED"); saw_accepted |= frame.to_string().contains("freshAgent.send.accepted"); } + assert_eq!( + sink.load_rollback(PROVIDER, &thread_id), + Some(rollback), + "a stale refused send cannot mutate saved rollback state before its ownership guard" + ); assert!(saw_reserved, "the stale owner fence is refused typed"); assert!( !saw_accepted, diff --git a/crates/freshell-freshagent/src/codex/turn_state.rs b/crates/freshell-freshagent/src/codex/turn_state.rs index a145bb956..12b692bf5 100644 --- a/crates/freshell-freshagent/src/codex/turn_state.rs +++ b/crates/freshell-freshagent/src/codex/turn_state.rs @@ -115,6 +115,7 @@ impl CodexTurnState { return false; } fact.delivery = Delivery::Refused; + fact.settlement = None; if self .stop .as_ref() @@ -138,13 +139,6 @@ impl CodexTurnState { if self.stop.is_some() { return StopDisposition::Latched; } - if self - .active - .as_ref() - .is_some_and(|id| self.user_stop.as_ref() == Some(id)) - { - return StopDisposition::None; - } let Some(fact) = self .facts .iter() @@ -155,6 +149,12 @@ impl CodexTurnState { }; let dispatch = fact.token; let target = self.active.clone().or_else(|| fact.turn_id.clone()); + if target + .as_ref() + .is_some_and(|id| self.user_stop.as_ref() == Some(id)) + { + return StopDisposition::None; + } self.stop = Some(StopOperation { dispatch, turn_id: target.clone(), @@ -323,6 +323,49 @@ impl CodexTurnState { mod tests { use super::*; + #[test] + fn an_acknowledged_stop_stays_deduplicated_through_the_native_idle_gap() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + assert_eq!( + s.request_stop(), + StopDisposition::Interrupt { + dispatch: old, + turn_id: "old".into() + } + ); + s.interrupt_ack(old, "old"); + s.observe_idle(); + assert_eq!( + s.request_stop(), + StopDisposition::None, + "thread idle must not re-arm the same acknowledged Stop" + ); + let new = s.begin_dispatch("new"); + s.accept(new, "new"); + assert_eq!( + s.request_stop(), + StopDisposition::Interrupt { + dispatch: new, + turn_id: "new".into() + } + ); + } + + #[test] + fn native_completion_during_a_pending_steer_cannot_assign_an_outcome_to_a_later_refusal() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + let steer = s.begin_dispatch("steer"); + s.settle_native("old", NativeSettlement::Completed); + assert!(s.reject(steer)); + assert_eq!(s.facts().back().unwrap().delivery, Delivery::Refused); + assert_eq!(s.facts().back().unwrap().settlement, None); + assert!(!s.has_owed_work()); + } + #[test] fn rejected_initial_dispatch_releases_its_latched_stop_without_owing_work() { let mut s = CodexTurnState::default(); From f0151661c21ff3765e0bc9622c57345e51df7c91 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:06:11 -0700 Subject: [PATCH 22/92] test(codex): prove terminal consumption under dispatch serialization --- crates/freshell-freshagent/src/codex.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index f9532f23d..bfc7977ab 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -19631,6 +19631,18 @@ pub(crate) mod tests { answer_initialize(&peer).await; let (stop_id, method, _) = peer.expect_request().await; assert_eq!(method, "turn/interrupt"); + // Hold the exact dispatch serialization lock while terminal evidence + // is consumed. A consumer that waits for this lock cannot release the + // issuance gate, and this deterministic barrier would fail. + let dispatch_lock = st + .sessions + .lock() + .await + .get("thread-held") + .unwrap() + .turn_lock + .clone(); + let dispatch_guard = dispatch_lock.lock().await; peer.emit_notification( "turn/completed", json!({"threadId":"thread-held", "turn":{"id":"old","status":"completed"}}), @@ -19643,6 +19655,7 @@ pub(crate) mod tests { terminal.matched, "consumer must progress with Stop RPC held" ); + drop(dispatch_guard); let turns = st .sessions .lock() From 0758e19fa4be6e877f56b6eb55127da417df636d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:49:00 -0700 Subject: [PATCH 23/92] feat(codex): honor native controls and MCP elicitation --- crates/freshell-codex/src/app_server.rs | 1 + .../tests/elicitation_capabilities.rs | 18 + .../freshell-freshagent/src/claude_edges.rs | 1 + crates/freshell-freshagent/src/codex.rs | 18 +- .../freshell-freshagent/src/codex/controls.rs | 634 ++++++++-- .../src/codex/controls/tests.rs | 1020 ++++++++++++++++- .../src/codex/elicitation.rs | 323 ++++++ .../src/codex/projection.rs | 23 + .../freshell-protocol/src/client_messages.rs | 36 +- .../freshell-protocol/src/server_messages.rs | 2 + .../freshell-server/src/fresh_agent_proxy.rs | 14 + .../src/fresh_agent_proxy_tests.rs | 74 ++ .../src/providers/fresh_agent.rs | 186 ++- crates/freshell-ws/src/hosted_fresh_agent.rs | 8 +- .../src/hosted_fresh_agent_tests.rs | 4 + crates/freshell-ws/src/lib.rs | 10 +- crates/freshell-ws/src/terminal.rs | 15 + .../freshagent_codex_attention_replay.rs | 118 ++ .../tests/freshagent_control_reply.rs | 16 + shared/fresh-agent-contract.ts | 29 + shared/ws-protocol.ts | 14 + .../fresh-agent/FreshAgentApprovalCard.tsx | 40 +- .../fresh-agent/FreshAgentChildObserver.tsx | 4 +- .../fresh-agent/FreshAgentComposer.tsx | 12 + .../FreshAgentElicitationBanner.tsx | 107 ++ .../fresh-agent/FreshAgentItemCard.tsx | 36 +- .../fresh-agent/FreshAgentQuestionBanner.tsx | 20 +- src/components/fresh-agent/FreshAgentView.tsx | 40 +- src/lib/fresh-agent-async-questions.ts | 28 + src/lib/fresh-agent-permission-updates.ts | 1 + src/lib/fresh-agent-ws.ts | 16 +- src/lib/ready-message-schema.ts | 1 + src/store/freshAgentSlice.ts | 14 + src/store/freshAgentTypes.ts | 2 + src/store/turnCompletionThunks.ts | 44 +- .../freshcodex-child-observer-rust.spec.ts | 30 +- .../specs/freshcodex-controls-rust.spec.ts | 187 +++ .../codex/task001-child-approval.json | 89 ++ .../FreshAgentChildObserver.test.tsx | 42 + .../FreshAgentElicitationBanner.test.tsx | 80 ++ .../fresh-agent/FreshAgentItemCard.test.tsx | 15 + .../FreshAgentQuestionBanner.test.tsx | 31 + .../FreshAgentView.approvals.test.tsx | 101 +- test/unit/client/lib/fresh-agent-ws.test.ts | 17 + .../store/turnCompletion.replay.test.ts | 19 +- test/unit/shared/fresh-agent-contract.test.ts | 20 + 46 files changed, 3428 insertions(+), 132 deletions(-) create mode 100644 crates/freshell-codex/tests/elicitation_capabilities.rs create mode 100644 crates/freshell-freshagent/src/codex/elicitation.rs create mode 100644 crates/freshell-ws/tests/freshagent_codex_attention_replay.rs create mode 100644 src/components/fresh-agent/FreshAgentElicitationBanner.tsx create mode 100644 src/lib/fresh-agent-async-questions.ts create mode 100644 test/e2e-browser/specs/freshcodex-controls-rust.spec.ts create mode 100644 test/fixtures/fresh-agent/codex/task001-child-approval.json create mode 100644 test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index f4036fec9..50a3a3bc3 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -313,6 +313,7 @@ impl CodexAppServerClient { "clientInfo": { "name": "freshell", "version": "1.0.0" }, "capabilities": { "experimentalApi": true, + "extensions": {"openai/form": {}, "openai/elicitation": {"form": {}}, "openai/standard-form-input": {}}, "optOutNotificationMethods": ["thread/started"], }, }); diff --git a/crates/freshell-codex/tests/elicitation_capabilities.rs b/crates/freshell-codex/tests/elicitation_capabilities.rs new file mode 100644 index 000000000..41188c2aa --- /dev/null +++ b/crates/freshell-codex/tests/elicitation_capabilities.rs @@ -0,0 +1,18 @@ +use freshell_codex::{new_channel_transport, CodexAppServerClient}; +use serde_json::json; + +#[tokio::test] +async fn task5_native_initialization_advertises_both_observed_openai_form_routes() { + let (transport, peer) = new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let initialize = tokio::spawn(async move { client.initialize().await.unwrap() }); + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "initialize"); + assert_eq!( + params["capabilities"]["extensions"], + json!({"openai/form":{},"openai/elicitation":{"form":{}},"openai/standard-form-input":{}}) + ); + peer.respond(&id, json!({})); + peer.expect_notification().await; + initialize.await.unwrap(); +} diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index f22275074..60ad989ad 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -181,6 +181,7 @@ impl ClaudeEdgeHub { session_type: mark.session_type, last_turn_complete_at: mark.last_turn_complete_at, last_waiting_at: mark.last_waiting_at, + last_question_available_at: None, }) .collect() } diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index bfc7977ab..f771f1da4 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -84,6 +84,7 @@ use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySi pub mod children; mod controls; +mod elicitation; mod metadata; pub(crate) mod native_history; mod projection; @@ -7753,6 +7754,9 @@ impl FreshCodexState { continue; } }; + state + .observe_child_control_turn(&thread_id, &projection, ¬ification) + .await; // Unified agent names (Task 3): an upstream `thread/name/updated` // is a NATIVE title observation — automatic provider metadata, // folded at the store's current location revision. It emits no @@ -7880,7 +7884,7 @@ impl FreshCodexState { } } if state - .consume_control_notification(&thread_id, ¬ification) + .consume_control_notification(&thread_id, ¬ification, &projection) .await { // A control-consumed notification (an approval/question @@ -7998,6 +8002,9 @@ impl FreshCodexState { /// armed window) and the epoch hasn't already fired. Used by [`Self::get_snapshot`] /// after it seeds an observed mid-flight turn (the U-1 repair). fn arm_codex_quiet_if_absent(&self, thread_id: &str, quiet: &Arc>) { + if self.awaited_control_count(thread_id) != 0 { + return; + } let window_ms = self.codex_quiet_window_ms(); let Some(generation) = quiet .lock() @@ -8038,7 +8045,7 @@ impl FreshCodexState { // "Turn still in flight" reads the dispatch-time latch — the lane's // turn-in-flight authority — at the deadline. let fired = crate::quiet_deadman::wait_for_quiet_deadline(&quiet, generation, || { - turn_in_flight.load(Ordering::SeqCst) + turn_in_flight.load(Ordering::SeqCst) && self.awaited_control_count(&thread_id) == 0 }) .await; if !fired { @@ -9589,7 +9596,7 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result>>>>; +pub(super) type ControlRegistry = Arc>>>; + +#[derive(Default)] +pub(super) struct SessionControls { + pending: TokioMutex, + child_turns: StdMutex>, + /// Actual unresolved reply-bearing native RPCs, independent of UI metadata. + awaited_control_count: std::sync::atomic::AtomicUsize, + last_waiting_at: std::sync::atomic::AtomicI64, + last_question_available_at: std::sync::atomic::AtomicI64, +} +struct ChildControlTurn { + turn_id: String, + native_client: Arc, +} +impl SessionControls { + async fn lock(&self) -> tokio::sync::MutexGuard<'_, PendingControls> { + self.pending.lock().await + } +} #[derive(Default)] pub(super) struct PendingControls { @@ -12,6 +34,15 @@ pub(super) struct PendingControls { /// The session's waiting-edge clock: each 0→≥1 pending transition rings /// `freshAgent.turn.waiting` at a strictly-monotonic `at`. waiting_clock: crate::attention::AttentionClock, + async_question_turn: Option, + async_question_ids: HashSet, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ControlKind { + Approval, + Question, + Elicitation, } struct PendingRequest { @@ -19,7 +50,8 @@ struct PendingRequest { method: String, params: Value, public: Value, - question: bool, + kind: ControlKind, + native_client: Arc, } fn request_key(id: &RequestId) -> String { @@ -32,12 +64,40 @@ fn response_key(id: &StringOrNumber) -> String { } } -fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option { - let key = request_key(&id); +fn normalize_request( + id: RequestId, + method: &str, + params: &Value, + native_client: Arc, +) -> Option { + // Native IDs can be reused after resolution or a sidecar restart. The public + // key identifies this operation; the original typed ID stays on its client. + let key = json!([id.to_json(), uuid::Uuid::new_v4().to_string()]).to_string(); let question = method == "item/tool/requestUserInput"; - let public = if question { + if method != "mcpServer/elicitation/request" + && !question + && params + .get("availableDecisions") + .and_then(Value::as_array) + .is_some_and(|choices| { + !choices.iter().any(|choice| { + choice.as_str().is_some_and(|value| { + matches!(value, "accept" | "acceptForSession" | "decline" | "cancel") + }) + }) + }) + { + return None; + } + let public = if method == "mcpServer/elicitation/request" { + let mut value = elicitation::normalize(params)?; + value["requestId"] = json!(key); + value + } else if question { let questions = params.get("questions")?.as_array()?.iter().filter_map(|entry| { let mut value = json!({ "id": entry.get("id")?.as_str()?, "question": entry.get("question")?.as_str()? }); + value["isOther"] = json!(entry.get("isOther").and_then(Value::as_bool).unwrap_or(false)); + value["isSecret"] = json!(entry.get("isSecret").and_then(Value::as_bool).unwrap_or(false)); if let Some(header) = entry.get("header").and_then(Value::as_str) { value["header"] = json!(header); } if let Some(options) = entry.get("options").and_then(Value::as_array) { value["options"] = json!(options.iter().filter_map(|option| Some(json!({ @@ -50,7 +110,11 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option ( @@ -66,7 +130,7 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option return None, }; - let mut value = json!({ "requestId": key, "toolName": tool, "input": input }); + let mut value = json!({ "requestId": key, "toolName": tool, "input": input, "native": params, "providerRequest": {"id": id.to_json(), "method": method} }); if let Some(item_id) = params.get("itemId").and_then(Value::as_str) { value["toolUseID"] = json!(item_id); } @@ -78,24 +142,119 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option Value { + let mut event = request.public.clone(); + event["type"] = json!(match request.kind { + ControlKind::Approval => "freshAgent.permission.request", + ControlKind::Question => "freshAgent.question.request", + ControlKind::Elicitation => "freshAgent.elicitation.request", + }); + event["sessionId"] = json!(session_id); + if request.kind == ControlKind::Approval { + event["tool"] = json!({"name":request.public["toolName"],"input":request.public["input"]}); + } + event +} + impl FreshCodexState { - async fn session_controls(&self, session_id: &str) -> Arc> { + async fn session_controls(&self, session_id: &str) -> Arc { self.controls .lock() - .await + .expect("codex controls registry") .entry(session_id.into()) .or_default() .clone() } + pub(super) fn awaited_control_count(&self, session_id: &str) -> usize { + self.controls + .lock() + .expect("codex controls registry") + .get(session_id) + .map_or(0, |controls| { + controls.awaited_control_count.load(Ordering::SeqCst) + }) + } + + async fn publish_control_membership( + &self, + session_id: &str, + controls: &SessionControls, + pending: &PendingControls, + ) { + controls + .awaited_control_count + .store(pending.requests.len(), Ordering::SeqCst); + if pending.requests.is_empty() { + controls.last_waiting_at.store(0, Ordering::SeqCst); + } + if let Some((_, quiet, in_flight, _, _)) = self.quiet_handles(session_id).await { + note_codex_activity(self, session_id, &in_flight, &quiet); + } + } + + /// Ready-frame marks read retained runtime state only. They never hydrate or resume native history. + pub async fn attention_marks(&self) -> Vec { + let sessions = self + .sessions + .lock() + .await + .iter() + .map(|(id, session)| (id.clone(), session.last_turn_complete_at.clone())) + .collect::>(); + let controls = self.controls.lock().expect("codex controls registry"); + sessions + .into_iter() + .map(|(id, complete)| { + let retained = controls.get(&id); + let waiting = retained.map_or(0, |controls| { + controls.last_waiting_at.load(Ordering::SeqCst) + }); + let available = retained.map_or(0, |controls| { + controls.last_question_available_at.load(Ordering::SeqCst) + }); + freshell_protocol::FreshAgentAttentionMark { + provider: PROVIDER.into(), + session_type: SESSION_TYPE.into(), + session_id: id, + last_turn_complete_at: *complete.lock().expect("attention complete clock"), + last_waiting_at: (waiting > 0).then_some(waiting), + last_question_available_at: (available > 0).then_some(available), + } + }) + .collect() + } + fn control_event(&self, session_id: &str, event: Value) { self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { provider: PROVIDER.into(), @@ -107,8 +266,8 @@ impl FreshCodexState { fn cancelled_control(&self, session_id: &str, request: &PendingRequest) { self.control_event(session_id, json!({ - "type": if request.question { "freshAgent.question.cancelled" } else { "freshAgent.permission.cancelled" }, - "sessionId": session_id, "requestId": request_key(&request.id), + "type": match request.kind { ControlKind::Question => "freshAgent.question.cancelled", ControlKind::Approval => "freshAgent.permission.cancelled", ControlKind::Elicitation => "freshAgent.elicitation.cancelled" }, + "sessionId": session_id, "requestId": request.public["requestId"], })); } @@ -118,6 +277,13 @@ impl FreshCodexState { for request in pending.requests.drain(..) { self.cancelled_control(session_id, &request); } + controls + .child_turns + .lock() + .expect("child control turns") + .clear(); + self.publish_control_membership(session_id, &controls, &pending) + .await; } pub(super) async fn overlay_controls(&self, session_id: &str, snapshot: &mut Value) { @@ -125,149 +291,431 @@ impl FreshCodexState { snapshot["capabilities"]["questions"] = json!(true); let controls = self.session_controls(session_id).await; let pending = controls.lock().await; + snapshot["pendingElicitations"] = json!(pending + .requests + .iter() + .filter(|r| r.kind == ControlKind::Elicitation) + .map(|r| &r.public) + .collect::>()); snapshot["pendingApprovals"] = json!(pending .requests .iter() - .filter(|r| !r.question) + .filter(|r| r.kind == ControlKind::Approval) .map(|r| &r.public) .collect::>()); snapshot["pendingQuestions"] = json!(pending .requests .iter() - .filter(|r| r.question) + .filter(|r| r.kind == ControlKind::Question) .map(|r| &r.public) .collect::>()); } + /// Authoritative retained controls for bridge loss reconciliation; never reads/resumes native history. + pub async fn retained_control_events(&self, session_id: &str) -> Vec { + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + let Some(controls) = controls else { + return Vec::new(); + }; + let pending = controls.lock().await; + pending + .requests + .iter() + .map(|request| control_request_event(session_id, request)) + .collect() + } + pub(super) async fn consume_control_notification( &self, session_id: &str, notification: &CodexNotification, + source: &Arc>, ) -> bool { + let client = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| Arc::ptr_eq(&session.projection, source)) + .map(|session| session.client.clone()); + let Some(client) = client else { + return matches!(notification, CodexNotification::ServerRequest { .. }); + }; match notification { CodexNotification::ServerRequest { id, method, params } => { - let client = self - .sessions - .lock() - .await - .get(session_id) - .map(|s| s.client.clone()); - if params.get("threadId").and_then(Value::as_str) != Some(session_id) { - if let Some(client) = client { - let _ = client - .reject_request(id, "Request does not belong to this conversation") - .await; - } + let turn_matches = self + .control_scope_matches(session_id, params, &client, false) + .await; + if !turn_matches { + let _ = client.reject_request(id, "Request does not belong to this native conversation or active child turn").await; + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request belongs to a retired turn.", + ); return true; } - let Some(request) = normalize_request(id.clone(), method, params) else { + let Some(mut request) = + normalize_request(id.clone(), method, params, client.clone()) + else { tracing::warn!( provider = PROVIDER, session_id, method, + request_id = %request_key(id), + native_turn_id = params.get("turnId").and_then(|value| value.as_str()), + native_item_id = params.get("itemId").and_then(|value| value.as_str()), "freshagent.codex.unsupported_request" ); - if let Some(client) = client { + if method == "mcpServer/elicitation/request" { + let _ = client + .respond(id, json!({"action":"decline","content":null})) + .await; + } else { let _ = client .reject_request(id, "This Codex request is not supported by Freshell") .await; } self.emit_fresh_agent_error( session_id, - "UNSUPPORTED_CAPABILITY", - "Codex requested an unsupported action. Try continuing with a message.", + if method == "mcpServer/elicitation/request" {"UNSUPPORTED_ELICITATION"} else {"UNSUPPORTED_CAPABILITY"}, + &format!("Codex requested an unsupported action ({}). Freshell supports flat primitive and enum forms; nested forms and verification actions must be completed outside this form. Try continuing with a message.", params.get("mode").and_then(Value::as_str).unwrap_or(method)), ); return true; }; let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; let was_empty = pending.requests.is_empty(); - pending.requests.retain(|r| r.id != request.id); - let event = if request.question { - json!({ "type": "freshAgent.question.request", "sessionId": session_id, "requestId": request_key(id), "questions": request.public["questions"] }) - } else { - json!({ "type": "freshAgent.permission.request", "sessionId": session_id, "requestId": request_key(id), "tool": { "name": request.public["toolName"], "input": request.public["input"] } }) - }; + if let Some(index) = pending.requests.iter().position(|r| r.id == request.id) { + let previous = pending.requests.remove(index); + if previous.method == request.method + && previous.params == request.params + && Arc::ptr_eq(&previous.native_client, &request.native_client) + { + // Repeated native delivery and snapshot replay refer to + // the same unresolved operation, including its public key. + request.public["requestId"] = previous.public["requestId"].clone(); + } else { + self.cancelled_control(session_id, &previous); + } + } + let event = control_request_event(session_id, &request); pending.requests.push(request); + self.publish_control_membership(session_id, &controls, &pending) + .await; self.control_event(session_id, event); if was_empty { let at = pending.waiting_clock.mint(); + controls.last_waiting_at.store(at, Ordering::SeqCst); self.control_event(session_id, json!({ "type": "freshAgent.turn.waiting", "sessionId": session_id, "at": at })); } tracing::info!( provider = PROVIDER, session_id, method, + request_id = %request_key(id), + native_turn_id = params.get("turnId").and_then(|value| value.as_str()), + native_item_id = params.get("itemId").and_then(|value| value.as_str()), + awaited_control_count = pending.requests.len(), "freshagent.codex.request_pending" ); true } + CodexNotification::Other { + method, + params: Some(params), + } if method == "item/completed" + && params["threadId"] == session_id + && params["item"]["type"] == "agentMessage" + && params["item"]["delivery"] == "async" => + { + let item = ¶ms["item"]; + let (Some(item_id), Some(turn_id), Some(questions)) = ( + item["id"].as_str(), + params["turnId"].as_str(), + item["questions"].as_array(), + ) else { + return false; + }; + if questions.is_empty() + || !self + .control_scope_matches(session_id, params, &client, true) + .await + { + return false; + } + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + if pending.async_question_turn.as_deref() != Some(turn_id) { + pending.async_question_ids.clear(); + pending.async_question_turn = Some(turn_id.into()); + } + if pending.async_question_ids.insert(item_id.into()) { + let at = pending.waiting_clock.mint(); + controls + .last_question_available_at + .store(at, Ordering::SeqCst); + self.control_event(session_id,json!({"type":"freshAgent.question.available","sessionId":session_id,"itemId":item_id,"turnId":turn_id,"awaited":false,"at":at})); + } + false + } CodexNotification::Other { method, params } if method == "serverRequest/resolved" => { if let Some(params) = params { - if params.get("threadId").and_then(Value::as_str) == Some(session_id) { + if self + .control_scope_matches(session_id, params, &client, false) + .await + { let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; let id = params.get("requestId"); pending.requests.retain(|request| { - if id == Some(&request.id.to_json()) { + if id == Some(&request.id.to_json()) + && params["threadId"] == request.params["threadId"] + { self.cancelled_control(session_id, request); false } else { true } }); + self.publish_control_membership(session_id, &controls, &pending) + .await; } } true } - CodexNotification::TurnCompleted(event) if event.thread_id == session_id => { + CodexNotification::TurnCompleted(event) + if (event.thread_id == session_id + || self + .control_scope_matches( + session_id, + &json!({"threadId":event.thread_id,"turnId":event.turn_id}), + &client, + false, + ) + .await) + && event + .params + .get("status") + .or_else(|| event.params.get("turn").and_then(|turn| turn.get("status"))) + .and_then(Value::as_str) + != Some("inProgress") => + { let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; pending.requests.retain(|request| { - if event.turn_id.as_deref().is_none_or(|id| { - request.params.get("turnId").and_then(Value::as_str) == Some(id) - }) { + if request.params["threadId"] == event.thread_id + && event.turn_id.as_deref().is_none_or(|id| { + request.params.get("turnId").and_then(Value::as_str) == Some(id) + }) + { self.cancelled_control(session_id, request); false } else { true } }); + if event.thread_id != session_id { + controls + .child_turns + .lock() + .expect("child control turns") + .remove(&event.thread_id); + } + self.publish_control_membership(session_id, &controls, &pending) + .await; false } CodexNotification::ThreadClosed { thread_id } if thread_id == session_id => { self.clear_controls(session_id).await; false } + CodexNotification::ThreadClosed { thread_id } + if self + .control_scope_matches( + session_id, + &json!({"threadId":thread_id}), + &client, + false, + ) + .await => + { + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + pending.requests.retain(|request| { + if request.params["threadId"] == *thread_id { + self.cancelled_control(session_id, request); + false + } else { + true + } + }); + controls + .child_turns + .lock() + .expect("child control turns") + .remove(thread_id); + self.publish_control_membership(session_id, &controls, &pending) + .await; + false + } _ => false, } } - pub async fn handle_approval_respond(&self, msg: FreshAgentApprovalRespond) { - let allow = match msg.decision.get("behavior").and_then(Value::as_str) { - Some("allow") => true, - Some("deny") => false, - _ => { - self.emit_fresh_agent_error( - &msg.session_id, - "INVALID_DECISION", - "Choose Allow or Deny for the Codex request.", - ); - return; + /// Observe child lifecycle before the consumer's turn/started fast path. + /// Its native spawn, current source/client and actual child turn are required. + pub(super) async fn observe_child_control_turn( + &self, + session_id: &str, + source: &Arc>, + notification: &CodexNotification, + ) { + let CodexNotification::TurnStarted(event) = notification else { + return; + }; + if event.thread_id == session_id { + return; + } + let Some(turn_id) = &event.turn_id else { + return; + }; + let client = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| Arc::ptr_eq(&session.projection, source)) + .map(|session| session.client.clone()); + let Some(client) = client else { + return; + }; + if !source + .lock() + .expect("codex projection lock") + .knows_child_thread(&event.thread_id) + { + return; + } + let controls = self.session_controls(session_id).await; + controls + .child_turns + .lock() + .expect("child control turns") + .insert( + event.thread_id.clone(), + ChildControlTurn { + turn_id: turn_id.clone(), + native_client: client, + }, + ); + } + + async fn control_scope_matches( + &self, + session_id: &str, + params: &Value, + native_client: &Arc, + allow_retired: bool, + ) -> bool { + let sessions = self.sessions.lock().await; + let Some(session) = sessions.get(session_id) else { + return false; + }; + if !Arc::ptr_eq(&session.client, native_client) { + return false; + } + let Some(thread_id) = params.get("threadId").and_then(Value::as_str) else { + return false; + }; + let turn_id = params.get("turnId").and_then(Value::as_str); + if thread_id != session_id { + if !session + .projection + .lock() + .expect("codex projection lock") + .knows_child_thread(thread_id) + { + return false; } + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + return controls.is_some_and(|controls| { + controls + .child_turns + .lock() + .expect("child control turns") + .get(thread_id) + .is_some_and(|child| { + Arc::ptr_eq(&child.native_client, native_client) + && turn_id.is_none_or(|id| child.turn_id == id) + }) + }); + } + let Some(turn_id) = turn_id else { + return true; }; - self.respond_control(&msg.session_id, &response_key(&msg.request_id), false, |request| { - Ok(if request.method == "item/permissions/requestApproval" { - json!({ "permissions": if allow { request.params["permissions"].clone() } else { json!({}) }, "scope": "turn" }) - } else { json!({ "decision": if allow { "accept" } else { "decline" } }) }) - }).await; + session.active_turn.lock().expect("active turn").as_deref() == Some(turn_id) + // Async content remains actionable after its turn completes. A new + // awaited native RPC must still belong to the current active turn. + || allow_retired && session + .retired_turn_id + .lock() + .expect("retired turn") + .as_deref() + == Some(turn_id) + } + + pub async fn handle_approval_respond(&self, msg: FreshAgentApprovalRespond) { + self.try_handle_approval_respond(msg).await; + } + + pub async fn try_handle_approval_respond(&self, msg: FreshAgentApprovalRespond) -> bool { + self.respond_control(&msg.session_id, &response_key(&msg.request_id), ControlKind::Approval, |request| { + let decision = msg.decision.get("nativeDecision").and_then(Value::as_str).or_else(|| { + match msg.decision.get("behavior").and_then(Value::as_str) { + Some("allow") => Some("accept"), Some("deny") => Some("decline"), _ => None, + } + }).ok_or("Choose an offered decision for this Codex request.")?; + if request.method == "item/permissions/requestApproval" { + return match decision { + "accept" => Ok(json!({"permissions":request.params["permissions"], "scope":"turn"})), + "decline" => Ok(json!({"permissions":{}, "scope":"turn"})), + _ => Err("This permission request supports allowing or declining the requested permissions."), + }; + } + if !matches!(decision, "accept" | "acceptForSession" | "decline" | "cancel") { + return Err("This native approval decision is not supported."); + } + if let Some(offered) = request.params.get("availableDecisions").and_then(Value::as_array) { + if !offered.iter().any(|value| value.as_str() == Some(decision)) { + return Err("Codex did not offer this approval decision."); + } + } else if decision == "acceptForSession" { + return Err("Codex did not offer session approval."); + } + Ok(json!({"decision":decision})) + }).await } pub async fn handle_question_respond(&self, msg: FreshAgentQuestionRespond) { + self.try_handle_question_respond(msg).await; + } + + pub async fn try_handle_question_respond(&self, msg: FreshAgentQuestionRespond) -> bool { self.respond_control( &msg.session_id, &response_key(&msg.request_id), - true, + ControlKind::Question, |request| { let mut answers = Map::new(); for question in request.public["questions"] @@ -275,25 +723,48 @@ impl FreshCodexState { .expect("normalized questions") { let id = question["id"].as_str().expect("normalized id"); - let text = question["question"].as_str().expect("normalized question"); - let Some(answer) = msg.answers.get(id).or_else(|| msg.answers.get(text)) else { + let Some(answer) = msg.answers.get(id) else { return Err("Answer every question before submitting."); }; + if question["isOther"] == false + && question["options"].as_array().is_some_and(|options| { + !options.is_empty() + && !options + .iter() + .any(|option| option["label"].as_str() == Some(answer)) + }) + { + return Err("Choose one of the options Codex offered."); + } answers.insert(id.into(), json!({ "answers": [answer] })); } Ok(json!({ "answers": answers })) }, ) - .await; + .await + } + + pub async fn handle_elicitation_respond(&self, msg: FreshAgentElicitationRespond) { + self.try_handle_elicitation_respond(msg).await; + } + + pub async fn try_handle_elicitation_respond(&self, msg: FreshAgentElicitationRespond) -> bool { + self.respond_control( + &msg.session_id, + &response_key(&msg.request_id), + ControlKind::Elicitation, + |request| elicitation::response(&request.params, &msg.action, &msg.content), + ) + .await } async fn respond_control( &self, session_id: &str, key: &str, - question: bool, + kind: ControlKind, build: impl FnOnce(&PendingRequest) -> Result, - ) { + ) -> bool { let client = self .sessions .lock() @@ -306,7 +777,7 @@ impl FreshCodexState { "INVALID_SESSION_ID", "Codex session is no longer available.", ); - return; + return false; }; // Keep membership until the transport write succeeds; duplicate clicks cannot // answer twice and a failed write leaves the banner available for retry. @@ -315,39 +786,56 @@ impl FreshCodexState { let Some(index) = pending .requests .iter() - .position(|r| request_key(&r.id) == key && r.question == question) + .position(|r| r.public["requestId"].as_str() == Some(key) && r.kind == kind) else { self.emit_fresh_agent_error( session_id, "REQUEST_UNAVAILABLE", "This Codex request is no longer waiting for an answer.", ); - return; + return false; }; let request = &pending.requests[index]; + if !Arc::ptr_eq(&client, &request.native_client) + || !self + .control_scope_matches(session_id, &request.params, &request.native_client, false) + .await + { + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request belongs to an earlier native session or turn.", + ); + return false; + } let result = match build(request) { Ok(value) => value, Err(message) => { + tracing::warn!(provider=PROVIDER,session_id,request_id=%key,method=%request.method,native_turn_id=request.params.get("turnId").and_then(|value| value.as_str()),native_item_id=request.params.get("itemId").and_then(|value| value.as_str()),"freshagent.codex.invalid_control_answer"); self.emit_fresh_agent_error(session_id, "INVALID_ANSWER", message); - return; + return false; } }; - if let Err(error) = client.respond(&request.id, result).await { - tracing::warn!(provider = PROVIDER, session_id, error = %error, "freshagent.codex.respond_failed"); + if let Err(error) = request.native_client.respond(&request.id, result).await { + tracing::warn!(provider = PROVIDER, session_id, request_id = %key, error = %error, "freshagent.codex.respond_failed"); self.emit_fresh_agent_error( session_id, "RESPONSE_FAILED", &format!("Could not send your answer to Codex: {error}"), ); - return; + return false; } let request = pending.requests.remove(index); + self.publish_control_membership(session_id, &controls, &pending) + .await; self.cancelled_control(session_id, &request); tracing::info!( provider = PROVIDER, session_id, + request_id = %key, "freshagent.codex.request_answered" ); + true } } diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index 7c79422d0..cca12b500 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -6,15 +6,26 @@ async fn runtime() -> ( FreshCodexState, ChannelPeer, tokio::sync::broadcast::Receiver, +) { + runtime_for("thread-1", "turn-1").await +} + +async fn runtime_for( + session_id: &str, + turn_id: &str, +) -> ( + FreshCodexState, + ChannelPeer, + tokio::sync::broadcast::Receiver, ) { let (transport, peer) = freshell_codex::new_channel_transport(); let (client, notifications) = CodexAppServerClient::connect(transport); let (state, rx) = state_with_bus(); insert_fake_session_with_real_consumer( &state, - "thread-1", + session_id, Arc::new(client), - Arc::new(StdMutex::new(Some("turn-1".into()))), + Arc::new(StdMutex::new(Some(turn_id.into()))), notifications, spawn_sleeper(), "codex-controls-fixture", @@ -23,6 +34,265 @@ async fn runtime() -> ( (state, peer, rx) } +fn task001_child_control_frames() -> Vec { + serde_json::from_str::(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/fresh-agent/codex/task001-child-approval.json" + ))) + .unwrap()["frames"] + .as_array() + .unwrap() + .clone() +} + +async fn child_control_runtime() -> ( + FreshCodexState, + ChannelPeer, + tokio::sync::broadcast::Receiver, + Vec, +) { + let frames = task001_child_control_frames(); + let (state, peer, rx) = runtime_for( + frames[0]["params"]["threadId"].as_str().unwrap(), + frames[0]["params"]["turnId"].as_str().unwrap(), + ) + .await; + for frame in &frames[..2] { + peer.emit_notification(frame["method"].as_str().unwrap(), frame["params"].clone()); + } + (state, peer, rx, frames) +} + +async fn child_approval_event(rx: &mut tokio::sync::broadcast::Receiver) -> Value { + loop { + let next = event(rx).await; + if next["type"] == "freshAgent.permission.request" { + return next; + } + } +} + +#[tokio::test] +async fn task5_actual_parent_owned_child_approval_uses_parent_client_without_a_child_writer() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = tokio::select! { + request = child_approval_event(&mut rx) => request, + frame = peer.next_raw_frame() => panic!("projection-proven child approval was refused: {frame:?}"), + }; + assert_eq!(request["sessionId"], parent); + assert_eq!(request["providerRequest"]["id"], 0); + assert_eq!(request["providerRequest"]["threadId"], child); + let mut snapshot = json!({"capabilities":{}}); + state.overlay_controls(parent, &mut snapshot).await; + assert_eq!( + snapshot["pendingApprovals"][0]["requestId"], + request["requestId"] + ); + assert_eq!(state.awaited_control_count(parent), 1); + { + let sessions = state.sessions.lock().await; + assert_eq!(sessions.len(), 1); + assert!(!sessions.contains_key(child)); + assert_eq!( + sessions[parent].active_turn.lock().unwrap().as_deref(), + Some(parent_turn) + ); + } + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + assert!(state.retained_control_events(parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_child_control_still_refuses_unrelated_threads_and_wrong_native_child_turns() { + for invalid_thread in [true, false] { + let (state, peer, _rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let mut params = frames[2]["params"].clone(); + params[if invalid_thread { "threadId" } else { "turnId" }] = + json!("unrelated-native-identity"); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + params, + ); + assert!(peer.next_raw_frame().await.unwrap().get("error").is_some()); + assert!(state.retained_control_events(parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_child_control_outlives_parent_terminal_and_keeps_its_native_owner() { + // This is a deliberate interleaving, not the capture's completion ordering. + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":parent_turn,"status":"completed","items":[]}}), + ); + loop { + let next = event(&mut rx).await; + assert_ne!(next["type"], "freshAgent.permission.cancelled"); + if next["type"] == "freshAgent.turn.complete" { + break; + } + } + assert_eq!(state.awaited_control_count(parent), 1); + assert_eq!( + state.retained_control_events(parent).await[0]["requestId"], + request["requestId"] + ); + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_child_resolution_and_terminal_only_cancel_the_matching_native_thread() { + for finish in ["serverRequest/resolved", "turn/completed", "thread/closed"] { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + let child_turn = frames[2]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let child_request = child_approval_event(&mut rx).await; + peer.request_client( + &RequestId::Int(301), + "item/commandExecution/requestApproval", + json!({"threadId":parent,"turnId":parent_turn,"command":"parent fixture"}), + ); + let parent_request = child_approval_event(&mut rx).await; + peer.emit_notification(finish, match finish { + "serverRequest/resolved" => json!({"threadId":child,"requestId":0}), + "turn/completed" => json!({"threadId":child,"turn":{"id":child_turn,"status":"completed","items":[]}}), + _ => json!({"threadId":child}), + }); + loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.permission.cancelled" { + assert_eq!(next["requestId"], child_request["requestId"]); + break; + } + } + let retained = state.retained_control_events(parent).await; + assert_eq!(retained.len(), 1); + assert_eq!(retained[0]["requestId"], parent_request["requestId"]); + assert_eq!(state.awaited_control_count(parent), 1); + assert_eq!( + state.sessions.lock().await[parent] + .active_turn + .lock() + .unwrap() + .as_deref(), + Some(parent_turn) + ); + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_parent_source_close_drains_child_and_parent_controls_once() { + for disconnect in [false, true] { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let child_request = child_approval_event(&mut rx).await; + peer.request_client( + &RequestId::Int(301), + "item/commandExecution/requestApproval", + json!({"threadId":parent,"turnId":parent_turn,"command":"parent fixture"}), + ); + let parent_request = child_approval_event(&mut rx).await; + let remaining_peer = if disconnect { + peer.disconnect(); + None + } else { + peer.emit_notification("thread/closed", json!({"threadId":parent})); + Some(peer) + }; + let mut cancelled = Vec::new(); + while cancelled.len() < 2 { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.permission.cancelled" { + cancelled.push(next["requestId"].clone()); + } + } + assert!(cancelled.contains(&child_request["requestId"])); + assert!(cancelled.contains(&parent_request["requestId"])); + assert_eq!(state.awaited_control_count(parent), 0); + assert!(state.retained_control_events(parent).await.is_empty()); + state.clear_controls(parent).await; + while let Ok(frame) = rx.try_recv() { + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!(frame["event"]["type"], "freshAgent.permission.cancelled"); + } + if let Some(peer) = remaining_peer { + peer.disconnect(); + } + tokio::time::timeout(Duration::from_secs(3), state.shutdown()) + .await + .expect("owned channel fixture teardown must complete"); + } +} + +#[tokio::test] +async fn task5_child_relationship_requires_the_exact_native_parent_sender() { + let mut frames = task001_child_control_frames(); + let parent = frames[0]["params"]["threadId"].as_str().unwrap().to_owned(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap().to_owned(); + frames[0]["params"]["item"]["senderThreadId"] = json!("unrelated-parent"); + let (state, peer, _rx) = runtime_for(&parent, &parent_turn).await; + for frame in &frames[..2] { + peer.emit_notification(frame["method"].as_str().unwrap(), frame["params"].clone()); + } + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + assert!(peer.next_raw_frame().await.unwrap().get("error").is_some()); + assert!(state.retained_control_events(&parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + async fn event(rx: &mut tokio::sync::broadcast::Receiver) -> Value { let frame = tokio::time::timeout(Duration::from_secs(3), rx.recv()) .await @@ -273,3 +543,749 @@ async fn codex_user_controls_failed_response_keeps_request_available() { assert_eq!(pending["pendingApprovals"].as_array().unwrap().len(), 1); state.shutdown().await; } + +#[tokio::test] +async fn codex_question_keeps_secret_other_and_blocking_flags_on_reload() { + let (state, peer, mut rx) = runtime().await; + peer.request_client( + &RequestId::Int(51), + "item/tool/requestUserInput", + json!({ + "threadId":"thread-1","turnId":"turn-1","itemId":"ask-1","isBlocking":false, + "questions":[{"id":"token","header":"Token","question":"Enter token", + "options":null,"isOther":false,"isSecret":true}] + }), + ); + let request = event(&mut rx).await; + assert_eq!(request["questions"][0]["isSecret"], true); + assert_eq!(request["isBlocking"], false); + let pending = snapshot(&state, &peer).await; + assert_eq!( + pending["pendingQuestions"][0]["questions"][0]["isOther"], + false + ); + assert_eq!( + pending["pendingQuestions"][0]["questions"][0]["isSecret"], + true + ); + let answer = " token, punctuation! "; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"token":answer}})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":51,"result":{"answers":{"token":{"answers":[answer]}}}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_awaited_questions_pause_quiet_deadman_regardless_of_blocking_flag() { + for blocking in [false, true] { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(80); + peer.request_client(&RequestId::Int(61), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":blocking,"questions":[{"id":"a","question":"A?"}]})); + assert_eq!(event(&mut rx).await["type"], "freshAgent.question.request"); + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + tokio::time::sleep(Duration::from_millis(300)).await; + while let Ok(frame) = rx.try_recv() { + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!( + value["event"]["status"], "stuck", + "awaited native RPC must pause quiet detection: {value}" + ); + } + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_last_awaited_reply_rearms_a_full_quiet_window() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(120); + let mut keys = Vec::new(); + for id in [71, 72] { + peer.request_client(&RequestId::Int(id), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"a","question":"A?"}]})); + let request = event(&mut rx).await; + assert_eq!(request["type"], "freshAgent.question.request"); + keys.push(request["requestId"].clone()); + if id == 71 { + event(&mut rx).await; + } + } + let respond = |id| { + serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":id,"answers":{"a":"one, exact answer"}})).unwrap() + }; + state.handle_question_respond(respond(&keys[0])).await; + peer.next_raw_frame().await.unwrap(); + event(&mut rx).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + rx.try_recv().is_err(), + "one unresolved native control must still pause detection" + ); + state.handle_question_respond(respond(&keys[1])).await; + peer.next_raw_frame().await.unwrap(); + event(&mut rx).await; + assert!( + tokio::time::timeout(Duration::from_millis(60), rx.recv()) + .await + .is_err(), + "last response starts a NEW quiet window" + ); + let stuck = event(&mut rx).await; + assert_eq!( + stuck["status"], "stuck", + "owed native work must be watched after final answer: {stuck}" + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_approval_metadata_and_offered_cancel_are_preserved() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Str("41".into()), "item/commandExecution/requestApproval", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command","approvalId":"approval-distinct","networkApprovalContext":{"host":"example.com","protocol":"https"},"availableDecisions":["accept","cancel"]})); + let request = event(&mut rx).await; + assert_eq!( + request["native"]["availableDecisions"], + json!(["accept", "cancel"]) + ); + assert_eq!( + request["native"]["networkApprovalContext"]["host"], + "example.com" + ); + event(&mut rx).await; + state.handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":"41","result":{"decision":"cancel"}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_approval_cannot_grant_a_decision_native_did_not_offer() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(91), "item/commandExecution/requestApproval", json!({"threadId":"thread-1","turnId":"turn-1","command":"echo x","availableDecisions":["cancel"]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"decision":{"behavior":"allow"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_stale_turn_control_is_refused_before_it_becomes_answerable() { + let (state, peer, mut rx) = runtime().await; + peer.request_client( + &RequestId::Int(99), + "item/tool/requestUserInput", + json!({"threadId":"thread-1","turnId":"old-turn","questions":[{"id":"a","question":"A?"}]}), + ); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("stale native request receives a refusal") + .unwrap(); + assert!(response.get("error").is_some(), "{response}"); + assert_ne!(event(&mut rx).await["type"], "freshAgent.question.request"); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_mcp_form_and_url_requests_are_retained_as_elicitations() { + for (mode, body) in [ + ( + "form", + json!({"requestedSchema":{"type":"object","properties":{"token":{"type":"string","writeOnly":true}},"required":["token"]}}), + ), + ( + "openai/form", + json!({"requestedSchema":{"type":"object","properties":{"age":{"type":"integer","minimum":1}},"required":["age"]}}), + ), + ( + "openaiForm", + json!({"requestedSchema":{"type":"object","properties":{"enabled":{"type":"boolean"}}}}), + ), + ( + "url", + json!({"url":"https://example.com/verify","elicitationId":"public-url-id"}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + let mut params = json!({"threadId":"thread-1","turnId":null,"serverName":"contract","mode":mode,"message":"Fixture request"}); + params + .as_object_mut() + .unwrap() + .extend(body.as_object().unwrap().clone()); + peer.request_client(&RequestId::Int(41), "mcpServer/elicitation/request", params); + let request = event(&mut rx).await; + assert_eq!( + request["type"], "freshAgent.elicitation.request", + "{request}" + ); + assert_eq!(request["mode"], mode); + let pending = snapshot(&state, &peer).await; + assert_eq!( + pending["pendingElicitations"][0]["requestId"], + request["requestId"] + ); + assert_eq!( + pending["pendingElicitations"][0]["providerRequest"]["id"], + 41 + ); + peer.disconnect(); + state.shutdown().await; + } +} + +#[test] +fn task5_elicitation_response_is_a_typed_client_message() { + let response = json!({"type":"freshAgent.elicitation.respond","provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":"41","action":"accept","content":{"age":3,"enabled":true,"name":"hello","colors":["blue"]}}); + let parsed = serde_json::from_value::(response.clone()); + assert!( + parsed.is_ok(), + "new response must have ordinary WS ingress: {parsed:?}" + ); + assert_eq!(serde_json::to_value(parsed.unwrap()).unwrap(), response); +} + +#[tokio::test] +async fn task5_unsupported_elicitation_declines_once_and_explains_subtype() { + for (mode, schema) in [ + ("openai/userVerification", json!(null)), + ( + "form", + json!({"type":"object","properties":{"nested":{"type":"object","properties":{}}}}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(101), "mcpServer/elicitation/request", json!({"threadId":"thread-1","serverName":"contract","mode":mode,"title":"Verify", "message":"Fixture", "requestedSchema":schema})); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":101,"result":{"action":"decline","content":null}}) + ); + let notice = event(&mut rx).await; + assert_eq!(notice["code"], "UNSUPPORTED_ELICITATION"); + assert!(notice["message"].as_str().unwrap().contains(mode)); + peer.disconnect(); + state.shutdown().await; + } +} + +async fn invalid_elicitation(content: Value) { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(111), "mcpServer/elicitation/request", json!({"threadId":"thread-1","turnId":"turn-1","serverName":"contract","mode":"form","message":"Fixture","requestedSchema":{"type":"object","properties":{"count":{"type":"integer","minimum":1,"maximum":5},"color":{"type":"string","enum":["blue","green"]},"name":{"type":"string","minLength":2,"maxLength":8},"colors":{"type":"array","items":{"type":"string","enum":["blue","green"]},"minItems":1,"maxItems":2}},"required":["count","color","name","colors"]}})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_elicitation_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"action":"accept","content":content})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "invalid answer must leave native RPC pending" + ); + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} +macro_rules! invalid_elicitation_case { + ($name:ident, $patch:expr) => { + #[tokio::test] + async fn $name() { + let mut content = json!({"count":3,"color":"blue","name":"valid","colors":["green"]}); + ($patch)(&mut content); + invalid_elicitation(content).await; + } + } +} +invalid_elicitation_case!(task5_form_rejects_missing_required, |v: &mut Value| { + v.as_object_mut().unwrap().remove("name"); +}); +invalid_elicitation_case!(task5_form_rejects_wrong_type, |v: &mut Value| { + v["count"] = json!("3"); +}); +invalid_elicitation_case!(task5_form_rejects_fractional_integer, |v: &mut Value| { + v["count"] = json!(1.5); +}); +invalid_elicitation_case!(task5_form_rejects_numeric_range, |v: &mut Value| { + v["count"] = json!(10); +}); +invalid_elicitation_case!(task5_form_rejects_unknown_enum, |v: &mut Value| { + v["color"] = json!("red"); +}); +invalid_elicitation_case!(task5_form_rejects_string_bounds, |v: &mut Value| { + v["name"] = json!("x"); +}); +invalid_elicitation_case!(task5_form_rejects_array_enum, |v: &mut Value| { + v["colors"] = json!(["red"]); +}); +invalid_elicitation_case!(task5_form_rejects_extra_fields, |v: &mut Value| { + v["extra"] = json!(true); +}); + +#[tokio::test] +async fn task5_form_and_url_actions_preserve_original_native_id_type() { + let (state, peer, mut rx) = runtime().await; + let mut keys = Vec::new(); + for id in [RequestId::Int(41), RequestId::Str("41".into())] { + peer.request_client(&id, "mcpServer/elicitation/request", json!({"threadId":"thread-1","serverName":"contract","mode":"url","message":"Visit then confirm","url":"https://example.com","elicitationId":"public-url"})); + let request = event(&mut rx).await; + if id == RequestId::Int(41) { + event(&mut rx).await; + } + assert_eq!(request["providerRequest"]["id"], id.to_json()); + keys.push(request["requestId"].clone()); + } + assert_eq!(state.retained_control_events("thread-1").await.len(), 2); + for (key, id, action) in [ + (&keys[1], json!("41"), "cancel"), + (&keys[0], json!(41), "accept"), + ] { + state.handle_elicitation_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":key,"action":action,"content":null})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":id,"result":{"action":action,"content":null}}) + ); + assert_eq!( + event(&mut rx).await["type"], + "freshAgent.elicitation.cancelled" + ); + } + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_question_rejects_other_when_native_only_offers_choices() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(121),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"pick","question":"Pick","isOther":false,"options":[{"label":"A","description":"Alpha"}]}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"pick":"unoffered"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + peer.disconnect(); + state.shutdown().await; +} + +#[test] +fn task5_async_agent_message_retains_delivery_and_native_identity() { + let items=map_codex_item("async-question",&json!({"id":"async-question","type":"agentMessage","text":"Where?","delivery":"async","questions":[{"title":"Where?","options":["Staging","Production"]}]}),"agentMessage").unwrap(); + assert_eq!(items[0]["extensions"]["codex"]["delivery"], "async"); + assert_eq!(items[0]["extensions"]["codex"]["itemId"], "async-question"); +} + +#[tokio::test] +async fn task5_async_attention_is_nonblocking_deduplicated_and_does_not_hide_silent_work() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(150); + let item = json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"async-one","type":"agentMessage","text":"Work continues","delivery":"async","questions":[{"title":"Where?","options":["Staging","Production"]}]}}); + peer.emit_notification("item/completed", item.clone()); + let available = tokio::time::timeout(Duration::from_millis(500), async { + loop { + let value = event(&mut rx).await; + if value["type"] == "freshAgent.question.available" { + break value; + } + } + }) + .await + .expect("async question must emit an actionable, nonblocking attention edge"); + assert_eq!(available["awaited"], false); + assert_eq!(available["itemId"], "async-one"); + assert!(available["at"].as_i64().is_some()); + assert!( + state.retained_control_events("thread-1").await.is_empty(), + "async questions never enter pending decisions" + ); + peer.emit_notification("item/completed", item); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"command","type":"commandExecution","command":"keep working","status":"inProgress"}})); + let stuck = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let value = event(&mut rx).await; + assert_ne!( + value["type"], "freshAgent.question.available", + "same native item must not ring twice" + ); + if value["status"] == "stuck" { + break value; + } + } + }) + .await + .expect("ordinary owed work must still trip the quiet deadman"); + assert_eq!(stuck["status"], "stuck"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "async visibility never creates a native response RPC" + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_nonterminal_completion_cannot_cancel_awaited_controls() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(141),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"q","question":"Question"}]})); + event(&mut rx).await; + event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-1","turnId":"turn-1","status":"inProgress"}), + ); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + while let Ok(raw) = rx.try_recv() { + let frame: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!( + frame["event"]["type"], "freshAgent.question.cancelled", + "nonterminal notification must not dismiss the question" + ); + } + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_unrepresentable_openai_schema_is_declined_instead_of_silently_dropping_constraints() +{ + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(151),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openai/form","message":"Constrained form","requestedSchema":{"type":"object","properties":{"name":{"type":"string"}},"anyOf":[{"required":["name"]}]}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported schema must release its native waiter") + .unwrap(); + assert_eq!( + response, + json!({"id":151,"result":{"action":"decline","content":null}}) + ); + let notice = event(&mut rx).await; + assert_eq!(notice["code"], "UNSUPPORTED_ELICITATION"); + assert!(notice["message"].as_str().unwrap().contains("flat")); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_structured_only_approval_choices_are_explicitly_refused() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(161),"item/commandExecution/requestApproval",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command","availableDecisions":[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["echo"]}}]})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported decision subtype must receive an explicit native refusal") + .unwrap(); + assert!(response.get("error").is_some()); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_CAPABILITY"); + assert!(state.retained_control_events("thread-1").await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_nonprimitive_form_default_is_declined_before_publication() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(171),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"name":{"type":"string","default":{"nested":"value"}}}}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("nonprimitive default must not break snapshot publication") + .unwrap(); + assert_eq!( + response, + json!({"id":171,"result":{"action":"decline","content":null}}) + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_native_question_answers_require_question_ids_when_titles_repeat() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(181),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"first","question":"Same title"},{"id":"second","question":"Same title"}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"Same title":"ambiguous"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_mcp_enum_names_are_labels_and_do_not_replace_native_values() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(191),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"form","message":"Pick","requestedSchema":{"type":"object","properties":{"color":{"type":"string","enum":["blue","green"],"enumNames":["Blue hue","Green hue"]}}}})); + let request = event(&mut rx).await; + assert_eq!( + request["fields"][0]["choices"][0], + json!({"value":"blue","label":"Blue hue"}) + ); + peer.disconnect(); + state.shutdown().await; +} + +async fn task5_unsupported_field(field: Value) { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(201),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"field":field}}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported field must release the native waiter") + .unwrap(); + assert_eq!( + response, + json!({"id":201,"result":{"action":"decline","content":null}}) + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.disconnect(); + state.shutdown().await; +} +macro_rules! unsupported_field_case { + ($name:ident,$field:expr) => { + #[tokio::test] + async fn $name() { + task5_unsupported_field($field).await; + } + }; +} +unsupported_field_case!( + task5_unsupported_leaf_constraint, + json!({"type":"string","allOf":[{"minLength":2}]}) +); +unsupported_field_case!( + task5_unsupported_description_type, + json!({"type":"string","description":{"nested":"value"}}) +); +unsupported_field_case!( + task5_unsupported_numeric_bound_type, + json!({"type":"number","minimum":"one"}) +); +unsupported_field_case!( + task5_unsupported_format, + json!({"type":"string","format":"unknown-format"}) +); +unsupported_field_case!( + task5_unsupported_array_item_type, + json!({"type":"array","items":{"type":"integer","enum":["blue"]}}) +); +unsupported_field_case!( + task5_unsupported_secret_hint_type, + json!({"type":"string","writeOnly":"yes"}) +); + +#[test] +fn task5_rejects_non_array_required_metadata() { + assert!(elicitation::normalize(&json!({"mode":"openaiForm","serverName":"contract","message":"Form","requestedSchema":{"type":"object","required":"field","properties":{"field":{"type":"string"}}}})).is_none()); +} +#[test] +fn task5_rejects_non_array_enum_metadata() { + assert!(elicitation::normalize(&json!({"mode":"openai/form","serverName":"contract","message":"Form","requestedSchema":{"type":"object","properties":{"field":{"type":"string","enum":"blue"}}}})).is_none()); +} + +#[test] +fn task5_rejects_non_string_url_identity_before_publication() { + assert!(elicitation::normalize(&json!({"mode":"url","serverName":"contract","message":"Visit","url":"https://example.com","elicitationId":{"nested":"unsupported"}})).is_none()); +} + +#[tokio::test] +async fn task5_approval_and_elicitation_waiters_pause_quiet_deadman() { + for (method, params) in [ + ( + "item/commandExecution/requestApproval", + json!({"command":"fixture","availableDecisions":["cancel"]}), + ), + ( + "mcpServer/elicitation/request", + json!({"mode":"url","serverName":"contract","message":"Visit","url":"https://example.com","elicitationId":"url-id"}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(60); + let mut params = params; + params["threadId"] = json!("thread-1"); + params["turnId"] = json!("turn-1"); + peer.request_client(&RequestId::Int(271), method, params); + event(&mut rx).await; + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + assert_eq!(state.awaited_control_count("thread-1"), 1); + tokio::time::sleep(Duration::from_millis(240)).await; + while let Ok(frame) = rx.try_recv() { + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!(frame["event"]["status"], "stuck"); + assert_ne!(frame["event"]["type"], "freshAgent.turn.complete"); + } + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_secret_form_defaults_stay_private_while_original_native_identity_is_retained() { + let (state, peer, mut rx) = runtime().await; + let secret = "sensitive-default-fixture"; + peer.request_client(&RequestId::Int(211),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"token":{"type":"string","writeOnly":true,"default":secret}},"required":["token"]}})); + let request = event(&mut rx).await; + assert_eq!(request["providerRequest"]["id"], 211); + assert_eq!( + request["providerRequest"]["method"], + "mcpServer/elicitation/request" + ); + assert!( + !request.to_string().contains(secret), + "secret defaults must not enter public controls or durable permission events" + ); + let snapshot = snapshot(&state, &peer).await; + assert!(!snapshot["pendingElicitations"].to_string().contains(secret)); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_reissued_native_id_rejects_old_public_answer_but_duplicate_replay_is_stable() { + let (state, peer, mut rx) = runtime().await; + let params = json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"answer","question":"Current question"}]}); + peer.request_client( + &RequestId::Int(251), + "item/tool/requestUserInput", + params.clone(), + ); + let original = event(&mut rx).await; + event(&mut rx).await; + peer.request_client( + &RequestId::Int(251), + "item/tool/requestUserInput", + params.clone(), + ); + assert_eq!(event(&mut rx).await["requestId"], original["requestId"]); + assert_eq!( + snapshot(&state, &peer).await["pendingQuestions"][0]["requestId"], + original["requestId"] + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + peer.emit_notification( + "serverRequest/resolved", + json!({"threadId":"thread-1","requestId":251}), + ); + assert_eq!(event(&mut rx).await["requestId"], original["requestId"]); + peer.request_client(&RequestId::Int(251), "item/tool/requestUserInput", params); + let current = event(&mut rx).await; + event(&mut rx).await; + assert_ne!( + current["requestId"], original["requestId"], + "a new native operation must not accept an old browser answer" + ); + let answer = |key: &Value| { + serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":key,"answers":{"answer":"exact value"}})).unwrap() + }; + assert!( + !state + .try_handle_question_respond(answer(&original["requestId"])) + .await + ); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert_eq!(state.awaited_control_count("thread-1"), 1); + assert!( + state + .try_handle_question_respond(answer(¤t["requestId"])) + .await + ); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":251,"result":{"answers":{"answer":{"answers":["exact value"]}}}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_pending_answer_cannot_cross_native_client_incarnations() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(253), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"answer","question":"Old native client"}]})); + let original = event(&mut rx).await; + event(&mut rx).await; + let (transport, new_peer) = freshell_codex::new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + state + .sessions + .lock() + .await + .get_mut("thread-1") + .unwrap() + .client = Arc::new(client); + assert!(!state.try_handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":original["requestId"],"answers":{"answer":"stale answer"}})).unwrap()).await, "an old question must not be written into the new native client"); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert!( + tokio::time::timeout(Duration::from_millis(30), new_peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + new_peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_retired_turn_cannot_register_a_new_awaited_question() { + let (state, peer, mut rx) = runtime().await; + *state + .sessions + .lock() + .await + .get("thread-1") + .unwrap() + .retired_turn_id + .lock() + .unwrap() = Some("retired-turn".into()); + peer.request_client(&RequestId::Int(281), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"retired-turn","questions":[{"id":"answer","question":"Retired question"}]})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("a retired turn's late control must receive a native refusal") + .unwrap(); + assert_eq!(response["id"], 281); + assert!(response.get("error").is_some()); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert_eq!(state.awaited_control_count("thread-1"), 0); + assert!(state.retained_control_events("thread-1").await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_control_diagnostics_keep_locators_and_severity_without_secret_payloads() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let (events, _guard) = capture(); + let (state, peer, mut rx) = runtime().await; + let secret = "sensitive-answer-fixture"; + peer.request_client(&RequestId::Int(261), "mcpServer/elicitation/request", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"form-item","serverName":"contract","mode":"form","requestedSchema":{"type":"object","properties":{"token":{"type":"object","default":secret}}}})); + assert_eq!( + peer.next_raw_frame().await.unwrap()["result"]["action"], + "decline" + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.request_client(&RequestId::Int(262), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"question-item","questions":[{"id":"token","question":"Secret?","isSecret":true,"isOther":false,"options":[{"label":"offered","description":"A choice"}]}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + assert!(!state.try_handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"token":secret}})).unwrap()).await); + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + let invalid = with_message(&events, "freshagent.codex.invalid_control_answer"); + assert_eq!(invalid.len(), 1); + assert_eq!(invalid[0].level, tracing::Level::WARN); + assert_eq!(invalid[0].field("native_item_id"), Some("question-item")); + let unsupported = with_message(&events, "freshagent.codex.unsupported_request"); + assert_eq!(unsupported.len(), 1); + assert_eq!(unsupported[0].level, tracing::Level::WARN); + assert_eq!(unsupported[0].field("native_turn_id"), Some("turn-1")); + assert!(!format!("{:?}", events.lock().unwrap()).contains(secret)); + peer.disconnect(); + state.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/codex/elicitation.rs b/crates/freshell-freshagent/src/codex/elicitation.rs new file mode 100644 index 000000000..7278ff36f --- /dev/null +++ b/crates/freshell-freshagent/src/codex/elicitation.rs @@ -0,0 +1,323 @@ +//! Bounded native MCP elicitation projection. No provider RPC or UI schema interpreter. +use super::*; + +pub(super) fn normalize(params: &Value) -> Option { + let mode = params.get("mode")?.as_str()?; + let mut public = json!({"mode":mode,"serverName":params.get("serverName")?.as_str()?,"message":params.get("message").and_then(Value::as_str).unwrap_or("")}); + if let Some(title) = params.get("title").and_then(Value::as_str) { + public["title"] = json!(title); + } + if mode == "url" { + public["url"] = json!(params.get("url")?.as_str()?); + public["elicitationId"] = json!(params.get("elicitationId")?.as_str()?); + return Some(public); + } + if !matches!(mode, "form" | "openai/form" | "openaiForm") { + return None; + } + let schema = params.get("requestedSchema")?; + if schema.get("type")?.as_str()? != "object" { + return None; + } + let object = schema.as_object()?; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "$schema" + | "type" + | "properties" + | "required" + | "title" + | "description" + | "additionalProperties" + ) + }) || schema + .get("additionalProperties") + .is_some_and(|value| !value.is_boolean()) + { + return None; + } + let properties = schema.get("properties")?.as_object()?; + if properties.len() > 32 { + return None; + } + if schema + .get("required") + .is_some_and(|value| !value.is_null() && !value.is_array()) + { + return None; + } + let required = schema.get("required").and_then(Value::as_array); + if required.is_some_and(|ids| { + ids.iter() + .any(|id| id.as_str().is_none_or(|id| !properties.contains_key(id))) + }) { + return None; + } + let mut fields = Vec::new(); + for (id, raw) in properties { + let kind = raw.get("type")?.as_str()?; + if !matches!(kind, "string" | "number" | "integer" | "boolean" | "array") { + return None; + } + if !representable_field(raw, kind) { + return None; + } + if let Some(default) = raw.get("default").filter(|value| !value.is_null()) { + let valid = match kind { + "string" => default.is_string(), + "boolean" => default.is_boolean(), + "number" => default.is_number(), + "integer" => default.as_f64().is_some_and(|value| value.fract() == 0.0), + "array" => default + .as_array() + .is_some_and(|values| values.iter().all(Value::is_string)), + _ => false, + }; + if !valid { + return None; + } + } + let mut field = json!({"id":id,"type":kind,"title":raw.get("title").and_then(Value::as_str).unwrap_or(id),"required":required.is_some_and(|ids| ids.iter().any(|value|value.as_str()==Some(id))),"secret":raw["writeOnly"]==true || raw["isSecret"]==true}); + let choices = if kind == "array" { &raw["items"] } else { raw }; + if let Some(values) = choices.get("enum").and_then(Value::as_array) { + if values.iter().any(|v| !v.is_string()) { + return None; + } + field["choices"] = json!(values.iter().enumerate().map(|(index,value)| json!({"value":value,"label":choices.get("enumNames").and_then(Value::as_array).and_then(|names|names.get(index)).and_then(Value::as_str).map(Value::from).unwrap_or_else(||value.clone())})).collect::>()); + } else if let Some(values) = choices + .get(if kind == "array" { "anyOf" } else { "oneOf" }) + .and_then(Value::as_array) + { + let values = values.iter().map(|value| Some(json!({"value":value.get("const")?.as_str()?,"label":value.get("title")?.as_str()?}))).collect::>>()?; + field["choices"] = json!(values); + } else if kind == "array" { + return None; + } + for key in [ + "description", + "default", + "minimum", + "maximum", + "minLength", + "maxLength", + "minItems", + "maxItems", + "format", + ] { + if key == "default" && field["secret"] == true { + continue; + } + if let Some(value) = raw.get(key).filter(|v| !v.is_null()) { + field[key] = value.clone(); + } + } + if raw.get("pattern").is_some() { + return None; + } + fields.push(field); + } + public["fields"] = json!(fields); + Some(public) +} + +fn representable_field(raw: &Value, kind: &str) -> bool { + let Some(object) = raw.as_object() else { + return false; + }; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "type" + | "title" + | "description" + | "default" + | "writeOnly" + | "isSecret" + | "enum" + | "enumNames" + | "oneOf" + | "items" + | "minimum" + | "maximum" + | "minLength" + | "maxLength" + | "minItems" + | "maxItems" + | "format" + ) + }) { + return false; + } + for key in ["title", "description"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return false; + } + } + for key in ["writeOnly", "isSecret"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_boolean()) + { + return false; + } + } + for key in ["minimum", "maximum"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_number()) + { + return false; + } + } + for key in ["minLength", "maxLength", "minItems", "maxItems"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && value.as_u64().is_none()) + { + return false; + } + } + if raw.get("format").is_some_and(|value| { + !value.is_null() && !matches!(value.as_str(), Some("email" | "uri" | "date" | "date-time")) + }) { + return false; + } + if kind == "array" { + let Some(items) = raw["items"].as_object() else { + return false; + }; + if items + .keys() + .any(|key| !matches!(key.as_str(), "type" | "enum" | "anyOf")) + || items + .get("type") + .is_some_and(|value| value.as_str() != Some("string")) + { + return false; + } + } + let choices = if kind == "array" { &raw["items"] } else { raw }; + for key in ["enum", "enumNames"] { + if choices.get(key).is_some_and(|value| { + !value.is_null() + && value + .as_array() + .is_none_or(|values| values.iter().any(|value| !value.is_string())) + }) { + return false; + } + } + let choice_key = if kind == "array" { "anyOf" } else { "oneOf" }; + if choices + .get(choice_key) + .is_some_and(|value| !value.is_null() && !value.is_array()) + { + return false; + } + + if let Some(options) = choices + .get(if kind == "array" { "anyOf" } else { "oneOf" }) + .and_then(Value::as_array) + { + if options.iter().any(|value| { + value.as_object().is_none_or(|object| { + object + .keys() + .any(|key| !matches!(key.as_str(), "const" | "title")) + }) + }) { + return false; + } + } + true +} + +pub(super) fn response( + params: &Value, + action: &freshell_protocol::ElicitationAction, + content: &Option>, +) -> Result { + use freshell_protocol::ElicitationAction; + if *action != ElicitationAction::Accept || params["mode"] == "url" { + if content.is_some() { + return Err("This elicitation action requires empty content."); + } + return Ok(json!({"action":action,"content":null})); + } + let public = normalize(params).ok_or("This elicitation form is no longer supported.")?; + let fields = public["fields"] + .as_array() + .ok_or("This request has no form fields.")?; + let content = content + .as_ref() + .ok_or("Complete the required form fields before continuing.")?; + if content + .keys() + .any(|key| !fields.iter().any(|field| field["id"].as_str() == Some(key))) + { + return Err("The answer contains a field not requested by the server."); + } + for field in fields { + let id = field["id"].as_str().expect("normalized id"); + let Some(value) = content.get(id) else { + if field["required"] == true { + return Err("Complete every required form field."); + } + continue; + }; + let value = serde_json::to_value(value).map_err(|_| "The answer could not be encoded.")?; + let kind = field["type"].as_str().expect("normalized type"); + let valid = match kind { + "string" => value.as_str().is_some_and(|s| { + within( + s.chars().count() as f64, + &field["minLength"], + &field["maxLength"], + ) && allowed(&value, field) + && format_matches(s, field["format"].as_str()) + }), + "number" | "integer" => value.as_f64().is_some_and(|n| { + n.is_finite() + && (kind != "integer" || n.fract() == 0.0) + && within(n, &field["minimum"], &field["maximum"]) + }), + "boolean" => value.is_boolean(), + "array" => value.as_array().is_some_and(|values| { + within(values.len() as f64, &field["minItems"], &field["maxItems"]) + && values.iter().all(|v| v.is_string() && allowed(v, field)) + }), + _ => false, + }; + if !valid { + return Err("An answer does not match its requested type, range or choices."); + } + } + Ok(json!({"action":action,"content":content})) +} + +fn within(value: f64, minimum: &Value, maximum: &Value) -> bool { + minimum.as_f64().is_none_or(|min| value >= min) + && maximum.as_f64().is_none_or(|max| value <= max) +} +fn allowed(value: &Value, field: &Value) -> bool { + field["choices"] + .as_array() + .is_none_or(|choices| choices.iter().any(|choice| choice["value"] == *value)) +} +fn format_matches(text: &str, format: Option<&str>) -> bool { + match format { + Some("email") => text.split_once('@').is_some_and(|(local, host)| { + !local.is_empty() && !host.is_empty() && !text.contains(char::is_whitespace) + }), + Some("uri") => text.split_once(':').is_some_and(|(scheme, rest)| { + !scheme.is_empty() && !rest.is_empty() && !text.contains(char::is_whitespace) + }), + // Native date formats are displayed as input hints; their string bounds + // remain enforced here. No locale-dependent date coercion takes place. + _ => true, + } +} diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index f3dc247f3..d3ff3ca87 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -193,6 +193,29 @@ impl CodexProjection { self.thread_id == id } + /// Exact native spawn membership, without starting or resuming a child writer. + pub(super) fn knows_child_thread(&self, id: &str) -> bool { + self.turns + .iter() + .map(|turn| &turn.raw) + .chain( + self.last_native + .iter() + .flat_map(|native| native["thread"]["turns"].as_array().into_iter().flatten()), + ) + .flat_map(|turn| turn["items"].as_array().into_iter().flatten()) + .any(|item| { + item["type"] == "collabAgentToolCall" + && item["tool"] == "spawnAgent" + && item["senderThreadId"] == self.thread_id + && item["receiverThreadIds"] + .as_array() + .is_some_and(|children| { + children.iter().any(|child| child.as_str() == Some(id)) + }) + }) + } + pub(super) fn matches_fence(&self, fence: &SnapshotReadFence) -> bool { fence.projection_id == self.id && fence.owner_epoch == self.owner_epoch diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index da8788075..4ef939d3f 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -111,6 +111,8 @@ pub enum ClientMessage { FreshAgentApprovalRespond(FreshAgentApprovalRespond), #[serde(rename = "freshAgent.question.respond")] FreshAgentQuestionRespond(FreshAgentQuestionRespond), + #[serde(rename = "freshAgent.elicitation.respond")] + FreshAgentElicitationRespond(FreshAgentElicitationRespond), #[serde(rename = "freshAgent.kill")] FreshAgentKill(FreshAgentKill), #[serde(rename = "freshAgent.recovery.stop")] @@ -181,7 +183,8 @@ pub const CLIENT_MESSAGE_TYPES: [&str; 42] = [ /// Extension client→server discriminants declared beyond the generated /// inventory. The process-only Codex recovery stop is an additive extension; /// the generated frozen inventory remains unchanged. -pub const EXTENSION_CLIENT_MESSAGE_TYPES: [&str; 1] = ["freshAgent.recovery.stop"]; +pub const EXTENSION_CLIENT_MESSAGE_TYPES: [&str; 2] = + ["freshAgent.recovery.stop", "freshAgent.elicitation.respond"]; // --- hello ------------------------------------------------------------------ @@ -1090,6 +1093,37 @@ pub struct FreshAgentQuestionRespond { pub cwd: Option, } +/// An MCP elicitation reply is a native control response, never model text. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentElicitationRespond { + pub provider: AgentProvider, + pub session_id: String, + pub session_type: SessionType, + pub request_id: StringOrNumber, + pub action: ElicitationAction, + pub content: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ElicitationAction { + Accept, + Decline, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ElicitationValue { + String(String), + Number(serde_json::Number), + Boolean(bool), + Strings(Vec), +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentKill { diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 9fd53cbe4..ae710aa67 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1303,6 +1303,8 @@ pub struct FreshAgentAttentionMark { pub last_turn_complete_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_waiting_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_question_available_at: Option, } /// One `ready.runtimeOwners` replay record (kata b8ke reconnect-owner diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 60f22b446..d5268c10e 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -359,6 +359,20 @@ impl HostedFreshAgentProxy { ) .await; } + HostedFreshAgentCommand::Elicitation(message) => { + if message.provider != AgentProvider::Codex { + self.error_event_code( + &message.provider, + &message.session_id, + message.session_type, + "UNSUPPORTED_CAPABILITY", + "MCP elicitation is not supported for this provider.", + ); + return; + } + self.resolve_decision(message.provider, message.session_id, message.session_type, decision_id(message.request_id), + serde_json::json!({"elicitation":{"action":message.action,"content":message.content}})).await; + } HostedFreshAgentCommand::Question(message) => { self.resolve_decision( message.provider, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 394282cc3..dd2cdd574 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -2197,3 +2197,77 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() assert_eq!(recovered.effort.as_deref(), Some("high")); assert_eq!(recovered.permission_mode.as_deref(), Some("default")); } + +#[tokio::test] +async fn task5_hosted_elicitation_for_other_providers_is_explicitly_unsupported() { + let dir = tempfile::tempdir().unwrap(); + let (proxy, mut receiver) = configure_proxy( + &dir.path().join("absent-host.sock"), + HashMap::from([( + ("claude".into(), "claude-thread".into()), + SoulId::parse("claude-soul").unwrap(), + )]), + ); + proxy.handle(HostedFreshAgentCommand::Elicitation(serde_json::from_value(serde_json::json!({"provider":"claude","sessionType":"freshclaude","sessionId":"claude-thread","requestId":"41","action":"decline","content":null})).unwrap())).await; + let frame: serde_json::Value = serde_json::from_str(&receiver.try_recv().unwrap()).unwrap(); + assert_eq!(frame["event"]["code"], "UNSUPPORTED_CAPABILITY"); + assert!(frame["event"]["message"] + .as_str() + .unwrap() + .contains("elicitation")); +} + +#[tokio::test] +async fn task5_hosted_elicitation_reuses_resolve_with_tagged_typed_content_and_opaque_ids() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("elicitation.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let mut ids = Vec::new(); + while ids.len() < 2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 77, + installation_id: InstallationId::new(), + }, + AdminCommand::FreshAgentResolve(resolve) => { + assert_eq!(resolve.soul_id.as_str(), "codex-soul"); + assert_eq!(resolve.expected_control_epoch, Some(77)); + assert_eq!( + resolve.decision, + serde_json::json!({"elicitation":{"action":"accept","content":{"count":3,"enabled":false}}}) + ); + ids.push(resolve.decision_id); + AdminResult::FreshAgentCommand { + state: freshell_runtime_protocol::CommandState::ProviderAcked, + } + } + other => panic!("unexpected control opcode: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + ids + }); + let (proxy, mut receiver) = configure_proxy( + &socket, + HashMap::from([( + ("codex".into(), "public-thread".into()), + SoulId::parse("codex-soul").unwrap(), + )]), + ); + for id in ["41", "\"41\""] { + proxy.clone().handle(HostedFreshAgentCommand::Elicitation(serde_json::from_value(serde_json::json!({"provider":"codex","sessionType":"freshcodex","sessionId":"public-thread","requestId":id,"action":"accept","content":{"count":3,"enabled":false}})).unwrap())).await; + } + assert_eq!(settled(server).await, vec!["41", "\"41\""]); + assert!(receiver.try_recv().is_err()); +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index a8f1e1258..9d3498559 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -16,9 +16,9 @@ use freshell_freshagent::rollback_record::{RollbackDirection, RollbackModeReq, R use freshell_freshagent::{FreshAgentState, FreshClaudeState, FreshCodexState, FreshOpencodeState}; use freshell_protocol::{ AgentProvider, FreshAgentApprovalRespond, FreshAgentCheckpointIntent, FreshAgentCompact, - FreshAgentConfigure, FreshAgentCreate, FreshAgentFork, FreshAgentInterrupt, FreshAgentKill, - FreshAgentQuestionRespond, FreshAgentSend, FreshAgentSendSettings, ServerMessage, - SessionLocator, SessionType, StringOrNumber, + FreshAgentConfigure, FreshAgentCreate, FreshAgentElicitationRespond, FreshAgentFork, + FreshAgentInterrupt, FreshAgentKill, FreshAgentQuestionRespond, FreshAgentSend, + FreshAgentSendSettings, ServerMessage, SessionLocator, SessionType, StringOrNumber, }; use freshell_runtime_protocol::{ AgentEvent, FreshAgentCapture, FreshAgentFixtureTransport, FreshAgentLaunchSpec, @@ -744,6 +744,40 @@ impl FreshAgentTransport for HostedTransport { acceptance_ambiguous: false, })?; let request_id = StringOrNumber::Str(decision_id.to_string()); + if let Some(elicitation) = decision.get("elicitation") { + let message = FreshAgentElicitationRespond { + provider: self.provider_wire(), + session_id, + session_type: self.session_type(), + request_id, + cwd: None, + action: serde_json::from_value(elicitation["action"].clone()).map_err(|_| { + DispatchFailure { + message: "invalid elicitation action".into(), + acceptance_ambiguous: false, + } + })?, + content: serde_json::from_value(elicitation["content"].clone()).map_err(|_| { + DispatchFailure { + message: "invalid elicitation content".into(), + acceptance_ambiguous: false, + } + })?, + }; + return match &self.state { + ProviderState::Codex(state) => { + if state.try_handle_elicitation_respond(message).await { + Ok(()) + } else { + Err(DispatchFailure { message:"Codex did not accept this elicitation response; the request remains pending.".into(), acceptance_ambiguous:false }) + } + } + _ => Err(DispatchFailure { + message: "provider has no elicitation flow".into(), + acceptance_ambiguous: false, + }), + }; + } if let Some(answers) = decision.get("answers").and_then(Value::as_object) { let answers = answers .iter() @@ -759,7 +793,11 @@ impl FreshAgentTransport for HostedTransport { }; match &self.state { ProviderState::Claude(state) => state.handle_question_respond(message).await, - ProviderState::Codex(state) => state.handle_question_respond(message).await, + ProviderState::Codex(state) => { + if !state.try_handle_question_respond(message).await { + return Err(DispatchFailure {message:"Codex did not accept this question answer; the request remains pending.".into(),acceptance_ambiguous:false}); + } + } ProviderState::Opencode { .. } => { return Err(DispatchFailure { message: "provider has no question flow".into(), @@ -778,7 +816,11 @@ impl FreshAgentTransport for HostedTransport { }; match &self.state { ProviderState::Claude(state) => state.handle_approval_respond(message).await, - ProviderState::Codex(state) => state.handle_approval_respond(message).await, + ProviderState::Codex(state) => { + if !state.try_handle_approval_respond(message).await { + return Err(DispatchFailure {message:"Codex did not accept this approval decision; the request remains pending.".into(),acceptance_ambiguous:false}); + } + } ProviderState::Opencode { .. } => { return Err(DispatchFailure { message: "provider has no approval flow".into(), @@ -1300,6 +1342,7 @@ fn spawn_broadcast_bridge( } if event_type.contains("permission.request") || event_type.contains("question.request") + || event_type == "freshAgent.elicitation.request" { if let Some(request_id) = event.get("requestId").and_then(|value| match value { Value::String(value) => Some(value.clone()), @@ -1503,6 +1546,139 @@ mod tests { static OPENCODE_SNAPSHOT_ENV_LOCK: Mutex<()> = Mutex::const_new(()); + async fn task5_durable_native_control( + method: &str, + params: Value, + event_type: &str, + invalid_answer: Value, + valid_answer: Value, + ) { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = SnapshotProviderEnv( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + let exec_dir = tempfile::tempdir_in( + std::env::var_os("FRESHELL_TEST_EXECUTABLE_TMP_DIR") + .expect("owned executable fixture root"), + ) + .unwrap(); + let wrapper = exec_dir.path().join("codex.mjs"); + std::fs::write( + &wrapper, + format!( + "#!/usr/bin/env node\nimport {};\n", + serde_json::to_string(&fixture).unwrap() + ), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::env::set_var("CODEX_CMD", wrapper); + let codex_home = dir.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).unwrap(); + std::env::set_var("CODEX_HOME", codex_home); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":41,"method":method,"params":params}}}).to_string()); + let transport = HostedTransport::new(FreshProvider::Codex).await; + let mut profile = parity_profile(FreshProvider::Codex); + profile.cwd = dir.path().to_string_lossy().into(); + profile.native_session_id = None; + profile.session_ref = None; + profile.plugins = None; + profile.provider_launch_context = None; + profile.provider_secret_references.clear(); + profile.model = Some("gpt-5.3-codex-spark".into()); + profile.model_selection = None; + profile.effort = None; + profile.permission_mode = Some("never".into()); + let mut diagnostic = transport.provider_frames.subscribe(); + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport.clone()) + .await + .unwrap_or_else(|error| { + let mut frames = Vec::new(); + while let Ok(frame) = diagnostic.try_recv() { + frames.push(frame); + } + panic!("hosted native fixture startup failed: {error:?}; {frames:?}") + }); + actor + .dispatch(RequestId::parse("ask-native").unwrap(), "ask".into(), None) + .await + .unwrap_or_else(|error| { + let mut frames = Vec::new(); + while let Ok(frame) = diagnostic.try_recv() { + frames.push(frame); + } + panic!("hosted native fixture send failed: {error:?}; {frames:?}") + }); + let pending=tokio::time::timeout(Duration::from_secs(5),async { + loop { + if let Some(event)=actor.read_events(0,256).await.events.into_iter().find(|entry| matches!(&entry.event, AgentEvent::PermissionRequested {payload,..} if payload["type"]==event_type)) {break event;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("native elicitation must enter the durable pending-decision path before a user click"); + let AgentEvent::PermissionRequested { + decision_id, + payload, + } = pending.event + else { + unreachable!() + }; + assert_eq!(payload["providerRequest"]["id"], 41); + // Reading from a reconnect's cursor yields the exact same public/native IDs. + assert!(actor.read_events(0,256).await.events.iter().any(|entry| matches!(&entry.event,AgentEvent::PermissionRequested{decision_id:id,payload:p} if id==&decision_id && p==&payload))); + let invalid = actor.resolve_permission(&decision_id, invalid_answer).await; + assert!( + invalid.is_err(), + "an invalid native answer must not resolve its durable decision" + ); + assert!(!actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event,AgentEvent::DecisionResolved{decision_id:id} if id==&decision_id))); + actor + .resolve_permission(&decision_id, valid_answer.clone()) + .await + .unwrap(); + actor + .resolve_permission(&decision_id, valid_answer) + .await + .unwrap(); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task5_durable_native_elicitation_survives_reconnect_and_invalid_answer() { + task5_durable_native_control("mcpServer/elicitation/request",json!({"serverName":"contract","mode":"form","message":"Hosted form","requestedSchema":{"type":"object","properties":{"count":{"type":"integer","minimum":1}},"required":["count"]}}),"freshAgent.elicitation.request",json!({"elicitation":{"action":"accept","content":{"count":0}}}),json!({"elicitation":{"action":"accept","content":{"count":2}}})).await; + } + #[tokio::test] + async fn task5_durable_native_question_keeps_a_rejected_answer_pending() { + task5_durable_native_control("item/tool/requestUserInput",json!({"isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Pick","isOther":false,"options":[{"label":"A","description":"Alpha"}]}]}),"freshAgent.question.request",json!({"answers":{"choice":"unoffered"}}),json!({"answers":{"choice":"A"}})).await; + } + #[tokio::test] + async fn task5_durable_native_approval_keeps_an_unoffered_decision_pending() { + task5_durable_native_control( + "item/commandExecution/requestApproval", + json!({"availableDecisions":["cancel"]}), + "freshAgent.permission.request", + json!({"behavior":"allow"}), + json!({"nativeDecision":"cancel"}), + ) + .await; + } + #[tokio::test] async fn managed_provider_claude_native_identity_uses_actual_adapter_event_name() { let (bus, rx) = broadcast::channel(8); diff --git a/crates/freshell-ws/src/hosted_fresh_agent.rs b/crates/freshell-ws/src/hosted_fresh_agent.rs index bee0c5cf7..bee4e441d 100644 --- a/crates/freshell-ws/src/hosted_fresh_agent.rs +++ b/crates/freshell-ws/src/hosted_fresh_agent.rs @@ -6,9 +6,9 @@ use freshell_protocol::{ ClientMessage, FreshAgentApprovalRespond, FreshAgentAttach, FreshAgentCompact, - FreshAgentConfigure, FreshAgentCreate, FreshAgentFork, FreshAgentInterrupt, FreshAgentKill, - FreshAgentQuestionRespond, FreshAgentRecoveryStop, FreshAgentRedo, FreshAgentSend, - FreshAgentUndo, + FreshAgentConfigure, FreshAgentCreate, FreshAgentElicitationRespond, FreshAgentFork, + FreshAgentInterrupt, FreshAgentKill, FreshAgentQuestionRespond, FreshAgentRecoveryStop, + FreshAgentRedo, FreshAgentSend, FreshAgentUndo, }; use futures::future::BoxFuture; use std::sync::{Arc, OnceLock}; @@ -24,6 +24,7 @@ pub enum HostedFreshAgentCommand { RecoveryStop(FreshAgentRecoveryStop), Approval(FreshAgentApprovalRespond), Question(FreshAgentQuestionRespond), + Elicitation(FreshAgentElicitationRespond), Compact(FreshAgentCompact), Fork(FreshAgentFork), Undo(FreshAgentUndo), @@ -58,6 +59,7 @@ pub(crate) fn hosted_route(message: &ClientMessage) -> HostedRoute { ClientMessage::FreshAgentRecoveryStop(value) => Command::RecoveryStop(value.clone()), ClientMessage::FreshAgentApprovalRespond(value) => Command::Approval(value.clone()), ClientMessage::FreshAgentQuestionRespond(value) => Command::Question(value.clone()), + ClientMessage::FreshAgentElicitationRespond(value) => Command::Elicitation(value.clone()), ClientMessage::FreshAgentCompact(value) => Command::Compact(value.clone()), ClientMessage::FreshAgentFork(value) => Command::Fork(value.clone()), ClientMessage::FreshAgentUndo(value) => Command::Undo(value.clone()), diff --git a/crates/freshell-ws/src/hosted_fresh_agent_tests.rs b/crates/freshell-ws/src/hosted_fresh_agent_tests.rs index def591995..8b509d912 100644 --- a/crates/freshell-ws/src/hosted_fresh_agent_tests.rs +++ b/crates/freshell-ws/src/hosted_fresh_agent_tests.rs @@ -20,6 +20,9 @@ fn fresh_agent_sample(kind: &str) -> serde_json::Value { "freshAgent.compact" => json!({"requestId": "compact-1"}), "freshAgent.approval.respond" => json!({"requestId": 7, "decision": {"allow": true}}), "freshAgent.question.respond" => json!({"requestId": "q-1", "answers": {"q": "a"}}), + "freshAgent.elicitation.respond" => { + json!({"requestId": "41", "action": "decline", "content": null}) + } "freshAgent.kill" => json!({}), "freshAgent.recovery.stop" => json!({"requestId": "stop-1"}), "freshAgent.fork" => json!({"requestId": "fork-1"}), @@ -49,6 +52,7 @@ fn command_wire_type(command: &HostedFreshAgentCommand) -> &'static str { HostedFreshAgentCommand::RecoveryStop(_) => "freshAgent.recovery.stop", HostedFreshAgentCommand::Approval(_) => "freshAgent.approval.respond", HostedFreshAgentCommand::Question(_) => "freshAgent.question.respond", + HostedFreshAgentCommand::Elicitation(_) => "freshAgent.elicitation.respond", HostedFreshAgentCommand::Compact(_) => "freshAgent.compact", HostedFreshAgentCommand::Fork(_) => "freshAgent.fork", HostedFreshAgentCommand::Undo(_) => "freshAgent.undo", diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 0e7296e75..ca4cd589a 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -656,9 +656,13 @@ pub async fn build_handshake_with_capabilities( fresh_agent_attention_replay_v1, } = capabilities; let boot_id = state.boot_id.as_ref().clone(); - let fresh_agent_attention = fresh_agent_attention_replay_v1 - .then(|| state.fresh_claude.attention_marks()) - .filter(|marks| !marks.is_empty()); + let fresh_agent_attention = if fresh_agent_attention_replay_v1 { + let mut marks = state.fresh_claude.attention_marks(); + marks.extend(state.fresh_codex.attention_marks().await); + (!marks.is_empty()).then_some(marks) + } else { + None + }; if let Some(marks) = fresh_agent_attention.as_ref() { tracing::debug!(count = marks.len(), "ws.handshake.attention_marks"); } diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 1410d3243..ca34e2f09 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -2178,6 +2178,14 @@ async fn handle_client_text( } true } + ClientMessage::FreshAgentElicitationRespond(respond) => { + let fresh_codex = state.fresh_codex.clone(); + tokio::spawn( + async move { fresh_codex.handle_elicitation_respond(respond).await } + .instrument(tracing::Span::current()), + ); + true + } ClientMessage::FreshAgentQuestionRespond(respond) => { if respond.provider == freshell_protocol::AgentProvider::Codex { let fresh_codex = state.fresh_codex.clone(); @@ -9558,6 +9566,13 @@ pub(crate) fn fresh_agent_control_refusal(message: &ClientMessage) -> Option ( + m.provider != AgentProvider::Codex, + "MCP elicitation is", + m.provider, + m.session_id.as_str(), + m.session_type, + ), ClientMessage::FreshAgentQuestionRespond(m) => ( !matches!(m.provider, AgentProvider::Claude | AgentProvider::Codex), "Questions are", diff --git a/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs new file mode 100644 index 000000000..14d754ee9 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs @@ -0,0 +1,118 @@ +mod common; +use common::*; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio_tungstenite::tungstenite::Message; +static ENV: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +struct Environment(Vec<(&'static str, Option)>); +impl Drop for Environment { + fn drop(&mut self) { + for (key, value) in &self.0 { + if let Some(value) = value { + std::env::set_var(key, value) + } else { + std::env::remove_var(key) + } + } + } +} + +async fn ready(url: &str) -> Value { + let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); + ws.send(Message::Text(json!({"type":"hello","token":AUTH_TOKEN,"protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"capabilities":{"freshAgentAttentionReplayV1":true}}).to_string())).await.unwrap(); + let frame = tokio::time::timeout(FRAME_BUDGET, ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let Message::Text(raw) = frame else { + panic!("ready text") + }; + serde_json::from_str(&raw).unwrap() +} +async fn event(ws: &mut TestWs, kind: &str) -> Value { + loop { + let value = next_frame_of_type(ws, "freshAgent.event").await; + if value["event"]["type"] == kind { + return value["event"].clone(); + } + } +} +async fn run_replay(async_question: bool) { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var("CODEX_HOME", dir.path().join("codex-home")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"overrides":{"turn/start":{"result":{"turn":{"id":"replay-turn","status":"inProgress"}}}},"serverRequestsByPrompt":{"ask":{"id":51,"method":"item/tool/requestUserInput","params":{"isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Which?","options":null}]}}},"notificationsAfterMethods":{"thread/read":[{"method":"item/completed","params":{"threadId":"thread-new-1","turnId":"replay-turn","item":{"id":"async-replay","type":"agentMessage","text":"Work continues","delivery":"async","questions":[{"title":"Where?","options":["Staging"]}]}}}]}}).to_string()); + let (url, _registry, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut first, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"create-replay","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut first, "freshAgent.created").await; + let session_id = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"send-replay","provider":"codex","sessionType":"freshcodex","sessionId":session_id,"text":"ask"})).unwrap()).await; + let request = event(&mut first, "freshAgent.question.request").await; + let waiting = event(&mut first, "freshAgent.turn.waiting").await; + let available = if async_question { + state + .fresh_codex + .get_snapshot(session_id, None) + .await + .unwrap(); + Some(event(&mut first, "freshAgent.question.available").await) + } else { + None + }; + let before = ready(&url).await; + state.fresh_codex.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":session_id,"requestId":request["requestId"],"answers":{"choice":"answer"}})).unwrap()).await; + let after = ready(&url).await; + state.fresh_codex.shutdown().await; + let find = |value: &Value| { + value["freshAgentAttention"] + .as_array() + .and_then(|marks| marks.iter().find(|mark| mark["sessionId"] == session_id)) + .cloned() + .unwrap_or_else(|| json!(null)) + }; + if let Some(available) = available { + assert_eq!( + find(&before)["lastQuestionAvailableAt"], + available["at"], + "async question replay must be separate from awaited state: {before}" + ); + assert_eq!(find(&after)["lastQuestionAvailableAt"], available["at"]); + } else { + assert_eq!( + find(&before)["lastWaitingAt"], + waiting["at"], + "awaited Codex control must seed ready replay: {before}" + ); + assert!(find(&after).get("lastWaitingAt").is_none()); + } +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task5_ready_replays_awaited_codex_controls_only_while_pending() { + run_replay(false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task5_ready_separates_nonblocking_async_question_attention() { + run_replay(true).await; +} diff --git a/crates/freshell-ws/tests/freshagent_control_reply.rs b/crates/freshell-ws/tests/freshagent_control_reply.rs index 98ed6fcc5..874fa645d 100644 --- a/crates/freshell-ws/tests/freshagent_control_reply.rs +++ b/crates/freshell-ws/tests/freshagent_control_reply.rs @@ -585,3 +585,19 @@ async fn handled_control_frames_still_reach_their_dispatch_arms() { let frame = next_frame_of_type(&mut ws, "pong").await; assert!(frame["timestamp"].is_string(), "pong carries a timestamp"); } + +#[tokio::test] +async fn task5_elicitation_typed_ingress_dispatches_codex_and_refuses_other_providers() { + let (url, _registry) = spawn_server().await; + let (mut ws, _) = connect_and_capture_inventory(&url).await; + for (provider, session_type, expected) in [ + ("codex", "freshcodex", "INVALID_SESSION_ID"), + ("claude", "freshclaude", "UNSUPPORTED_CAPABILITY"), + ("opencode", "freshopencode", "UNSUPPORTED_CAPABILITY"), + ] { + send_json(&mut ws,serde_json::json!({"type":"freshAgent.elicitation.respond","provider":provider,"sessionType":session_type,"sessionId":"absent-native-thread","requestId":"41","action":"decline","content":null})).await; + let frame = next_frame_of_type(&mut ws, "freshAgent.event").await; + assert_eq!(frame["provider"], provider); + assert_eq!(frame["event"]["code"], expected); + } +} diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index 75c4ce419..c4de7cb66 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -335,6 +335,7 @@ export const FreshAgentPendingApprovalSchema = z.object({ blockedPath: z.string().optional(), decisionReason: z.string().optional(), input: z.record(z.string(), z.unknown()).optional(), + native: z.record(z.string(), z.unknown()).optional(), // Claude only: the CLI's own suggestions for not asking again. suggestions: z.array(FreshAgentPermissionUpdateSchema).optional(), providerRequest: z.record(z.string(), z.unknown()).optional(), @@ -349,14 +350,39 @@ export const FreshAgentQuestionDefinitionSchema = z.object({ description: z.string(), }).strict()).optional(), multiSelect: z.boolean().optional(), + isOther: z.boolean().optional(), + isSecret: z.boolean().optional(), }).strict() export const FreshAgentPendingQuestionSchema = z.object({ requestId: FreshAgentRequestIdSchema, + isBlocking: z.boolean().optional(), questions: z.array(FreshAgentQuestionDefinitionSchema), providerRequest: z.record(z.string(), z.unknown()).optional(), }).strict() +export const FreshAgentElicitationFieldSchema = z.object({ + id: z.string().min(1), title: z.string(), description: z.string().optional(), + type: z.enum(['string', 'number', 'integer', 'boolean', 'array']), + required: z.boolean(), secret: z.boolean().optional(), + default: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(), + choices: z.array(z.object({ value: z.string(), label: z.string() }).strict()).optional(), + minimum: z.number().optional(), maximum: z.number().optional(), + minLength: z.number().int().nonnegative().optional(), maxLength: z.number().int().nonnegative().optional(), + minItems: z.number().int().nonnegative().optional(), maxItems: z.number().int().nonnegative().optional(), + format: z.string().optional(), +}).strict() +export const FreshAgentPendingElicitationSchema = z.object({ + requestId: FreshAgentRequestIdSchema, + mode: z.enum(['form', 'openai/form', 'openaiForm', 'url']), + serverName: z.string(), message: z.string(), title: z.string().optional(), + fields: z.array(FreshAgentElicitationFieldSchema).max(32).optional(), + url: z.string().optional(), elicitationId: z.string().optional(), + providerRequest: z.record(z.string(), z.unknown()).optional(), +}).strict() +export type FreshAgentPendingElicitation = z.infer +export type FreshAgentElicitationContent = Record + export const FreshAgentWorktreeSchema = z.object({ id: z.string().min(1), path: z.string().min(1), @@ -412,6 +438,7 @@ export const FreshAgentSnapshotSchema = FreshAgentThreadLocatorSchema.extend({ tokenUsage: FreshAgentTokenUsageSchema, pendingApprovals: z.array(FreshAgentPendingApprovalSchema).default([]), pendingQuestions: z.array(FreshAgentPendingQuestionSchema).default([]), + pendingElicitations: z.array(FreshAgentPendingElicitationSchema).optional(), worktrees: z.array(FreshAgentWorktreeSchema).default([]), diffs: z.array(FreshAgentDiffSummarySchema).default([]), childThreads: z.array(FreshAgentChildThreadSchema).default([]), @@ -506,6 +533,8 @@ export const FRESH_AGENT_CONTRACT_SCHEMA_NAMES = [ 'FreshAgentTranscriptPatchSchema', 'FreshAgentPendingApprovalSchema', 'FreshAgentPendingQuestionSchema', + 'FreshAgentElicitationFieldSchema', + 'FreshAgentPendingElicitationSchema', 'FreshAgentWorktreeSchema', 'FreshAgentDiffSummarySchema', 'FreshAgentChildThreadSchema', diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 9c82d6710..ca284cbe9 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -1018,6 +1018,17 @@ export const FreshAgentQuestionRespondSchema = z.object({ answers: z.record(z.string(), z.string()), }) +export const FreshAgentElicitationRespondSchema = z.object({ + type: z.literal('freshAgent.elicitation.respond'), + sessionId: z.string().min(1), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode']), + cwd: z.string().optional(), + requestId: z.union([z.string().min(1), z.number().int()]), + action: z.enum(['accept', 'decline', 'cancel']), + content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).nullable(), +}) + export const FreshAgentKillSchema = z.object({ type: z.literal('freshAgent.kill'), sessionId: z.string().min(1), @@ -1112,6 +1123,7 @@ export const FreshAgentClientMessageSchema = z.discriminatedUnion('type', [ FreshAgentCompactSchema, FreshAgentApprovalRespondSchema, FreshAgentQuestionRespondSchema, + FreshAgentElicitationRespondSchema, FreshAgentKillSchema, FreshAgentForkSchema, FreshAgentUndoSchema, @@ -1284,6 +1296,7 @@ export const ClientMessageSchema = z.discriminatedUnion('type', [ FreshAgentCompactSchema, FreshAgentApprovalRespondSchema, FreshAgentQuestionRespondSchema, + FreshAgentElicitationRespondSchema, FreshAgentKillSchema, FreshAgentForkSchema, FreshAgentUndoSchema, @@ -1317,6 +1330,7 @@ export type ReadyMessage = { sessionType?: string lastTurnCompleteAt?: number lastWaitingAt?: number + lastQuestionAvailableAt?: number }> /** kata b8ke: current runtime-owner state for every recorded * (provider, sessionId) — replayed so a device that missed a handoff diff --git a/src/components/fresh-agent/FreshAgentApprovalCard.tsx b/src/components/fresh-agent/FreshAgentApprovalCard.tsx index f8f44b6f4..1ee4c8a76 100644 --- a/src/components/fresh-agent/FreshAgentApprovalCard.tsx +++ b/src/components/fresh-agent/FreshAgentApprovalCard.tsx @@ -30,9 +30,8 @@ type FreshAgentApprovalCardProps = { * - when the CLI suggested permission updates (Claude only), the remember * button sends exactly those suggestions, each spelled out in plain language * underneath, so Claude itself stops asking; - * - `onAlwaysAllow`, when the caller provides it, adds the client-only "Always - * allow in this pane" (codex): the caller remembers the tool and - * answers later requests until the pane reloads. + * - legacy shared approvals can expose the caller's pane-local remember action. + * Native approvals expose only the decisions offered by their provider. * * Claude's ExitPlanMode request is a plan to review, not a tool call: it gets * the plan card ({@link FreshAgentPlanApprovalCard}). @@ -41,9 +40,44 @@ export function FreshAgentApprovalCard(props: FreshAgentApprovalCardProps) { if (props.approval.toolName === 'ExitPlanMode') { return } + if (props.approval.native) return return } +function NativeApprovalCard({ approval, disabled, onRespond }: FreshAgentApprovalCardProps) { + const native = approval.native ?? {} + const offered = Array.isArray(native.availableDecisions) ? native.availableDecisions : ['accept', 'decline', 'cancel'] + const supported = ['accept', 'acceptForSession', 'decline', 'cancel'] as const + const choices = approval.toolName === 'Additional permissions' ? ['accept', 'decline'] : offered + const network = native.networkApprovalContext as { host?: string; protocol?: string } | undefined + const labels = { accept: 'Allow', acceptForSession: 'Allow for this session', decline: 'Decline', cancel: 'Stop' } + const details = { + ...(native.kind ? { action: native.kind } : {}), + ...(native.command ? { command: native.command } : {}), + ...(native.cwd ? { directory: native.cwd } : {}), + ...(native.permissions ? { permissions: native.permissions } : {}), + ...(native.additionalPermissions ? { additionalPermissions: native.additionalPermissions } : {}), + ...(native.grantRoot ? { path: native.grantRoot } : {}), + } + return ( +
+

{native.kind === 'writeStdin' ? 'Write to a running command' : approval.toolName}

+ {network ?

Connect to {network.host} {network.protocol ? `using ${network.protocol}` : ''}

: null} + {approval.decisionReason ?

{approval.decisionReason}

: null} + {Object.keys(details).length ?
{JSON.stringify(details, null, 2)}
: null} + {approval.toolName === 'Additional permissions' ?

Allow the requested permissions for this turn.

: null} +
+ {supported.filter((choice) => choices.includes(choice)).map((choice) => ( + + ))} +
+ {offered.some((choice) => typeof choice !== 'string') ?

Persistent rule changes are not supported here.

: null} +
+ ) +} + function FreshAgentToolApprovalCard({ approval, disabled, onRespond, onAlwaysAllow }: FreshAgentApprovalCardProps) { const suggestionsId = useId() const toolName = approval.toolName ?? 'Tool' diff --git a/src/components/fresh-agent/FreshAgentChildObserver.tsx b/src/components/fresh-agent/FreshAgentChildObserver.tsx index 79e21240f..996da86df 100644 --- a/src/components/fresh-agent/FreshAgentChildObserver.tsx +++ b/src/components/fresh-agent/FreshAgentChildObserver.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { FreshAgentChildObservationSchema, type FreshAgentChildRead, type FreshAgentChildObservation, type FreshAgentSnapshot } from '@shared/fresh-agent-contract' import { api } from '@/lib/api' import { createLogger } from '@/lib/client-logger' +import { FreshAgentAsyncQuestionContext } from './FreshAgentItemCard.js' import { FreshAgentTranscript } from './FreshAgentTranscript.js' export type FreshAgentChildParent = Omit & { provider: 'codex'; sessionType: 'freshcodex' } @@ -62,6 +63,7 @@ export function FreshAgentChildObserver({ parent, childThreadId, onClose }: { pa const availability = ephemeral ? 'Codex does not expose history for this ephemeral child. Its activity remains in the parent conversation.' : observation?.status === 'unavailable' ? 'Child history is unavailable.' : observation?.status === 'pending' ? 'Waiting for child history to be saved.' : observation?.status === 'live' ? 'Observed live child history. Observation does not control the child.' : observation?.status === 'saved' ? 'Saved child history.' : 'Reading child history.' return createPortal(
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The modal container owns Escape and focus wrapping for its native controls, following ConfirmModal; keyboard and focus-return behavior is covered by observer tests. */}
{ if (event.key === 'Escape') { event.stopPropagation(); onClose() } if (event.key === 'Tab') { @@ -76,7 +78,7 @@ export function FreshAgentChildObserver({ parent, childThreadId, onClose }: { pa {snapshot && stale ? `Previously available content. ${availability}` : availability} {!ephemeral && observation && observation.status !== 'pending' ? : null}
- {snapshot ?
: null} + {snapshot ?
: null} , document.body, ) diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx index 4540ef25b..936253c37 100644 --- a/src/components/fresh-agent/FreshAgentComposer.tsx +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -70,6 +70,8 @@ export type FreshAgentComposerHandle = { * typed since (never overwriting it), focus, caret to end. */ restoreText: (text: string) => void + /** Replace one contextual answer line, preserving every unrelated draft line. */ + upsertTextLine: (prefix: string, text: string) => void } /** @@ -272,6 +274,16 @@ export const FreshAgentComposer = forwardRef (current.trim() ? `${value}\n${current}` : value)) focusAtEnd() }, + upsertTextLine: (prefix: string, value: string) => { + if (disabled) return + setText((current) => { + const lines = current.split('\n') + const index = lines.findIndex((line) => line.startsWith(prefix)) + if (index < 0) return current.trim() ? `${value}\n${current}` : value + return lines.flatMap((line, i) => i === index ? [value] : line.startsWith(prefix) ? [] : [line]).join('\n') + }) + focusAtEnd() + }, }), [disabled, focusAtEnd]) const chatPrefix = getCommandPrefix(text) diff --git a/src/components/fresh-agent/FreshAgentElicitationBanner.tsx b/src/components/fresh-agent/FreshAgentElicitationBanner.tsx new file mode 100644 index 000000000..7c8b9e657 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentElicitationBanner.tsx @@ -0,0 +1,107 @@ +import { useId, useState, type FormEvent } from 'react' +import type { FreshAgentElicitationContent, FreshAgentPendingElicitation } from '@shared/fresh-agent-contract' + +type Props = { + request: FreshAgentPendingElicitation + disabled?: boolean + onRespond: (action: 'accept' | 'decline' | 'cancel', content: FreshAgentElicitationContent | null) => void +} +const CONTROL = 'rounded border border-border bg-background px-2 py-1 text-sm disabled:opacity-50' +const ACTION = 'rounded-md border border-border px-3 py-1.5 text-sm disabled:opacity-50' + +export default function FreshAgentElicitationBanner({ request, disabled, onRespond }: Props) { + const formId = useId() + const [values, setValues] = useState(() => Object.fromEntries( + (request.fields ?? []) + .filter((field) => !field.secret && field.default !== undefined) + .map((field) => [field.id, field.default!]), + )) + const [error, setError] = useState('') + const set = (id: string, value: FreshAgentElicitationContent[string] | undefined) => { + setValues((previous) => { + const next = { ...previous } + const required = request.fields?.find((field) => field.id === id)?.required + const empty = value === '' || (Array.isArray(value) && value.length === 0) + if (value === undefined || (empty && !required)) delete next[id] + else next[id] = value + return next + }) + } + const submit = (event: FormEvent) => { + event.preventDefault() + if (disabled) return + if (!event.currentTarget.checkValidity()) { + event.currentTarget.reportValidity() + return + } + for (const field of request.fields ?? []) { + const value = values[field.id] + if (field.required && (value === undefined || value === '' || (Array.isArray(value) && value.length === 0))) { + setError('Complete every required field.') + return + } + if (Array.isArray(value) && ((field.minItems !== undefined && value.length < field.minItems) || (field.maxItems !== undefined && value.length > field.maxItems))) { + setError('Choose the requested number of options.') + return + } + } + setError('') + onRespond('accept', request.mode === 'url' ? null : values) + } + + return ( +
+

{request.title ?? request.serverName}

+

{request.message}

+ {request.mode === 'url' ? ( + Open {request.serverName} page + ) : (request.fields ?? []).map((field, index) => { + const id = `${formId}-${index}` + const value = values[field.id] + const numeric = field.type === 'number' || field.type === 'integer' + return ( +
+ + {field.description ?

{field.description}

: null} + {field.type === 'boolean' ? ( + + ) : field.choices ? ( + + ) : ( + set(field.id, numeric ? (event.target.value === '' ? undefined : event.target.valueAsNumber) : event.target.value)} + className={CONTROL} + /> + )} +
+ ) + })} + {error ?

{error}

: null} +
+ + + +
+
+ ) +} diff --git a/src/components/fresh-agent/FreshAgentItemCard.tsx b/src/components/fresh-agent/FreshAgentItemCard.tsx index 70458a276..e5522f9bd 100644 --- a/src/components/fresh-agent/FreshAgentItemCard.tsx +++ b/src/components/fresh-agent/FreshAgentItemCard.tsx @@ -1,14 +1,25 @@ import { createContext, memo, useContext, useMemo, useState } from 'react' import { Ban, Check, ChevronRight, Loader2, X } from 'lucide-react' +import FreshAgentQuestionBanner from './FreshAgentQuestionBanner.js' import DiffView from '@/components/fresh-agent/shared/DiffView' import { formatThoughtDuration } from '@/components/fresh-agent/shared/format-duration' import { getToolPreview } from '@/components/fresh-agent/shared/tool-preview' import { LazyMarkdown } from '@/components/markdown/LazyMarkdown' +import { asyncQuestionKey, type AsyncQuestionAnswer, type AsyncQuestionAnswerStatus } from '@/lib/fresh-agent-async-questions' import type { FreshAgentTranscriptItem } from '@shared/fresh-agent-contract' import { cn } from '@/lib/utils' import { childActivitySummary } from './shared/child-activity.js' import type { FreshAgentChildParent } from './FreshAgentChildObserver.js' +export const FreshAgentAsyncQuestionContext = createContext<{ onAnswer?: (answer: AsyncQuestionAnswer) => void; statuses?: Record }>({}) +const ASYNC_ANSWER_STATUS: Record = { + draft: 'Answer draft is in the composer', + sending: 'Sending answer as a message', + accepted: 'Answer accepted as a message', + refused: 'Answer not sent; your draft is in the composer.', + ambiguous: 'Answer delivery uncertain; review the composer before sending again.', +} + function formatJson(value: unknown): string { if (typeof value === 'string') return value try { @@ -410,10 +421,14 @@ function FreshAgentThinkingDisclosure({ text }: { text: string }) { export const FreshAgentItemCard = memo(function FreshAgentItemCard({ item, markdown = false, + onAnswerQuestion, }: { item: FreshAgentTranscriptItem markdown?: boolean + onAnswerQuestion?: (answer: AsyncQuestionAnswer) => void }) { + const asyncQuestions = useContext(FreshAgentAsyncQuestionContext) + const answerQuestion = onAnswerQuestion ?? asyncQuestions.onAnswer if (item.kind === 'text' || item.kind === 'thinking') { if (item.kind === 'thinking') { return @@ -422,7 +437,26 @@ export const FreshAgentItemCard = memo(function FreshAgentItemCard({ const questions = Array.isArray(native?.questions) ? native.questions : [] return <>{renderText(item.text, markdown)}{questions.map((question, index) => { const value = asRecord(question) - return value && typeof value.title === 'string' ?

{value.title}

{Array.isArray(value.options) ?
    {value.options.filter((option): option is string => typeof option === 'string').map((option, i) =>
  • {option}
  • )}
: null}
: null + if (!value || typeof value.title !== 'string') return null + const itemId = typeof native?.itemId === 'string' ? native.itemId : item.id + const questionId = typeof value.id === 'string' ? value.id : String(index) + const status = asyncQuestions.statuses?.[asyncQuestionKey(itemId, questionId)] + if (native?.delivery === 'async' && answerQuestion) { + const options = Array.isArray(value.options) + ? value.options.filter((option): option is string => typeof option === 'string').map((label) => ({ label, description: '' })) + : [] + return ( +
+ answerQuestion({ itemId, questionId, question: value.title as string, text: answers[questionId] })} + /> + {status ?

{ASYNC_ANSWER_STATUS[status]}

: null} +
+ ) + } + return

{value.title}

{Array.isArray(value.options) ?
    {value.options.filter((option): option is string => typeof option === 'string').map((option, i) =>
  • {option}
  • )}
: null}
})} } diff --git a/src/components/fresh-agent/FreshAgentQuestionBanner.tsx b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx index 4668a2fb2..d51c10f8e 100644 --- a/src/components/fresh-agent/FreshAgentQuestionBanner.tsx +++ b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx @@ -3,13 +3,15 @@ import { MessageCircleQuestion } from 'lucide-react' import { cn } from '@/lib/utils' type FreshAgentQuestion = { - requestId: string + requestId?: string questions: Array<{ id?: string question: string header: string options: Array<{ label: string; description: string }> multiSelect: boolean + isOther?: boolean + isSecret?: boolean }> } @@ -24,16 +26,17 @@ function SingleSelectQuestion({ onSelect: (answer: string) => void disabled?: boolean }) { - const [showOther, setShowOther] = useState(false) + const nativeFreeEntry = question.options.length === 0 && (question.isOther !== undefined || question.isSecret !== undefined) + const [showOther, setShowOther] = useState(nativeFreeEntry) const [otherText, setOtherText] = useState('') const otherInputRef = useRef(null) const submitOther = () => { - if (!disabled && otherText.trim()) onSelect(otherText.trim()) + if (!disabled && otherText.trim()) onSelect(question.isOther !== undefined || question.isSecret !== undefined ? otherText : otherText.trim()) } useEffect(() => { - if (showOther) otherInputRef.current?.focus() - }, [showOther]) + if (showOther && !nativeFreeEntry) otherInputRef.current?.focus() + }, [showOther, nativeFreeEntry]) return (
@@ -61,7 +64,7 @@ function SingleSelectQuestion({ ) : null} ))} - + : null}
{showOther ? (
logicalAttemptId?: string @@ -1160,6 +1164,8 @@ export function FreshAgentView({ const localEchoRef = useRef(null) localEchoRef.current = localEcho const pendingSendMetadataRef = useRef>(new Map()) + const [asyncQuestionStatuses, setAsyncQuestionStatuses] = useState>({}) + const [checkpointAvailabilityByTurn, setCheckpointAvailabilityByTurn] = useState>( () => checkpointAvailabilityMap(paneContent), ) @@ -2136,6 +2142,7 @@ export function FreshAgentView({ commitSnapshot(null) setLoadError(null) setQueuedMessages([]) + setAsyncQuestionStatuses({}) setLocalEcho(null) alwaysAllowToolsRef.current.clear() pendingAutoTitleBySessionIdRef.current.clear() @@ -3058,6 +3065,7 @@ export function FreshAgentView({ ? message.submittedTurnId : undefined const pending = pendingSendMetadataRef.current.get(message.requestId) + if (pending?.asyncQuestionKeys?.length) setAsyncQuestionStatuses((previous) => ({ ...previous, ...Object.fromEntries(pending.asyncQuestionKeys!.map((key) => [key, 'accepted' as const])) })) if (pending && !pending.checkpointOutcome) { // Older servers may accept a send without the additive outcome // frame. Keep that turn honest and rewind disabled after acceptance. @@ -3132,8 +3140,10 @@ export function FreshAgentView({ // pending-metadata entry, the stale local echo (dual-write), and the // optimistic `running` status. const failedText = pendingSendMetadataRef.current.get(failedRequestId)?.text + const asyncQuestionKeys = pendingSendMetadataRef.current.get(failedRequestId)?.asyncQuestionKeys const ambiguous = current.provider === 'codex' && typeof message.message === 'string' && message.message.startsWith('CODEX_DELIVERY_AMBIGUOUS:') + if (asyncQuestionKeys?.length) setAsyncQuestionStatuses((previous) => ({ ...previous, ...Object.fromEntries(asyncQuestionKeys.map((key) => [key, ambiguous ? 'ambiguous' as const : 'refused' as const])) })) if (ambiguous && pendingSendMetadataRef.current.get(failedRequestId)?.deliveryAmbiguous) return if (ambiguous) { // Keep the receipt and queue reservation: a lost response can follow @@ -4165,6 +4175,15 @@ export function FreshAgentView({ // old-kind send the server's generation fence would refuse with a // misleading failure instead of the pane's recoverable attach action. const composerDisabled = snapshotHistoryOnly || managedRecoveryActive || !paneContent.sessionId || sessionEnded || (!canSend && !isBusy) || Boolean(ownerDivergence) + const asyncQuestionContext = useMemo(() => ({ + statuses: asyncQuestionStatuses, + onAnswer: (answer: Parameters[0]) => { + if (composerDisabled) return + const key = asyncQuestionKey(answer.itemId, answer.questionId) + setAsyncQuestionStatuses((previous) => ({ ...previous, [key]: 'draft' })) + composerRef.current?.upsertTextLine(asyncQuestionDraftPrefix(answer.itemId, answer.questionId), formatAsyncQuestionAnswer(answer)) + }, + }), [asyncQuestionStatuses, composerDisabled]) useEffect(() => { const outgoing = outgoingTurnRef.current @@ -4227,8 +4246,11 @@ export function FreshAgentView({ const checkpointScope = checkpointAvailabilityScope(current) // Retain the outgoing text only in transient retry metadata. Durable // checkpoint outcomes contain no prompt text or provider payloads. + const asyncQuestionKeys = asyncQuestionKeysInText(text) + if (asyncQuestionKeys.length) setAsyncQuestionStatuses((previous) => ({ ...previous, ...Object.fromEntries(asyncQuestionKeys.map((key) => [key, 'sending' as const])) })) recordPendingSendMetadata(requestId, { text, + asyncQuestionKeys, logicalAttemptId, checkpointAvailabilityScope: checkpointScope, ...(current.checkpointWorkspace ? { checkpointWorkspace: current.checkpointWorkspace } : {}), @@ -4304,7 +4326,7 @@ export function FreshAgentView({ if (!current.sessionId || current.provider === 'claude') return const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) for (const approval of pendingApprovalsFromSnapshot) { - if (approval.toolName && alwaysAllowToolsRef.current.has(approval.toolName)) { + if (!approval.native && approval.toolName && alwaysAllowToolsRef.current.has(approval.toolName)) { sendFreshAgentMessage({ type: 'freshAgent.approval.respond', sessionId: current.sessionId, @@ -4824,12 +4846,17 @@ export function FreshAgentView({ approval={approval} disabled={!paneContent.sessionId} onRespond={(decision) => respondToApproval(approval.requestId, decision)} - onAlwaysAllow={paneContent.provider === 'claude' ? undefined : (toolName) => { + onAlwaysAllow={paneContent.provider === 'claude' || approval.native ? undefined : (toolName) => { alwaysAllowToolsRef.current.add(toolName) respondToApproval(approval.requestId, buildAllowDecision()) }} /> ))} + {(snapshot?.pendingElicitations ?? []).map((request) => { + if (!paneContent.sessionId) return + dispatch(dismissTabGreen(tabId)) + sendFreshAgentMessage({ type: 'freshAgent.elicitation.respond', sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, requestId: request.requestId, action, content }) + }} />)} {pendingQuestions.map((question) => ( composerRef.current?.insertText(text)} />
+
+ {/* Every fresh-agent pane gets the strip (unknown state included): the model chip opens the shared model dialog and the strip owns the bottom-chrome divider (the composer draws no border-top). */} @@ -5080,6 +5111,7 @@ export function FreshAgentView({ ) }, [ + asyncQuestionContext, canSend, claudeSession?.restoreFailureMessage, activeStyle, diff --git a/src/lib/fresh-agent-async-questions.ts b/src/lib/fresh-agent-async-questions.ts new file mode 100644 index 000000000..469417a66 --- /dev/null +++ b/src/lib/fresh-agent-async-questions.ts @@ -0,0 +1,28 @@ +/** Context for ordinary user input. None of these identities is a native control request. */ +export type AsyncQuestionAnswer = { itemId: string; questionId: string; question: string; text: string } +export type AsyncQuestionAnswerStatus = 'draft' | 'sending' | 'accepted' | 'refused' | 'ambiguous' +export const asyncQuestionKey = (itemId: string, questionId: string) => JSON.stringify([itemId, questionId]) +const HEADER = 'Answer to Codex question: ' + +export function asyncQuestionDraftPrefix(itemId: string, questionId: string): string { + return HEADER + JSON.stringify({ itemId, questionId }).slice(0, -1) + ',' +} + +export function formatAsyncQuestionAnswer(answer: AsyncQuestionAnswer): string { + return HEADER + JSON.stringify({ itemId: answer.itemId, questionId: answer.questionId, question: answer.question, answer: answer.text }) +} + +/** Recover only an ordinary draft's context on explicit Send, never a pending RPC or an automatic send. */ +export function asyncQuestionKeysInText(text: string): string[] { + const keys = new Set() + for (const line of text.split('\n')) { + if (!line.startsWith(HEADER)) continue + try { + const value: unknown = JSON.parse(line.slice(HEADER.length)) + if (!value || typeof value !== 'object') continue + const answer = value as Record + if (typeof answer.itemId === 'string' && typeof answer.questionId === 'string' && typeof answer.question === 'string' && typeof answer.answer === 'string') keys.add(asyncQuestionKey(answer.itemId, answer.questionId)) + } catch { /* Edited ordinary prose remains ordinary prose. */ } + } + return [...keys] +} diff --git a/src/lib/fresh-agent-permission-updates.ts b/src/lib/fresh-agent-permission-updates.ts index 769a352b3..c98aa89c2 100644 --- a/src/lib/fresh-agent-permission-updates.ts +++ b/src/lib/fresh-agent-permission-updates.ts @@ -10,6 +10,7 @@ import type { FreshAgentPermissionUpdate } from '@shared/fresh-agent-contract' export type FreshAgentApprovalDecision = + | { nativeDecision: 'accept' | 'acceptForSession' | 'decline' | 'cancel' } | { behavior: 'allow'; updatedPermissions?: FreshAgentPermissionUpdate[] } | { behavior: 'deny'; message: string; interrupt: false } diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 444bc7d3d..29bce1fd3 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -7,7 +7,7 @@ import { consumeCancelledCreate, consumeCreateRoute, rememberCreateRoute } from import { flushPersistedLayoutNow } from '@/store/persistControl' import { KILL_FAILED_MESSAGE } from '@/lib/kill-ack' import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice' -import { applyFreshAgentCompletion, applyFreshAgentWaiting, observeFreshAgentSession, rekeyFreshAgentAttention } from '@/store/turnCompletionThunks' +import { applyFreshAgentCompletion, applyFreshAgentWaiting, applyFreshAgentQuestionAvailable, observeFreshAgentSession, rekeyFreshAgentAttention } from '@/store/turnCompletionThunks' import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' @@ -17,6 +17,8 @@ import { FreshAgentTranscriptPatchSchema } from '@shared/fresh-agent-contract' import { addPermissionRequest, addQuestionRequest, + addElicitationRequest, + removeElicitation, applyRuntimeOwner, clearPendingCreateFailure, createFailed, @@ -540,6 +542,10 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh })) return true } + case 'freshAgent.question.available': { + if (typeof event.at === 'number' && Number.isFinite(event.at)) dispatch(applyFreshAgentQuestionAvailable({ provider: locator.provider, sessionId, at: event.at })) + return true + } case 'freshAgent.turn.waiting': { if (typeof event.at !== 'number' || !Number.isFinite(event.at)) { log.warn('dropping malformed freshAgent.turn.waiting without a numeric at', { sessionId, at: event.at }) @@ -623,6 +629,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh requestId: event.requestId as string, toolName: tool?.name, input: tool?.input, + ...(event.native && typeof event.native === 'object' ? { native: event.native as Record } : {}), providerRequest: { subtype: event.subtype, tool, @@ -641,9 +648,16 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh ...locator, requestId: event.requestId as string, questions: event.questions as never, + ...(typeof event.isBlocking === 'boolean' ? { isBlocking: event.isBlocking } : {}), providerRequest: event, })) return true + case 'freshAgent.elicitation.request': + dispatch(addElicitationRequest({ ...locator, ...event, requestId: event.requestId as string } as never)) + return true + case 'freshAgent.elicitation.cancelled': + dispatch(removeElicitation({ ...locator, requestId: event.requestId as string })) + return true case 'freshAgent.question.cancelled': // Provider-originated cancellation: the Rust claude/kilroy slice forwards // the sidecar's question-cancel edge as this event type. Clear the card diff --git a/src/lib/ready-message-schema.ts b/src/lib/ready-message-schema.ts index 7a7969c98..84097e485 100644 --- a/src/lib/ready-message-schema.ts +++ b/src/lib/ready-message-schema.ts @@ -31,6 +31,7 @@ export const ReadyMessageSchema = z.object({ sessionType: z.string().optional(), lastTurnCompleteAt: z.number().finite().optional(), lastWaitingAt: z.number().finite().optional(), + lastQuestionAvailableAt: z.number().finite().optional(), })).optional().catch(undefined), // kata b8ke: the handshake's runtime-owner replay (additive/optional — // old servers omit it). Same ready-frame doctrine: `.catch(undefined)` diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 1e7b4bd35..587676ca4 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -9,6 +9,7 @@ import { isValidClaudeSessionId } from '@/lib/claude-session-id' import type { SessionRuntimeOwnerMessage } from '@shared/ws-protocol' import type { FreshAgentPermissionRequest, + FreshAgentRequestId, FreshAgentQuestionRequest, FreshAgentSessionState, FreshAgentSessionStatus, @@ -73,6 +74,7 @@ function createSession(locator: FreshAgentSessionPayload, status: FreshAgentSess streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, + pendingElicitations: {}, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, @@ -377,6 +379,7 @@ const freshAgentSlice = createSlice({ session.pendingQuestions = Object.fromEntries( snapshot.pendingQuestions.map((question) => [String(question.requestId), question]), ) + session.pendingElicitations = Object.fromEntries((snapshot.pendingElicitations ?? []).map((request) => [String(request.requestId), request])) session.totalInputTokens = snapshot.tokenUsage.inputTokens session.totalOutputTokens = snapshot.tokenUsage.outputTokens session.totalCostUsd = snapshot.tokenUsage.costUsd ?? 0 @@ -426,6 +429,15 @@ const freshAgentSlice = createSlice({ delete state.sessions[key].pendingQuestions[String(action.payload.requestId)] }, + addElicitationRequest(state, action: PayloadAction[string]>) { + const session = resolveOrEnsureSession(state, action.payload) + if (session) (session.pendingElicitations ??= {})[String(action.payload.requestId)] = action.payload + }, + removeElicitation(state, action: PayloadAction) { + const key = resolveSessionKey(state, action.payload) + if (key) delete state.sessions[key].pendingElicitations?.[String(action.payload.requestId)] + }, + sessionError(state, action: PayloadAction) { const session = resolveOrEnsureSession(state, action.payload) if (!session) return @@ -712,6 +724,8 @@ const freshAgentSlice = createSlice({ export const { addPermissionRequest, addQuestionRequest, + addElicitationRequest, + removeElicitation, applyRuntimeOwner, applyRuntimeOwnerFenceRefresh, applyRefusalFence, diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index 855c23a8b..87efa3cd3 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -5,6 +5,7 @@ import type { import type { FreshAgentPendingApproval, FreshAgentPendingQuestion, + FreshAgentPendingElicitation, FreshAgentRequestId, FreshAgentSnapshot, FreshAgentTurn, @@ -123,6 +124,7 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { streamingActive: boolean pendingPermissions: Record pendingQuestions: Record + pendingElicitations?: Record totalCostUsd: number totalInputTokens: number totalOutputTokens: number diff --git a/src/store/turnCompletionThunks.ts b/src/store/turnCompletionThunks.ts index 47ef6a952..f1bc006dc 100644 --- a/src/store/turnCompletionThunks.ts +++ b/src/store/turnCompletionThunks.ts @@ -16,8 +16,10 @@ function completionDedupeKey(provider: string, sessionId: string): string { return `${provider}:${sessionId}` } -function waitingDedupeKey(provider: string, sessionId: string): string { - return `${completionDedupeKey(provider, sessionId)}#waiting` +type FreshAgentAttentionKind = 'complete' | 'waiting' | 'question' +const FRESH_AGENT_ATTENTION_KINDS = ['complete', 'waiting', 'question'] as const +function attentionDedupeKey(provider: string, sessionId: string, kind: FreshAgentAttentionKind): string { + return `${completionDedupeKey(provider, sessionId)}${kind === 'complete' ? '' : `#${kind}`}` } type FreshAgentPaneLocation = { @@ -30,13 +32,13 @@ function attentionDedupeKeys( provider: string, sessionId: string, location: FreshAgentPaneLocation | undefined, - waiting: boolean, + kind: FreshAgentAttentionKind, ): string[] { const sessionKeys = new Set([ ...(location?.sessionKeys ?? []), completionDedupeKey(provider, sessionId), ]) - return [...sessionKeys].map((key) => waiting ? `${key}#waiting` : key) + return [...sessionKeys].map((key) => kind === 'complete' ? key : `${key}#${kind}`) } function newestKnownAt(state: RootState, terminalIds: string[]): number | undefined { @@ -60,8 +62,8 @@ export function observeFreshAgentSession(payload: { provider: string; sessionId: const location = findFreshAgentPaneBySessionKey( getState(), completionDedupeKey(payload.provider, payload.sessionId), ) - for (const waiting of [false, true]) { - const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + for (const kind of FRESH_AGENT_ATTENTION_KINDS) { + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, kind) for (const terminalId of terminalIds) { dispatch(advanceCompletionBaseline({ terminalId, at: 0 })) } @@ -77,10 +79,10 @@ export function rekeyFreshAgentAttention(payload: { provider: string; previousSe const nextKey = completionDedupeKey(payload.provider, payload.sessionId) const location = findFreshAgentPaneBySessionKey(state, previousKey) ?? findFreshAgentPaneBySessionKey(state, nextKey) - for (const waiting of [false, true]) { + for (const kind of FRESH_AGENT_ATTENTION_KINDS) { const terminalIds = [...new Set([ - ...attentionDedupeKeys(payload.provider, payload.previousSessionId, location, waiting), - ...attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting), + ...attentionDedupeKeys(payload.provider, payload.previousSessionId, location, kind), + ...attentionDedupeKeys(payload.provider, payload.sessionId, location, kind), ])] const at = newestKnownAt(state, terminalIds) ?? 0 advanceAttentionAliases(dispatch, terminalIds, at) @@ -96,11 +98,12 @@ export function foldReadyFreshAgentAttention(marks: ReadyMessage['freshAgentAtte const location = findFreshAgentPaneBySessionKey( getState(), completionDedupeKey(mark.provider, mark.sessionId), ) - for (const [waiting, at, apply] of [ - [false, mark.lastTurnCompleteAt, applyFreshAgentCompletion], - [true, mark.lastWaitingAt, applyFreshAgentWaiting], + for (const [kind, at, apply] of [ + ['complete', mark.lastTurnCompleteAt, applyFreshAgentCompletion], + ['waiting', mark.lastWaitingAt, applyFreshAgentWaiting], + ['question', mark.lastQuestionAvailableAt, applyFreshAgentQuestionAvailable], ] as const) { - const terminalIds = attentionDedupeKeys(mark.provider, mark.sessionId, location, waiting) + const terminalIds = attentionDedupeKeys(mark.provider, mark.sessionId, location, kind) const known = newestKnownAt(getState(), terminalIds) if (known === undefined) { advanceAttentionAliases(dispatch, terminalIds, at ?? 0) @@ -123,14 +126,14 @@ export function foldReadyFreshAgentAttention(marks: ReadyMessage['freshAgentAtte function applyFreshAgentAttentionEdge( payload: ApplyFreshAgentCompletionPayload, - waiting: boolean, + kind: FreshAgentAttentionKind, ) { return (dispatch: AppDispatch, getState: () => RootState): void => { dispatch(observeFreshAgentSession(payload)) const state = getState() const sessionKey = completionDedupeKey(payload.provider, payload.sessionId) const location = findFreshAgentPaneBySessionKey(state, sessionKey) - const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, kind) const known = newestKnownAt(state, terminalIds) if (!location) { @@ -145,7 +148,7 @@ function applyFreshAgentAttentionEdge( dispatch(recordTurnComplete({ tabId: location.tabId, paneId: location.paneId, - terminalId: waiting ? waitingDedupeKey(payload.provider, payload.sessionId) : sessionKey, + terminalId: attentionDedupeKey(payload.provider, payload.sessionId, kind), at: payload.at, })) advanceAttentionAliases(dispatch, terminalIds, payload.at) @@ -239,7 +242,7 @@ export type ApplyFreshAgentCompletionPayload = { * values are dropped by the same baseline as live edges. */ export function applyFreshAgentCompletion(payload: ApplyFreshAgentCompletionPayload) { - return applyFreshAgentAttentionEdge(payload, false) + return applyFreshAgentAttentionEdge(payload, 'complete') } export type ApplyFreshAgentWaitingPayload = { @@ -260,7 +263,12 @@ export type ApplyFreshAgentWaitingPayload = { * waiting edge, and prevents a reconnect from ringing the same approval twice. */ export function applyFreshAgentWaiting(payload: ApplyFreshAgentWaitingPayload) { - return applyFreshAgentAttentionEdge(payload, true) + return applyFreshAgentAttentionEdge(payload, 'waiting') +} + +/** Actionable async content has its own replay mark and never means native work is paused. */ +export function applyFreshAgentQuestionAvailable(payload: ApplyFreshAgentWaitingPayload) { + return applyFreshAgentAttentionEdge(payload, 'question') } function findFreshAgentPaneBySessionKey( diff --git a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts index 34772444d..b57e6313f 100644 --- a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts +++ b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts @@ -22,13 +22,13 @@ test('Freshcodex observes the exact child read-only and restores the selection a await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) await fs.chmod(wrapper, 0o755) const parent = { id: parentId, ephemeral: false, status: { type: 'idle' }, turns: [{ id: 'parent-turn', status: 'completed', items: [ - { id: 'parent-answer', type: 'agentMessage', text: 'Parent conversation remains available' }, + { id: 'parent-answer', type: 'agentMessage', text: 'Parent conversation remains available', delivery: 'async', questions: [{ title: 'Parent question?', options: ['Parent choice'] }] }, { id: 'denied', type: 'commandExecution', command: 'touch blocked', status: 'declined', aggregatedOutput: 'User declined', exitCode: null }, { id: 'image-output', type: 'imageGeneration', status: 'completed', result: outputImage, savedPath: outputImage }, { id: 'spawn', type: 'collabAgentToolCall', tool: 'spawnAgent', status: 'completed', senderThreadId: parentId, receiverThreadIds: [childId], agentsStates: { [childId]: { status: 'completed', message: 'Child finished', model: 'child-model' } } }, ] }] } const child = { id: childId, parentThreadId: parentId, ephemeral: false, status: { type: 'idle' }, turns: [ - { id: 'child-turn', status: 'completed', itemsView: 'full', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer' }] }, + { id: 'child-turn', status: 'completed', itemsView: 'full', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer', delivery: 'async', questions: [{ title: 'Child question?', options: ['Child choice'] }] }] }, { id: 'preview-turn', status: 'interrupted', itemsView: 'summary', items: [{ id: 'child-preview', type: 'agentMessage', summary: 'Partial child preview' }] }, ] } const server = new RustServer({ env: { @@ -45,6 +45,10 @@ test('Freshcodex observes the exact child read-only and restores the selection a const owned = await createFreshE2ePage(browser, info) context = owned.context const page = owned.page + const inputs: Array> = [] + page.on('websocket', (socket) => socket.on('framesent', (frame) => { + try { const value = JSON.parse(String(frame.payload)); if (value.type === 'freshAgent.send' || value.type === 'freshAgent.question.respond') inputs.push(value) } catch { /* unrelated frame */ } + })) await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) await harness.waitForHarness() @@ -57,6 +61,8 @@ test('Freshcodex observes the exact child read-only and restores the selection a await directory.press('Enter') const pane = page.locator('[data-context="fresh-agent"]').last() await expect(pane.getByText('Parent conversation remains available', { exact: true })).toBeVisible({ timeout: 30_000 }) + await pane.getByRole('button', { name: 'Parent choice', exact: true }).click() + await expect(pane.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(/parent-answer/) const before = await fs.readFile(operations, 'utf8') const open = pane.getByRole('button', { name: `Observe child ${childId}`, exact: true }).first() await open.click({ timeout: 15_000 }) @@ -64,8 +70,22 @@ test('Freshcodex observes the exact child read-only and restores the selection a await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() await expect(observer.getByText('Partial child preview', { exact: true })).toBeVisible() await expect(observer.getByRole('note', { name: 'Output persistence' })).toHaveText('Native preview. Interrupted.') + await expect(observer.getByText('Child question?', { exact: true })).toBeVisible() + await expect(observer.getByText('Child choice', { exact: true })).toBeVisible() + await expect(observer.getByRole('button', { name: 'Child choice', exact: true })).toHaveCount(0) await expect(observer.getByRole('textbox')).toHaveCount(0) await expect(observer.getByRole('button', { name: /send|stop|configure|resume/i })).toHaveCount(0) + const close = observer.getByRole('button', { name: 'Close child conversation' }) + await expect(close).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(observer.getByRole('button', { name: 'Copy turn', exact: true }).last()).toBeFocused() + await page.keyboard.press('Tab') + await expect(close).toBeFocused() + await page.keyboard.press('Escape') + await expect(observer).toHaveCount(0) + await expect(open).toBeFocused() + await open.click() + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() await page.reload() await harness.waitForHarness() await harness.waitForConnection() @@ -86,6 +106,12 @@ test('Freshcodex observes the exact child read-only and restores the selection a const prior = before.split('\n').filter(Boolean).map((line) => JSON.parse(line)) expect(rows.filter((row) => row.method === 'thread/start')).toHaveLength(prior.filter((row) => row.method === 'thread/start').length) expect(rows.filter((row) => row.params?.threadId === childId && row.method !== 'thread/read')).toEqual([]) + expect(inputs).toEqual([]) + await expect(pane.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(/parent-answer/) + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect.poll(() => inputs.length).toBe(1) + expect(inputs[0]).toMatchObject({ type: 'freshAgent.send', text: expect.stringContaining('Parent choice') }) + await expect(pane.getByText('Answer accepted as a message', { exact: true })).toBeVisible() } finally { await context?.close().catch(() => {}) await server.stop() diff --git a/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts b/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts new file mode 100644 index 000000000..62d35585d --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts @@ -0,0 +1,187 @@ +import type { Browser, Locator, 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' +import { test, expect, createFreshE2ePage } 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 source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + + +function controlReplies(page: Page) { + const frames: Array> = [] + page.on('websocket', (socket) => socket.on('framesent', (frame) => { + try { + const message = JSON.parse(String(frame.payload)) + if (message.type === 'freshAgent.question.respond' || message.type === 'freshAgent.elicitation.respond') frames.push(message) + } catch { /* Non-JSON frames are unrelated to controls. */ } + })) + return frames +} + +function controlRequests(page: Page) { + const requests: Array> = [] + page.on('websocket', (socket) => socket.on('framereceived', (frame) => { + try { + const message = JSON.parse(String(frame.payload)) + if (['freshAgent.question.request', 'freshAgent.elicitation.request'].includes(message.event?.type)) requests.push(message.event) + } catch { /* Non-JSON frames are unrelated to controls. */ } + })) + return requests +} + +test('Freshcodex retains secret native question metadata after reload and sends its exact answer', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-controls-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, serverRequestsByPrompt: { 'Ask for token': { id: 51, method: 'item/tool/requestUserInput', params: { isBlocking: false, questions: [{ id: 'token', header: 'Token', question: 'Enter token', options: null, isOther: false, isSecret: true }] } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const replies = controlReplies(page) + const requests = controlRequests(page) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await pane.getByRole('textbox', { name: 'Chat message input' }).fill('Ask for token') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect(pane.getByRole('region', { name: 'Question from Codex' })).toBeVisible() + const other = pane.getByRole('button', { name: 'Other', exact: true }) + if (await other.count()) await other.click() + const secret = pane.getByLabel('Enter token') + await expect(secret).toHaveAttribute('type', 'password') + await secret.fill('unsent secret') + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(secret).toHaveAttribute('type', 'password') + await expect(secret).toHaveValue('') + await expect(pane.getByRole('button', { name: 'Other', exact: true })).toHaveCount(0) + await secret.fill(' token, punctuation! ') + await pane.getByRole('button', { name: 'Submit', exact: true }).click() + await expect(secret).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 51, method: 'item/tool/requestUserInput' }) + expect(replies).toEqual([{ type: 'freshAgent.question.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, answers: { token: ' token, punctuation! ' } }]) + await expect(pane.getByRole('button', { name: 'Send', exact: true })).toBeVisible() + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) + + +async function withNativeControl(browser: Browser, params: Record, run: (page: Page, pane: Locator, replies: Array>, requests: Array>) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-elicitation-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, serverRequestsByPrompt: { 'Ask fixture': { id: 41, method: 'mcpServer/elicitation/request', params: { serverName: 'contract', message: 'A native MCP request', ...params } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const replies = controlReplies(page) + const requests = controlRequests(page) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await pane.getByRole('textbox', { name: 'Chat message input' }).fill('Ask fixture') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await run(page, pane, replies, requests) + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +} + +for (const mode of ['form', 'openai/form', 'openaiForm']) { + test(`Freshcodex answers native ${mode} with typed values after reload`, async ({ browser }) => { + test.setTimeout(180_000) + await withNativeControl(browser, { mode, requestedSchema: { type: 'object', properties: { + token: { type: 'string', title: 'Token', writeOnly: true }, + count: { type: 'integer', title: 'Count', minimum: 1, maximum: 5 }, + enabled: { type: 'boolean', title: 'Enabled' }, + color: { type: 'string', title: 'Color', enum: ['blue', 'green'] }, + colors: { type: 'array', title: 'Colors', items: { type: 'string', enum: ['blue', 'green'] } }, + }, required: ['token', 'count', 'enabled', 'color', 'colors'] } }, async (page, pane, replies, requests) => { + const banner = pane.getByRole('region', { name: 'Request from contract' }) + await expect(banner).toBeVisible() + await expect(banner.getByLabel('Token', { exact: true })).toHaveAttribute('type', 'password') + await banner.getByLabel('Token', { exact: true }).fill('unsent fixture value') + await page.reload() + await expect(banner).toBeVisible({ timeout: 30_000 }) + await expect(banner.getByLabel('Token', { exact: true })).toHaveValue('') + expect(replies).toEqual([]) + await banner.getByLabel('Token', { exact: true }).fill(' exact, form answer! ') + await banner.getByLabel('Count', { exact: true }).fill('3') + await banner.getByLabel('Enabled', { exact: true }).selectOption('false') + await banner.getByLabel('Color', { exact: true }).selectOption('blue') + await banner.getByLabel('Colors', { exact: true }).selectOption(['blue', 'green']) + await banner.getByRole('button', { name: 'Continue', exact: true }).click() + await expect(banner).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 41, method: 'mcpServer/elicitation/request' }) + expect(replies).toEqual([{ type: 'freshAgent.elicitation.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, action: 'accept', content: { token: ' exact, form answer! ', count: 3, enabled: false, color: 'blue', colors: ['blue', 'green'] } }]) + }) + }) +} + +test('Freshcodex opens native URL elicitation without accepting until Continue is clicked', async ({ browser }) => { + test.setTimeout(180_000) + await withNativeControl(browser, { mode: 'url', turnId: null, url: 'https://elicitation.example.test/confirm', elicitationId: 'public-url-id' }, async (page, pane, replies, requests) => { + await page.context().route('https://elicitation.example.test/**', (route) => route.fulfill({ body: 'Controlled fixture page' })) + const banner = pane.getByRole('region', { name: 'Request from contract' }) + await expect(banner).toBeVisible() + const popupPromise = page.waitForEvent('popup') + await banner.getByRole('link', { name: 'Open contract page' }).click() + const popup = await popupPromise + expect(replies).toEqual([]) + await expect(banner).toBeVisible() + await popup.close() + await banner.getByRole('button', { name: 'Continue', exact: true }).click() + await expect(banner).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 41, method: 'mcpServer/elicitation/request' }) + expect(replies).toEqual([{ type: 'freshAgent.elicitation.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, action: 'accept', content: null }]) + }) +}) diff --git a/test/fixtures/fresh-agent/codex/task001-child-approval.json b/test/fixtures/fresh-agent/codex/task001-child-approval.json new file mode 100644 index 000000000..bf039336a --- /dev/null +++ b/test/fixtures/fresh-agent/codex/task001-child-approval.json @@ -0,0 +1,89 @@ +{ + "source": "Task001 actual Codex 0.164.0 capture d0c5507d-ae0a-4557-aac7-3811b5e36605.json, native frames 19, 21, 36", + "frames": [ + { + "method": "item/completed", + "params": { + "item": { + "type": "collabAgentToolCall", + "id": "call_native_child", + "tool": "spawnAgent", + "status": "completed", + "senderThreadId": "01a17436-329e-7f72-8506-9f1c328abcec", + "receiverThreadIds": [ + "01a17436-32e2-7b60-be71-f13d0e023464" + ], + "prompt": "freshell-child-native-probe: return the controlled child answer.", + "model": "contract-model", + "reasoningEffort": "low", + "agentsStates": { + "01a17436-32e2-7b60-be71-f13d0e023464": { + "status": "pendingInit", + "message": null + } + } + }, + "threadId": "01a17436-329e-7f72-8506-9f1c328abcec", + "turnId": "01a17436-32ab-77e1-bd1d-04f0ac1f4ff5", + "completedAtMs": 1792951071462 + }, + "emittedAtMs": 1792951071463 + }, + { + "method": "turn/started", + "params": { + "threadId": "01a17436-32e2-7b60-be71-f13d0e023464", + "turn": { + "id": "01a17436-32e4-7c00-afb7-baad4e048e86", + "rootTurnId": "01a17436-32ab-77e1-bd1d-04f0ac1f4ff5", + "items": [], + "itemsView": "notLoaded", + "status": "inProgress", + "error": null, + "startedAt": 1792951071, + "completedAt": null, + "durationMs": null + } + }, + "emittedAtMs": 1792951071467 + }, + { + "method": "item/commandExecution/requestApproval", + "id": 0, + "params": { + "kind": "command", + "threadId": "01a17436-32e2-7b60-be71-f13d0e023464", + "turnId": "01a17436-32e4-7c00-afb7-baad4e048e86", + "itemId": "call_child_contract_command", + "startedAtMs": 1792951071521, + "environmentId": "local", + "command": "/usr/bin/sh -c 'cat contract-input.txt'", + "cwd": "/tmp/codex-contract-7cVoCH/workspace", + "commandActions": [ + { + "type": "read", + "command": "cat contract-input.txt", + "name": "contract-input.txt", + "path": "/tmp/codex-contract-7cVoCH/workspace/contract-input.txt" + } + ], + "proposedExecpolicyAmendment": [ + "cat", + "contract-input.txt" + ], + "availableDecisions": [ + "accept", + { + "acceptWithExecpolicyAmendment": { + "execpolicy_amendment": [ + "cat", + "contract-input.txt" + ] + } + }, + "cancel" + ] + } + } + ] +} diff --git a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx index 0bdff8cea..e85bc747f 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FreshAgentAsyncQuestionContext } from '@/components/fresh-agent/FreshAgentItemCard' import { FreshAgentChildObserver } from '@/components/fresh-agent/FreshAgentChildObserver' import { api } from '@/lib/api' import { codexContractSnapshot } from '../../../../fixtures/fresh-agent/codex/contract-fixtures.js' @@ -22,6 +23,21 @@ describe('FreshAgentChildObserver', () => { expect(screen.queryByRole('button', { name: /send|stop|configure|resume/i })).not.toBeInTheDocument() }) + it('keeps child async questions read-only even under a parent answer context', async () => { + const onParentAnswer = vi.fn() + const response = readable() + response.snapshot.turns[0].items = [{ id: 'child-question', kind: 'text', text: 'A child asks', extensions: { codex: { itemId: 'native-child-question', delivery: 'async', questions: [{ title: 'Child question?', options: ['Child choice'] }] } } }] as never + vi.mocked(api.get).mockResolvedValue(response) + render() + expect(await screen.findByText('Child question?')).toBeVisible() + const choice = screen.queryByRole('button', { name: 'Child choice', exact: true }) + if (choice) fireEvent.click(choice) + expect(onParentAnswer).not.toHaveBeenCalled() + expect(choice).not.toBeInTheDocument() + expect(screen.getByText('Child choice')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + it('retains already available content during a source rebind, then refreshes it without changing identity', async () => { vi.mocked(api.get).mockResolvedValueOnce(readable()).mockRejectedValue(new Error('source unavailable')) const { rerender } = render() @@ -99,4 +115,30 @@ describe('FreshAgentChildObserver', () => { expect(opener).toHaveFocus() opener.remove() }) + + it('contains Tab and Shift-Tab, uses the latest Escape callback, and returns focus to its opener', async () => { + vi.mocked(api.get).mockResolvedValue(readable()) + const opener = document.createElement('button') + document.body.append(opener) + opener.focus() + const initialClose = vi.fn() + const latestClose = vi.fn() + const { rerender, unmount } = render() + await screen.findByText('Exact observed child') + const close = screen.getByRole('button', { name: 'Close child conversation' }) + const lastControl = screen.getByRole('button', { name: 'Copy turn', exact: true }) + expect(close).toHaveFocus() + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) + expect(lastControl).toHaveFocus() + fireEvent.keyDown(lastControl, { key: 'Tab' }) + expect(close).toHaveFocus() + rerender() + expect(close).toHaveFocus() + fireEvent.keyDown(lastControl, { key: 'Escape' }) + expect(initialClose).not.toHaveBeenCalled() + expect(latestClose).toHaveBeenCalledTimes(1) + unmount() + expect(opener).toHaveFocus() + opener.remove() + }) }) diff --git a/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx new file mode 100644 index 000000000..8aab6011d --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import FreshAgentElicitationBanner from '@/components/fresh-agent/FreshAgentElicitationBanner' +import type { FreshAgentPendingElicitation } from '@shared/fresh-agent-contract' + +afterEach(cleanup) +const base: FreshAgentPendingElicitation = { requestId: '41', mode: 'form', serverName: 'contract', message: 'A native form' } + +it('sends primitive and enum-array values with their exact native field IDs', () => { + const onRespond = vi.fn() + render() + expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password') + expect(screen.getByLabelText('Token')).toHaveValue('') + fireEvent.change(screen.getByLabelText('Token'), { target: { value: ' exact, token! ' } }) + fireEvent.change(screen.getByLabelText('Count'), { target: { value: '3' } }) + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: 'false' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: 'blue,green' } }) + const colors = screen.getByLabelText('Colors') as HTMLSelectElement + colors.options[0].selected = true + colors.options[1].selected = true + fireEvent.change(colors) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', { 'token-id': ' exact, token! ', 'count-id': 3, 'enabled-id': false, 'color-id': 'blue,green', 'colors-id': ['blue', 'green'] }) +}) + +it('opening a URL does not accept it; each explicit action sends null content', () => { + const onRespond = vi.fn() + render() + fireEvent.click(screen.getByRole('link', { name: 'Open contract page' })) + expect(onRespond).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(screen.getByRole('button', { name: 'Decline' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onRespond.mock.calls).toEqual([['accept', null], ['decline', null], ['cancel', null]]) +}) + +it('does not submit required or out-of-range values', () => { + const onRespond = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).not.toHaveBeenCalled() + fireEvent.change(screen.getByLabelText('Count'), { target: { value: '8' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).not.toHaveBeenCalled() +}) + +it('clears optional boolean and enum choices without silently retaining an old answer', () => { + const onRespond = vi.fn() + render() + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: 'true' } }) + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: '' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: 'blue' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: '' } }) + const colors = screen.getByLabelText('Colors') as HTMLSelectElement + colors.options[0].selected = true + fireEvent.change(colors) + colors.options[0].selected = false + fireEvent.change(colors) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', {}) +}) + + +it('keeps a date-time answer with its explicit UTC zone as the native string', () => { + const onRespond = vi.fn() + render() + fireEvent.change(screen.getByLabelText('When'), { target: { value: '2026-10-26T10:30:00Z' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', { time: '2026-10-26T10:30:00Z' }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx index 74ec6585d..e7d32d4f4 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx @@ -344,3 +344,18 @@ describe('formatThoughtDuration', () => { expect(formatThoughtDuration(3_720_000)).toBe('1h 2m') }) }) + + +describe('true asynchronous Codex questions', () => { + afterEach(cleanup) + const item = { id: 'async-item', kind: 'text' as const, text: 'I can keep working.', extensions: { codex: { itemId: 'native-async-item', delivery: 'async', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }, { title: 'Deadline?', options: null }] } } } + it('offers contextual choices and free entry without fabricating native control IDs', () => { + const onAnswerQuestion = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + expect(onAnswerQuestion).toHaveBeenCalledWith({ itemId: 'native-async-item', questionId: '0', question: 'Where?', text: 'Staging' }) + fireEvent.change(screen.getByLabelText('Deadline?'), { target: { value: ' Friday, 5 pm! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Submit', exact: true })) + expect(onAnswerQuestion).toHaveBeenLastCalledWith({ itemId: 'native-async-item', questionId: '1', question: 'Deadline?', text: ' Friday, 5 pm! ' }) + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx index c3ca1bf88..d774b919f 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx @@ -18,6 +18,18 @@ const singleQuestion = { describe('FreshAgentQuestionBanner', () => { afterEach(() => cleanup()) + it('preserves the legacy no-flag Other entry and trimming behavior when choices are absent', () => { + const onAnswer = vi.fn() + render() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Other', exact: true })) + const input = screen.getByRole('textbox', { name: 'Legacy question' }) + expect(input).toHaveFocus() + fireEvent.change(input, { target: { value: ' legacy answer ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onAnswer).toHaveBeenCalledWith({ 'Legacy question': 'legacy answer' }) + }) + it('keeps distinct provider question IDs when question text repeats', () => { const onAnswer = vi.fn() render( { expect(screen.getByRole('button', { name: 'Other' })).toBeDisabled() }) }) + + +describe('native question metadata', () => { + afterEach(cleanup) + it('masks free entry without requiring Other and preserves the exact native answer', () => { + const onAnswer = vi.fn() + render() + const input = screen.getByLabelText('Enter token') + expect(input).toHaveAttribute('type', 'password') + expect(screen.queryByRole('button', { name: 'Other' })).not.toBeInTheDocument() + fireEvent.change(input, { target: { value: ' token, punctuation! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(onAnswer).toHaveBeenCalledWith({ token: ' token, punctuation! ' }) + }) + it('omits Other for a native choice that forbids it', () => { + render() + expect(screen.queryByRole('button', { name: 'Other' })).not.toBeInTheDocument() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx index 67fe7de6e..3ffe5a20a 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import { appReducers } from '@/store/appReducers' @@ -25,6 +25,8 @@ const apiMock = vi.hoisted(() => ({ getFreshAgentThreadSnapshot: vi.fn(), })) +vi.mock('@/components/markdown/LazyMarkdown', () => ({ LazyMarkdown: ({ content }: { content: string }) => {content} })) + vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: wsMock.send, @@ -233,3 +235,100 @@ describe('FreshAgentView approvals (client-alerts G20)', () => { ]) }) }) + + +it('retains an MCP form from a snapshot and sends a separate typed answer', async () => { + const current = { ...snapshot(CODEX, [], 1), pendingElicitations: [{ requestId: '41', mode: 'form', serverName: 'contract', message: 'Enter credentials', fields: [{ id: 'token', type: 'string', title: 'Token', required: true, secret: true }], providerRequest: { id: 41, method: 'mcpServer/elicitation/request' } }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(current) + await mount(createStore(CODEX)) + const token = screen.getByLabelText('Token') + expect(token).toHaveAttribute('type', 'password') + fireEvent.change(token, { target: { value: ' exact, token! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue', exact: true })) + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.elicitation.respond', requestId: '41', action: 'accept', content: { token: ' exact, token! ' } })) +}) + +it('native approvals expose only offered actions and no pane auto-grant', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), pendingApprovals: [{ requestId: '41', toolName: 'Bash', native: { networkApprovalContext: { host: 'example.com', protocol: 'https' }, availableDecisions: ['accept', 'cancel'] } }] }) + await mount(createStore(CODEX)) + expect(screen.getByRole('alert', { name: 'Permission request for Bash' })).toHaveTextContent('example.com') + expect(screen.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Deny tool use' })).not.toBeInTheDocument() + fireEvent.click(within(screen.getByRole('alert', { name: 'Permission request for Bash' })).getByRole('button', { name: 'Stop', exact: true })) + expect(responds().at(-1)).toMatchObject({ requestId: '41', decision: { nativeDecision: 'cancel' } }) +}) + + +it.each([true, false])('answers async questions through the ordinary composer and waits for ordinary acceptance (native turn ID: %s)', async (hasTurnId) => { + const current = { ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn:row-1', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'I can keep working.', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }] } } }] }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(current) + const store = createStore(CODEX) + await mount(store) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + expect((composer as HTMLTextAreaElement).value).toContain('native-async-item') + expect((composer as HTMLTextAreaElement).value).toContain('Staging') + expect(wsMock.send.mock.calls.some(([m]) => m.type === 'freshAgent.question.respond')).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([m]) => m).find((m) => m.type === 'freshAgent.send') + expect(sent).toMatchObject({ text: expect.stringContaining('Staging') }) + expect(screen.queryByText('Answer accepted as a message')).not.toBeInTheDocument() + act(() => { for (const handler of wsMock.handlers) handler({ type: 'freshAgent.send.accepted', ...CODEX, requestId: sent.requestId, ...(hasTurnId ? { submittedTurnId: 'ordinary-next-turn' } : {}) }) }) + expect(screen.getByText('Answer accepted as a message')).toBeVisible() +}) + +it('changing an unsent async choice updates that answer and preserves the ordinary draft', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }] } } }] }] }) + await mount(createStore(CODEX)) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + fireEvent.change(composer, { target: { value: 'Keep my ordinary note' } }) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + fireEvent.click(screen.getByRole('button', { name: 'Production', exact: true })) + expect(composer.value).toContain('Keep my ordinary note') + expect(composer.value).toContain('Production') + expect(composer.value).not.toContain('Staging') + expect(wsMock.send.mock.calls.some(([message]) => message.type === 'freshAgent.send' || message.type === 'freshAgent.question.respond')).toBe(false) +}) + +it('restores an ordinary async-answer draft silently and associates acceptance only after explicit Send', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging'] }] } } }] }] }) + const store = createStore(CODEX) + await mount(store) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + cleanup() + resetSnapshotSchedulerForTests() + await mount(store) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + expect(composer.value).toContain('native-async-item') + expect(wsMock.send.mock.calls.filter(([message]) => message.type === 'freshAgent.send')).toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([message]) => message).find((message) => message.type === 'freshAgent.send') + expect(sent.text).toContain('Staging') + act(() => { for (const handler of wsMock.handlers) handler({ type: 'freshAgent.send.accepted', ...CODEX, requestId: sent.requestId, submittedTurnId: 'ordinary-next-turn' }) }) + expect(screen.getByText('Answer accepted as a message')).toBeVisible() + expect(wsMock.send.mock.calls.filter(([message]) => message.type === 'freshAgent.question.respond')).toHaveLength(0) +}) + +it.each([ + ['SESSION_RESERVED', 'Another resume is in flight', 'Answer not sent; your draft is in the composer.'], + ['INTERNAL_ERROR', 'CODEX_DELIVERY_AMBIGUOUS: response was lost', 'Answer delivery uncertain; review the composer before sending again.'], +])('retains an ordinary async answer and its delivery indication after %s', async (code, message, indication) => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging'] }] } } }] }] }) + await mount(createStore(CODEX)) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + const original = composer.value + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([frame]) => frame).find((frame) => frame.type === 'freshAgent.send') + fireEvent.change(composer, { target: { value: 'A newer ordinary draft' } }) + act(() => { for (const handler of wsMock.handlers) handler({ type: 'error', requestId: sent.requestId, code, message }) }) + expect(composer).toHaveValue(`${original}\nA newer ordinary draft`) + expect(screen.getByText(indication)).toBeVisible() + expect(screen.queryByText('Answer accepted as a message')).not.toBeInTheDocument() + await advance(1000) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.send')).toHaveLength(1) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.question.respond')).toHaveLength(0) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 3bb439714..317c99372 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -1463,3 +1463,20 @@ describe('runtime-owner folds (kata b8ke)', () => { expect(sent.observedGeneration).toBeUndefined() }) }) + + +it('rings server-authored async question attention once without pending controls or changing work status', () => { + const actions: Array<{ type: string }> = [] + const store = configureStore({ reducer: { freshAgent: freshAgentReducer, panes: panesReducer, turnCompletion: turnCompletionReducer }, middleware: (getDefault) => getDefault().concat(() => (next) => (action) => { actions.push(action as { type: string }); return next(action) }) }) + const locator = { sessionId: 'async-thread', sessionType: 'freshcodex', provider: 'codex' } as const + store.dispatch(initLayout({ tabId: 'async-tab', paneId: 'async-pane', content: { kind: 'fresh-agent', ...locator, createRequestId: 'async-create', status: 'running' } })) + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, event: { type: 'freshAgent.status', status: 'running' } }) + const available = { type: 'freshAgent.event' as const, ...locator, event: { type: 'freshAgent.question.available', itemId: 'async-item', awaited: false, at: 1234 } } + expect(handleFreshAgentMessage(store.dispatch, available)).toBe(true) + handleFreshAgentMessage(store.dispatch, available) + const session = store.getState().freshAgent.sessions['freshcodex:codex:async-thread'] + expect(session.status).toBe('running') + expect(session.pendingQuestions).toEqual({}) + expect(actions.filter((action) => action.type === 'turnCompletion/recordTurnComplete')).toHaveLength(1) + expect(store.getState().panes.activePane['async-tab']).toBe('async-pane') +}) diff --git a/test/unit/client/store/turnCompletion.replay.test.ts b/test/unit/client/store/turnCompletion.replay.test.ts index 1c4cff1f2..c915482c7 100644 --- a/test/unit/client/store/turnCompletion.replay.test.ts +++ b/test/unit/client/store/turnCompletion.replay.test.ts @@ -124,7 +124,7 @@ describe('fresh-agent reconnect attention', () => { dispatch(foldReadyFreshAgentAttention(mark(100, 200))) dispatch(observeFreshAgentSession({ provider: 'claude', sessionId: 'session-1' })) expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ - 'claude:session-1': 100, 'claude:session-1#waiting': 200, + 'claude:session-1': 100, 'claude:session-1#waiting': 200, 'claude:session-1#question': 0, }) }) @@ -239,7 +239,7 @@ describe('fresh-agent reconnect attention', () => { dispatch(foldReadyFreshAgentAttention([{ ...mark(100, 110)[0], sessionId: 'remote-only' }])) expect(store.getState().turnCompletion.seq).toBe(0) expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ - 'claude:remote-only': 100, 'claude:remote-only#waiting': 110, + 'claude:remote-only': 100, 'claude:remote-only#waiting': 110, 'claude:remote-only#question': 0, }) }) @@ -254,3 +254,18 @@ describe('fresh-agent reconnect attention', () => { expect(ReadyMessageSchema.parse({ ...valid, freshAgentAttention: mark(100) }).freshAgentAttention).toEqual(mark(100)) }) }) + + +describe('nonblocking question attention replay', () => { + it('passes the ready parser, seeds reload silently, and replays a newer question once in its own bucket', () => { + const { store, dispatch } = createStore() + const ready = (at: number) => ReadyMessageSchema.parse({ type: 'ready', timestamp: 'now', serverInstanceId: 'server', freshAgentAttention: [{ provider: 'claude', sessionId: 'session-1', lastQuestionAvailableAt: at }] }) + dispatch(foldReadyFreshAgentAttention(ready(100).freshAgentAttention)) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1#question']).toBe(100) + dispatch(foldReadyFreshAgentAttention(ready(101).freshAgentAttention)) + dispatch(foldReadyFreshAgentAttention(ready(101).freshAgentAttention)) + expect(store.getState().turnCompletion.pendingEvents).toEqual([expect.objectContaining({ terminalId: 'claude:session-1#question', at: 101 })]) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1#waiting']).toBe(0) + }) +}) diff --git a/test/unit/shared/fresh-agent-contract.test.ts b/test/unit/shared/fresh-agent-contract.test.ts index 1427b931c..bf00c3bb0 100644 --- a/test/unit/shared/fresh-agent-contract.test.ts +++ b/test/unit/shared/fresh-agent-contract.test.ts @@ -1,3 +1,4 @@ +import { FreshAgentClientMessageSchema } from '../../../shared/ws-protocol.js' import { describe, expect, it } from 'vitest' import { @@ -462,3 +463,22 @@ describe('durable opencode error projection (turn-level and tool-level)', () => }).success).toBe(false) }) }) + + +it('keeps native question flags and retained MCP forms in the durable contract', () => { + const parsed = FreshAgentSnapshotSchema.parse({ ...codexContractSnapshot, pendingQuestions: [{ requestId: '51', isBlocking: false, questions: [{ id: 'token', question: 'Token?', isOther: false, isSecret: true }] }], pendingElicitations: [{ requestId: '41', mode: 'openaiForm', serverName: 'contract', message: 'Form', fields: [{ id: 'enabled', title: 'Enabled', type: 'boolean', required: true }] }] }) + expect(parsed.pendingQuestions[0]).toMatchObject({ isBlocking: false, questions: [{ isOther: false, isSecret: true }] }) + expect(parsed.pendingElicitations[0]).toMatchObject({ mode: 'openaiForm', fields: [{ type: 'boolean' }] }) +}) + + +it('keeps elicitation replies separate from string-valued question answers and rejects nested content', () => { + const locator = { provider: 'codex', sessionType: 'freshcodex', sessionId: 'native-thread', requestId: '41' } + const frame = { ...locator, type: 'freshAgent.elicitation.respond', action: 'accept', content: { token: ' exact, answer! ', count: 3, enabled: false, colors: ['blue', 'green'] } } + expect(FreshAgentClientMessageSchema.parse(frame)).toEqual(frame) + for (const action of ['decline', 'cancel']) expect(FreshAgentClientMessageSchema.safeParse({ ...frame, action, content: null }).success).toBe(true) + expect(FreshAgentClientMessageSchema.safeParse({ ...frame, content: { nested: { value: 'unsupported' } } }).success).toBe(false) + expect(FreshAgentClientMessageSchema.safeParse({ ...frame, action: 'other' }).success).toBe(false) + const question = { ...locator, type: 'freshAgent.question.respond', answers: { token: ' exact, answer! ' } } + expect(FreshAgentClientMessageSchema.parse(question)).toEqual(question) +}) From 6ec398ba07872dc6e158fef38d84494a77fe77ce Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:22:29 -0700 Subject: [PATCH 24/92] fix(codex): retain child controls across display eviction --- .../freshell-freshagent/src/codex/controls.rs | 68 +++++--- .../src/codex/controls/tests.rs | 154 ++++++++++++++++++ 2 files changed, 201 insertions(+), 21 deletions(-) diff --git a/crates/freshell-freshagent/src/codex/controls.rs b/crates/freshell-freshagent/src/codex/controls.rs index 810d4dc24..6fc2b3f65 100644 --- a/crates/freshell-freshagent/src/codex/controls.rs +++ b/crates/freshell-freshagent/src/codex/controls.rs @@ -8,6 +8,7 @@ use freshell_protocol::{ use std::collections::HashSet; pub(super) type ControlRegistry = Arc>>>; +type ControlSource = StdMutex; #[derive(Default)] pub(super) struct SessionControls { @@ -21,6 +22,7 @@ pub(super) struct SessionControls { struct ChildControlTurn { turn_id: String, native_client: Arc, + source: std::sync::Weak, } impl SessionControls { async fn lock(&self) -> tokio::sync::MutexGuard<'_, PendingControls> { @@ -52,6 +54,7 @@ struct PendingRequest { public: Value, kind: ControlKind, native_client: Arc, + source: std::sync::Weak, } fn request_key(id: &RequestId) -> String { @@ -69,6 +72,7 @@ fn normalize_request( method: &str, params: &Value, native_client: Arc, + source: &Arc, ) -> Option { // Native IDs can be reused after resolution or a sidecar restart. The public // key identifies this operation; the original typed ID stays on its client. @@ -163,6 +167,7 @@ fn normalize_request( params: params.clone(), public, native_client, + source: Arc::downgrade(source), kind: if question { ControlKind::Question } else if method == "mcpServer/elicitation/request" { @@ -348,20 +353,20 @@ impl FreshCodexState { }; match notification { CodexNotification::ServerRequest { id, method, params } => { - let turn_matches = self - .control_scope_matches(session_id, params, &client, false) + let scope_matches = self + .control_scope_matches(session_id, params, &client, source, false) .await; - if !turn_matches { + if !scope_matches { let _ = client.reject_request(id, "Request does not belong to this native conversation or active child turn").await; self.emit_fresh_agent_error( session_id, "REQUEST_UNAVAILABLE", - "This Codex request belongs to a retired turn.", + "This Codex request no longer belongs to the active native conversation or child turn.", ); return true; } let Some(mut request) = - normalize_request(id.clone(), method, params, client.clone()) + normalize_request(id.clone(), method, params, client.clone(), source) else { tracing::warn!( provider = PROVIDER, @@ -396,6 +401,7 @@ impl FreshCodexState { if previous.method == request.method && previous.params == request.params && Arc::ptr_eq(&previous.native_client, &request.native_client) + && std::sync::Weak::ptr_eq(&previous.source, &request.source) { // Repeated native delivery and snapshot replay refer to // the same unresolved operation, including its public key. @@ -444,7 +450,7 @@ impl FreshCodexState { }; if questions.is_empty() || !self - .control_scope_matches(session_id, params, &client, true) + .control_scope_matches(session_id, params, &client, source, true) .await { return false; @@ -467,7 +473,7 @@ impl FreshCodexState { CodexNotification::Other { method, params } if method == "serverRequest/resolved" => { if let Some(params) = params { if self - .control_scope_matches(session_id, params, &client, false) + .control_scope_matches(session_id, params, &client, source, false) .await { let controls = self.session_controls(session_id).await; @@ -496,6 +502,7 @@ impl FreshCodexState { session_id, &json!({"threadId":event.thread_id,"turnId":event.turn_id}), &client, + source, false, ) .await) @@ -541,6 +548,7 @@ impl FreshCodexState { session_id, &json!({"threadId":thread_id}), &client, + source, false, ) .await => @@ -612,6 +620,7 @@ impl FreshCodexState { ChildControlTurn { turn_id: turn_id.clone(), native_client: client, + source: Arc::downgrade(source), }, ); } @@ -621,13 +630,15 @@ impl FreshCodexState { session_id: &str, params: &Value, native_client: &Arc, + source: &Arc, allow_retired: bool, ) -> bool { let sessions = self.sessions.lock().await; let Some(session) = sessions.get(session_id) else { return false; }; - if !Arc::ptr_eq(&session.client, native_client) { + if !Arc::ptr_eq(&session.client, native_client) || !Arc::ptr_eq(&session.projection, source) + { return false; } let Some(thread_id) = params.get("threadId").and_then(Value::as_str) else { @@ -635,14 +646,6 @@ impl FreshCodexState { }; let turn_id = params.get("turnId").and_then(Value::as_str); if thread_id != session_id { - if !session - .projection - .lock() - .expect("codex projection lock") - .knows_child_thread(thread_id) - { - return false; - } let controls = self .controls .lock() @@ -657,6 +660,10 @@ impl FreshCodexState { .get(thread_id) .is_some_and(|child| { Arc::ptr_eq(&child.native_client, native_client) + // The native relationship was proved at child start. + // Display eviction must not erase that proof, and a + // weak reference must not retain old display payloads. + && child.source.upgrade().is_some_and(|proof| Arc::ptr_eq(&proof, source)) && turn_id.is_none_or(|id| child.turn_id == id) }) }); @@ -664,10 +671,15 @@ impl FreshCodexState { let Some(turn_id) = turn_id else { return true; }; - session.active_turn.lock().expect("active turn").as_deref() == Some(turn_id) - // Async content remains actionable after its turn completes. A new - // awaited native RPC must still belong to the current active turn. - || allow_retired && session + let active_matches = + session.active_turn.lock().expect("active turn").as_deref() == Some(turn_id); + if active_matches { + return true; + } + // Drop the active-turn guard before consulting the retired slot. Async + // content can remain actionable there; awaited RPCs require a live turn. + allow_retired + && session .retired_turn_id .lock() .expect("retired turn") @@ -796,9 +808,23 @@ impl FreshCodexState { return false; }; let request = &pending.requests[index]; + let Some(source) = request.source.upgrade() else { + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request belongs to an earlier native source.", + ); + return false; + }; if !Arc::ptr_eq(&client, &request.native_client) || !self - .control_scope_matches(session_id, &request.params, &request.native_client, false) + .control_scope_matches( + session_id, + &request.params, + &request.native_client, + &source, + false, + ) .await { self.emit_fresh_agent_error( diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index cca12b500..97eac6488 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -293,6 +293,160 @@ async fn task5_child_relationship_requires_the_exact_native_parent_sender() { state.shutdown().await; } +#[tokio::test] +async fn task5_child_control_survives_real_parent_display_eviction_without_restoring_rows() { + // Deliberate bounded continuation after the captured relationship, not a + // claim that the Task001 capture contains forty subsequent parent turns. + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":parent_turn,"status":"completed","items":[]}}), + ); + loop { + if event(&mut rx).await["type"] == "freshAgent.turn.complete" { + break; + } + } + for index in 0..40 { + let turn = format!("later-parent-turn-{index}"); + peer.emit_notification( + "turn/started", + json!({"threadId":parent,"turn":{"id":turn,"status":"inProgress","items":[]}}), + ); + peer.emit_notification("item/completed", json!({"threadId":parent,"turnId":turn,"item":{"id":format!("later-parent-item-{index}"),"type":"agentMessage","text":"Parent continuation"}})); + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":turn,"status":"completed","items":[]}}), + ); + loop { + let next = event(&mut rx).await; + assert_ne!(next["type"], "freshAgent.permission.cancelled"); + if next["type"] == "freshAgent.turn.complete" { + break; + } + } + } + let source = state.sessions.lock().await[parent].projection.clone(); + assert!( + !source.lock().unwrap().knows_child_thread(child), + "the actual projection must have evicted the old spawn payload" + ); + assert_eq!( + state.retained_control_events(parent).await[0]["requestId"], + request["requestId"] + ); + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await, "display retention must not invalidate the still-awaited child control"); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + assert!( + !source.lock().unwrap().knows_child_thread(child), + "answering must not revive the evicted display row" + ); + assert_eq!(state.sessions.lock().await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} + +async fn replace_child_control_source( + state: &FreshCodexState, + parent: &str, + frames: &[Value], +) -> Arc> { + let source = Arc::new(StdMutex::new(projection::CodexProjection::new( + "replacement-parent-source".into(), + parent.into(), + ))); + source + .lock() + .unwrap() + .apply(frames[0]["method"].as_str().unwrap(), &frames[0]["params"]) + .unwrap(); + state + .sessions + .lock() + .await + .get_mut(parent) + .unwrap() + .projection = source.clone(); + let child_start = freshell_codex::protocol::classify_notification( + frames[1]["method"].as_str().unwrap(), + Some(&frames[1]["params"]), + ); + state + .observe_child_control_turn(parent, &source, &child_start) + .await; + source +} + +#[tokio::test] +async fn task5_old_child_answer_cannot_cross_projection_replacement_with_reused_native_scope() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + let source = replace_child_control_source(&state, parent, &frames).await; + assert!(source + .lock() + .unwrap() + .knows_child_thread(frames[2]["params"]["threadId"].as_str().unwrap())); + assert!(!state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await, "the old operation must stay bound to its original projection even if a new source proves the same child IDs"); + assert_eq!(state.retained_control_events(parent).await.len(), 1); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_reissued_child_rpc_on_new_projection_gets_a_new_public_operation_key() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let original = child_approval_event(&mut rx).await; + let source = replace_child_control_source(&state, parent, &frames).await; + let notification = CodexNotification::ServerRequest { + id: RequestId::Int(0), + method: frames[2]["method"].as_str().unwrap().into(), + params: frames[2]["params"].clone(), + }; + assert!( + state + .consume_control_notification(parent, ¬ification, &source) + .await + ); + let current = state.retained_control_events(parent).await; + assert_eq!(current.len(), 1); + assert_ne!( + current[0]["requestId"], original["requestId"], + "source replacement must not reuse the old public answer key" + ); + assert_eq!(current[0]["providerRequest"]["id"], 0); + peer.disconnect(); + state.shutdown().await; +} + async fn event(rx: &mut tokio::sync::broadcast::Receiver) -> Value { let frame = tokio::time::timeout(Duration::from_secs(3), rx.recv()) .await From f1398b11cd5c38ae6364667527264162cf28e4d6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:29:19 -0700 Subject: [PATCH 25/92] fix(codex): refresh delivered elicitation controls --- .../fresh-agent/fresh-agent-event-classes.ts | 30 ++++++++----------- .../FreshAgentView.approvals.test.tsx | 22 ++++++++++++++ .../fresh-agent-event-classes.test.ts | 4 +++ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/components/fresh-agent/fresh-agent-event-classes.ts b/src/components/fresh-agent/fresh-agent-event-classes.ts index 3f74e3468..fda412add 100644 --- a/src/components/fresh-agent/fresh-agent-event-classes.ts +++ b/src/components/fresh-agent/fresh-agent-event-classes.ts @@ -7,18 +7,24 @@ export const FRESH_AGENT_BUSY_STATUSES: ReadonlySet = new Set(['running', 'compacting']) +// Pending approval/question/elicitation cards change the pane surface without +// making its transcript stale. Hidden panes owe a non-blocking reveal refresh. +export const SURFACE_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + 'freshAgent.permission.request', + 'freshAgent.permission.cancelled', + 'freshAgent.question.request', + 'freshAgent.question.cancelled', + 'freshAgent.elicitation.request', + 'freshAgent.elicitation.cancelled', +]) + // Events that re-drive the pane's snapshot while it is visible. export const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + ...SURFACE_INVALIDATING_FRESH_AGENT_EVENTS, 'freshAgent.session.changed', 'freshAgent.session.snapshot', 'freshAgent.result', 'freshAgent.turn.complete', - 'freshAgent.permission.request', - 'freshAgent.permission.cancelled', - 'freshAgent.question.request', - // A provider-cancelled question must also re-drive the snapshot, so the card clears - // even if the freshAgent.question.cancelled fold races (fresh-eyes round-3 F3). - 'freshAgent.question.cancelled', // kata 1wxv: rollback converges through the invalidating snapshot — the marker // bucket, the active prefix, and rollback{canRedo,undoneDepth} all ride it. 'freshAgent.session.rolledBack', @@ -46,18 +52,6 @@ export const TRANSCRIPT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = n 'freshAgent.redone', ]) -// Approval and question lifecycle events change only the pane surface (the -// cards rendered from the snapshot's pendingApprovals/pendingQuestions). A -// hidden pane owes a non-blocking refresh for them on reveal; they must not -// take the blocking transcript path, whose overlay clears only on a newer -// transcript revision that an approval never produces. -export const SURFACE_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ - 'freshAgent.permission.request', - 'freshAgent.permission.cancelled', - 'freshAgent.question.request', - 'freshAgent.question.cancelled', -]) - export type HiddenPaneEventClass = 'transcript' | 'surface' | 'none' function isRecord(value: unknown): value is Record { diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx index 3ffe5a20a..9ce34a4c0 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.approvals.test.tsx @@ -248,6 +248,28 @@ it('retains an MCP form from a snapshot and sends a separate typed answer', asyn expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.elicitation.respond', requestId: '41', action: 'accept', content: { token: ' exact, token! ' } })) }) +it.each(['freshAgent.elicitation.request', 'freshAgent.elicitation.cancelled'])('refreshes an idle native form from %s without waiting for a turn or poll', async (eventType) => { + const request = { requestId: 'live-elicitation', mode: 'form', serverName: 'contract', message: 'Live form', fields: [{ id: 'name', title: 'Live name', type: 'string', required: true }] } + const before = eventType === 'freshAgent.elicitation.request' ? [] : [request] + const after = eventType === 'freshAgent.elicitation.request' ? [request] : [] + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', pendingElicitations: before }) + const store = createStore(CODEX) + await mount(store) + expect(screen.queryAllByRole('region', { name: 'Request from contract' })).toHaveLength(before.length) + const reads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 2), status: 'idle', pendingElicitations: after }) + const message = { type: 'freshAgent.event', ...CODEX, event: { ...request, type: eventType } } + act(() => { + handleFreshAgentMessage(store.dispatch, message) + for (const handler of wsMock.handlers) handler(message) + }) + await advance(SNAPSHOT_DEBOUNCE_MS) + await advance(0) + expect(screen.queryAllByRole('region', { name: 'Request from contract' })).toHaveLength(after.length) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(reads + 1) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.elicitation.respond' || frame.type === 'freshAgent.send')).toHaveLength(0) +}) + it('native approvals expose only offered actions and no pane auto-grant', async () => { apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), pendingApprovals: [{ requestId: '41', toolName: 'Bash', native: { networkApprovalContext: { host: 'example.com', protocol: 'https' }, availableDecisions: ['accept', 'cancel'] } }] }) await mount(createStore(CODEX)) diff --git a/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts b/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts index e7551d466..4401e87dc 100644 --- a/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts +++ b/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts @@ -29,6 +29,8 @@ describe('classifyHiddenPaneEvent', () => { ['permission.cancelled', { type: 'freshAgent.permission.cancelled' }, 'surface'], ['question.request', { type: 'freshAgent.question.request' }, 'surface'], ['question.cancelled', { type: 'freshAgent.question.cancelled' }, 'surface'], + ['elicitation.request', { type: 'freshAgent.elicitation.request' }, 'surface'], + ['elicitation.cancelled', { type: 'freshAgent.elicitation.cancelled' }, 'surface'], // None: status-only frames never stale a hidden pane. ['idle snapshot', { type: 'freshAgent.session.snapshot', status: 'idle' }, 'none'], ['status', { type: 'freshAgent.status', status: 'running' }, 'none'], @@ -66,6 +68,8 @@ describe('isSnapshotInvalidatingFreshAgentEvent', () => { 'freshAgent.permission.cancelled', 'freshAgent.question.request', 'freshAgent.question.cancelled', + 'freshAgent.elicitation.request', + 'freshAgent.elicitation.cancelled', 'freshAgent.session.rolledBack', 'freshAgent.session.redone', 'freshAgent.rolledBack', From b63014a54135847b69b82fd7da369b19abed6e42 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:07:26 -0700 Subject: [PATCH 26/92] fix(codex): restore drafts after native session binding --- .../fresh-agent/FreshAgentComposer.tsx | 12 ++++++++++ .../fresh-agent/FreshAgentComposer.test.tsx | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/components/fresh-agent/FreshAgentComposer.tsx b/src/components/fresh-agent/FreshAgentComposer.tsx index 936253c37..cd88ac3b9 100644 --- a/src/components/fresh-agent/FreshAgentComposer.tsx +++ b/src/components/fresh-agent/FreshAgentComposer.tsx @@ -225,6 +225,7 @@ export const FreshAgentComposer = forwardRef(null) const [filter, setFilter] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(0) @@ -308,7 +309,18 @@ export const FreshAgentComposer = forwardRef { + const keyChanged = draftStorageKeyRef.current !== storageKey + draftStorageKeyRef.current = storageKey if (!storageKey || typeof window === 'undefined') return + if (keyChanged && !text) { + const saved = window.sessionStorage.getItem(storageKey) + if (saved) { + // Restores can bind the native session after this composer mounts. + // Hydrate its existing ordinary draft before an empty value can erase it. + setText((current) => current || saved) + return + } + } if (text) { window.sessionStorage.setItem(storageKey, text) } else { diff --git a/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx index f103e0541..34e5da4d6 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx @@ -34,6 +34,29 @@ describe('FreshAgentComposer', () => { }) afterEach(() => cleanup()) + it('loads a restored ordinary draft when the native storage key binds after mount without sending it', () => { + const onSend = vi.fn() + const draft = 'Answer to Codex question: {"itemId":"parent-answer","questionId":"0","question":"Where?","answer":"Staging"}' + window.sessionStorage.setItem('native-session-draft', draft) + const { rerender } = render() + expect(getInput()).toHaveValue('') + rerender() + expect(getInput()).toHaveValue(draft) + expect(window.sessionStorage.getItem('native-session-draft')).toBe(draft) + expect(onSend).not.toHaveBeenCalled() + }) + + it('keeps newer unsent text when the native storage key binds', () => { + window.sessionStorage.setItem('native-session-draft', 'An older stored draft') + const onSend = vi.fn() + const { rerender } = render() + fireEvent.change(getInput(), { target: { value: 'A newer unsent draft' } }) + rerender() + expect(getInput()).toHaveValue('A newer unsent draft') + expect(window.sessionStorage.getItem('native-session-draft')).toBe('A newer unsent draft') + expect(onSend).not.toHaveBeenCalled() + }) + it('blocks shell escapes without a Rust shell route', () => { const onSend = vi.fn() render() From cfc54c88ea61941eaf6130d4625e9e6370d56760 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:07:27 -0700 Subject: [PATCH 27/92] test(claude): await the persisted compact rollback point --- crates/freshell-freshagent/src/claude.rs | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index f9dd787b3..2807c726e 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -16296,6 +16296,71 @@ rl.on('line', (line) => { // 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; + // The simulator, like the CLI, flushes JSONL independently of result. + // Synchronize this fixture on its actual send identity and native point; + // a publicly advertised point without that backing is a product failure. + let compact_frames = env.request_frames_of_type("send", 1).await; + let compact_uuid = compact_frames[0]["uuid"] + .as_str() + .expect("compact send UUID") + .to_owned(); + let point = tokio::time::timeout(Duration::from_secs(15), async { + loop { + let public = st + .get_snapshot(SessionType::Freshclaude, &session_id) + .await + .ok(); + let advertised = public.as_ref().is_some_and(|snapshot| { + snapshot["turns"].as_array().is_some_and(|turns| { + turns.iter().any(|turn| turn["turnId"] == compact_uuid) + }) + }); + let saved = st + .effective_transcript(&session_id) + .await + .ok() + .filter(|saved| { + saved.source_path.is_some() + && saved + .text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .any(|line| line["type"] == "user" && line["uuid"] == compact_uuid) + }); + let point = saved.and_then(|saved| { + let exact = crate::claude_snapshot::resolve_resume_point( + &saved.text, + &session_id, + crate::claude_snapshot::ResumeTarget::ToTurn(compact_uuid.clone()), + ) + .ok()?; + let step = crate::claude_snapshot::resolve_resume_point( + &saved.text, + &session_id, + crate::claude_snapshot::ResumeTarget::Step, + ) + .ok()?; + assert_eq!(step.resume_at_uuid, exact.resume_at_uuid); + assert_eq!(step.prompt_text, exact.prompt_text); + Some(step) + }); + assert!( + !advertised || point.is_some(), + "public compact rollback point has no persisted native backing" + ); + if let Some(point) = point { + break point; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "compact send {compact_uuid} never became a persisted rollback point under {}", + env.dir.display() + ) + }); let (sink2, captured2) = capturing_sink(); st.handle_rollback( rollback_op(&session_id, "rb-compact-2", RollbackDirection::Undo), @@ -16307,6 +16372,15 @@ rl.on('line', (line) => { frames2[0]["event"]["type"], "freshAgent.rolledBack", "{frames2:?}" ); + assert_eq!(frames2[0]["event"]["removedPromptText"], point.prompt_text); + assert_eq!( + frames2[0]["event"]["removedTurnIds"], + json!(point + .removed_turns + .iter() + .filter_map(|turn| turn["turnId"].as_str()) + .collect::>()) + ); let replacement = frames2[0]["event"]["newSessionId"].as_str().unwrap(); assert_eq!( st.resolve_session_key(&session_id).await.as_deref(), From b5c709c18009350742ba005c9ddd1a7d882621d3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:04:59 -0700 Subject: [PATCH 28/92] test(codex): cover native approval refusal and details --- .../src/codex/controls/tests.rs | 70 +++++++++++++++++++ .../FreshAgentApprovalCard.test.tsx | 68 ++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index 97eac6488..25ec08072 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -836,6 +836,76 @@ async fn task5_approval_cannot_grant_a_decision_native_did_not_offer() { state.shutdown().await; } +#[tokio::test] +async fn task5_approval_without_offered_decisions_refuses_session_grant_and_allows_retry() { + let (state, peer, mut rx) = runtime().await; + for native_id in [RequestId::Int(92), RequestId::Str("92".into())] { + peer.request_client( + &native_id, + "item/commandExecution/requestApproval", + json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command-92","command":"npm test"}), + ); + let request = event(&mut rx).await; + assert_eq!(request["type"], "freshAgent.permission.request"); + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + let before = snapshot(&state, &peer).await; + assert_eq!(before["pendingApprovals"].as_array().unwrap().len(), 1); + assert_eq!( + before["pendingApprovals"][0]["requestId"], + request["requestId"] + ); + assert_eq!( + before["pendingApprovals"][0]["providerRequest"]["id"], + native_id.to_json() + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + let response = |decision| { + serde_json::from_value(json!({ + "provider":"codex","sessionType":"freshcodex","sessionId":"thread-1", + "requestId":request["requestId"],"decision":{"nativeDecision":decision} + })) + .unwrap() + }; + + state + .handle_approval_respond(response("acceptForSession")) + .await; + let refused = event(&mut rx).await; + assert_eq!(refused["type"], "freshAgent.error"); + assert_eq!(refused["code"], "INVALID_ANSWER"); + assert_eq!(refused["sessionId"], "thread-1"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "an unoffered session grant must not answer the native RPC" + ); + assert_eq!( + snapshot(&state, &peer).await["pendingApprovals"], + before["pendingApprovals"] + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + + state.handle_approval_respond(response("accept")).await; + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), peer.next_raw_frame()) + .await + .expect("the supported retry answers the original native RPC") + .unwrap(), + json!({"id":native_id.to_json(),"result":{"decision":"accept"}}) + ); + let cancelled = event(&mut rx).await; + assert_eq!(cancelled["type"], "freshAgent.permission.cancelled"); + assert_eq!(cancelled["requestId"], request["requestId"]); + assert_eq!(snapshot(&state, &peer).await["pendingApprovals"], json!([])); + assert_eq!(state.awaited_control_count("thread-1"), 0); + } + peer.disconnect(); + tokio::time::timeout(Duration::from_secs(3), state.shutdown()) + .await + .expect("owned controls fixture shuts down"); +} + #[tokio::test] async fn task5_stale_turn_control_is_refused_before_it_becomes_answerable() { let (state, peer, mut rx) = runtime().await; diff --git a/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx index 46f626788..8a449cd82 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx @@ -97,4 +97,72 @@ describe('FreshAgentApprovalCard', () => { for (const button of screen.getAllByRole('button')) expect(button).toBeDisabled() }) + it('identifies native stdin writes and sends only the offered native decisions', () => { + const onAlwaysAllow = vi.fn() + const { onRespond } = renderCard({ + requestId: 'stdin-approval', + toolName: 'Bash', + native: { kind: 'writeStdin', command: 'python interactive.py', availableDecisions: ['accept', 'cancel'] }, + }, { onAlwaysAllow }) + const card = within(screen.getByRole('alert', { name: 'Permission request for Bash' })) + + expect(card.getByText('Write to a running command')).toBeVisible() + expect(card.getAllByRole('button').map((button) => button.textContent)).toEqual(['Allow', 'Stop']) + expect(card.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + fireEvent.click(card.getByRole('button', { name: 'Allow', exact: true })) + fireEvent.click(card.getByRole('button', { name: 'Stop', exact: true })) + + expect(onRespond.mock.calls).toEqual([[{ nativeDecision: 'accept' }], [{ nativeDecision: 'cancel' }]]) + expect(onAlwaysAllow).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'permissions', + toolName: 'Additional permissions', + details: { permissions: { fileSystem: { entries: [ + { path: { type: 'path', path: '/srv/reports' }, access: 'read' }, + { path: { type: 'path', path: '/srv/output' }, access: 'write' }, + ] }, network: { enabled: true } } }, + offered: ['accept', 'decline'], + actions: [{ label: 'Allow', decision: 'accept' }, { label: 'Decline', decision: 'decline' }], + }, + { + name: 'additionalPermissions', + toolName: 'Bash', + details: { additionalPermissions: { fileSystem: { entries: [ + { path: { type: 'path', path: '/var/cache/build' }, access: 'write' }, + { path: { type: 'glob_pattern', pattern: '/srv/private/**' }, access: 'deny' }, + ] }, network: { enabled: false } } }, + offered: ['accept', 'cancel', { acceptWithExecpolicyAmendment: { execpolicyAmendment: ['build'] } }], + actions: [{ label: 'Allow', decision: 'accept' }, { label: 'Stop', decision: 'cancel' }], + }, + { + name: 'grantRoot', + toolName: 'Edit', + details: { path: '/srv/generated' }, + offered: ['acceptForSession', 'decline'], + actions: [{ label: 'Allow for this session', decision: 'acceptForSession' }, { label: 'Decline', decision: 'decline' }], + }, + ])('renders requested $name details and preserves native action callbacks', ({ name, toolName, details, offered, actions }) => { + const onAlwaysAllow = vi.fn() + const nativeDetails = name === 'grantRoot' ? { grantRoot: details.path } : details + const { onRespond } = renderCard({ + requestId: `detail-${name}`, + toolName, + native: { ...nativeDetails, availableDecisions: offered }, + }, { onAlwaysAllow }) + const card = within(screen.getByRole('alert', { name: `Permission request for ${toolName}` })) + const preview = card.getByText((_content, element) => element?.tagName === 'PRE') + + expect(preview).toBeVisible() + expect(JSON.parse(preview.textContent ?? '')).toEqual(details) + expect(card.getAllByRole('button').map((button) => button.textContent)).toEqual(actions.map((action) => action.label)) + expect(card.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + for (const action of actions) fireEvent.click(card.getByRole('button', { name: action.label, exact: true })) + + expect(onRespond.mock.calls).toEqual(actions.map((action) => [{ nativeDecision: action.decision }])) + expect(onAlwaysAllow).not.toHaveBeenCalled() + }) + }) From 71d9c48f6a5e0b7915b8b92ec1f031f5214d011e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:10:07 -0700 Subject: [PATCH 29/92] test: settle successful Claude captures before closing --- test/helpers/claude-session-capture.ts | 34 +++++++++ .../providers/claude-cli-contract.test.ts | 19 +++-- .../providers/claude-cli-scenarios.mjs | 32 +++++++-- .../claude-scenario-drain.test.ts | 53 +++++++------- .../claude-session-settlement.test.ts | 70 +++++++++++++++++++ 5 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 test/helpers/claude-session-capture.ts create mode 100644 test/unit/provider-fixtures/claude-session-settlement.test.ts diff --git a/test/helpers/claude-session-capture.ts b/test/helpers/claude-session-capture.ts new file mode 100644 index 000000000..506517cf8 --- /dev/null +++ b/test/helpers/claude-session-capture.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest' +import { Session } from '../integration/providers/claude-cli-scenarios.mjs' + +/** The real capture session consuming a controlled SDK iterator; no provider process. */ +export function createClaudeCapture() { + const queue: any[] = [] + let wake: (() => void) | undefined + let ended = false + const query = { + async *[Symbol.asyncIterator]() { + while (!ended || queue.length) { + if (queue.length) yield queue.shift() + else await new Promise(resolve => { wake = resolve }) + } + }, + close: vi.fn(() => { ended = true; wake?.() }), + } + const events: any[] = [] + const session = new Session({ + query: () => query, + fixture: { cwd: '/fixture/workspace', env: () => ({}) }, + provider: { url: 'http://127.0.0.1/fixture' }, + trace: { add: (event: unknown) => events.push(event) }, + uuid: (label: string) => label, + }, 'S') + const deliver = async (...messages: any[]) => { + for (const message of messages) { + queue.push(message) + wake?.() + await session.waitFor((candidate: unknown) => candidate === message) + } + } + return { session, query, events, deliver } +} diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 59bda460f..ec9742543 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -66,6 +66,16 @@ function results(result: ScenarioResult, session: string) { return messagesOf(result, session).filter(isResult) } +function expectSuccessfulClose(result: ScenarioResult, session: string, uuid: string) { + const events = eventsOf(result, session) + const close = at(events, event => event.k === 'close') + const terminal = events.slice(0, close).filter(event => event.k === 'msg') + .map(event => event.m).filter(message => isResult(message) || message.type === 'command_lifecycle' || isState('idle')(message)) + .slice(-3) + expect(terminal.map(message => isResult(message) ? 'result' : message.state)).toEqual(['result', 'completed', 'idle']) + expect(terminal[1].command_uuid).toBe(uuid) +} + describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback provider)`, () => { it('the pinned SDK and its bundled CLI are installed and report the pinned versions', async () => { const pinned = await resolvePinnedClaude(REPO_ROOT) @@ -137,6 +147,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p it('T4: a fresh boot with an id whose transcript exists is refused: "Session ID X is already in use." and exit code 1', async () => { const r = await real('session-in-use') + expectSuccessfulClose(r, 'seed', 'u1') const events = eventsOf(r, 'S') expect(r.observations.rejected.stderr).toContain('Session ID X is already in use.') expect(r.observations.rejected.childExit).toEqual({ code: 1, signal: null }) @@ -504,13 +515,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(results(r, 'S').map((m) => m.subtype)).toEqual(['success']) expect(results(r, 'M').map((m) => m.subtype)).toEqual(['success']) for (const [session, uuid] of [['S', 'u1'], ['M', 'u2']]) { - const events = eventsOf(r, session) - const close = at(events, event => event.k === 'close') - const terminal = events.slice(0, close).filter(event => event.k === 'msg') - .map(event => event.m).filter(message => isResult(message) || message.type === 'command_lifecycle' || isState('idle')(message)) - .slice(-3) - expect(terminal.map(message => isResult(message) ? 'result' : message.state)).toEqual(['result', 'completed', 'idle']) - expect(terminal[1].command_uuid).toBe(uuid) + expectSuccessfulClose(r, session, uuid) } }, SCENARIO_TIMEOUT) }) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index ca59799bd..08fd0df0b 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -116,7 +116,7 @@ function createInput() { } } -class Session { +export class Session { constructor(ctx, label, { options = {}, env = {}, permissions } = {}) { this.ctx = ctx this.label = label @@ -303,8 +303,29 @@ class Session { return this.child?.pid } - async close() { - if (!this.closed) this.ctx.trace.add({ s: this.label, k: 'close' }) + close() { + this.closing ??= this.closeAfterSuccessfulDrain() + return this.closing + } + + async closeAfterSuccessfulDrain() { + // A successful result is followed by command completion and the queue's + // idle frame on a later pipe delivery. Keep that tail inside the capture. + // Cancellation/error results and deliberate mid-turn closes retain their + // immediate-close semantics; they do not promise this successful drain. + const resultAt = this.frames.findLastIndex((m) => m.type === 'result' && !m.parent_tool_use_id) + const result = this.frames[resultAt] + const startedAt = this.frames.findLastIndex((m) => !m.parent_tool_use_id && ( + (m.type === 'command_lifecycle' && m.state === 'started') + || (m.type === 'system' && (m.subtype === 'init' || (m.subtype === 'session_state_changed' && m.state === 'running'))))) + let drainError + if (result?.subtype === 'success' && result.is_error === false + && (result.terminal_reason === undefined || result.terminal_reason === 'completed') && startedAt < resultAt) { + const drained = await this.waitFor((m) => isStateIdle(m) && this.frames.indexOf(m) > resultAt, + 10_000, 'successful result idle') + if (!drained) drainError = new Error(`successful result for session ${result.session_id} did not reach idle within 10000ms`) + } + this.ctx.trace.add({ s: this.label, k: 'close' }) this.closed = true this.input.end() try { this.query.close() } catch { /* already closed */ } @@ -313,6 +334,7 @@ class Session { this.child.kill('SIGKILL') await Promise.race([new Promise((resolve) => this.child.once('exit', resolve)), sleep(3000)]) } + if (drainError) throw drainError } } @@ -768,9 +790,6 @@ export const SCENARIOS = { }) s.push('MCP:call=mcp__freshell__context', 'u1') await s.waitForResults(1, 30_000) - // A result precedes command completion and the queue's idle frame. Closing - // here would truncate the real trace according to pipe delivery timing. - ctx.need(await s.waitForCount(isStateIdle, 1, 10_000, 'MCP connected idle'), 'MCP connected idle') ctx.observe('connected', mcpTurnShape(s)) const seen = JSON.parse(await fs.readFile(report, 'utf8')) delete seen.pid @@ -780,7 +799,6 @@ export const SCENARIOS = { const m = ctx.open('M') m.push('MCP:call=mcp__freshell__freshell', 'u2') await m.waitForResults(1, 30_000) - ctx.need(await m.waitForCount(isStateIdle, 1, 10_000, 'MCP missing tool idle'), 'MCP missing tool idle') ctx.observe('missing', mcpTurnShape(m)) await m.close() }, diff --git a/test/unit/provider-fixtures/claude-scenario-drain.test.ts b/test/unit/provider-fixtures/claude-scenario-drain.test.ts index 6768dc586..9aa831b28 100644 --- a/test/unit/provider-fixtures/claude-scenario-drain.test.ts +++ b/test/unit/provider-fixtures/claude-scenario-drain.test.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { expect, it } from 'vitest' import { SCENARIOS } from '../../integration/providers/claude-cli-scenarios.mjs' +import { createClaudeCapture } from '../../helpers/claude-session-capture.js' function deferred() { let resolve!: (value: T) => void @@ -15,10 +16,13 @@ it('captures each MCP turn through its terminal idle before closing either SDK q const root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-scenario-drain-')) await fs.writeFile(path.join(root, 'mcp-probe-report.json'), '{}') const sessions = [makeSession(), makeSession()] + for (const session of sessions) await session.deliver({ + type: 'result', subtype: 'success', is_error: false, terminal_reason: 'completed', + }) let opened = 0 const capturing = SCENARIOS.mcp({ fixture: { root }, - open: () => sessions[opened++], + open: () => sessions[opened++].session, observe: () => {}, need(value: unknown, what: string) { if (!value) throw new Error(`scenario prerequisite missing: ${what}`) @@ -28,50 +32,43 @@ it('captures each MCP turn through its terminal idle before closing either SDK q try { for (const session of sessions) { // The result is already available; only the native drain is withheld. - expect(await session.boundary.promise).toBe('await-idle') - expect(session.closed).toBe(false) - session.releaseIdle() + await session.boundary.promise + expect(session.query.close).not.toHaveBeenCalled() + await session.releaseIdle() await session.didClose.promise expect(session.capturedAtClose).toEqual(['result', 'completed', 'idle']) } await capturing } finally { - for (const session of sessions) session.releaseIdle() + for (const session of sessions) await session.releaseIdle() await capturing.finally(() => fs.rm(root, { recursive: true, force: true })) } }) function makeSession() { - const boundary = deferred() - const idle = deferred() + const capture = createClaudeCapture() + const boundary = deferred() const didClose = deferred() - const frames: any[] = [{ type: 'result' }] - return { + const originalClose = capture.session.close.bind(capture.session) + const observed = { + ...capture, boundary, didClose, - closed: false, capturedAtClose: [] as string[], - push() {}, - messages(predicate: (message: any) => boolean) { return frames.filter(predicate) }, - async waitForResults() { return frames[0] }, - async waitForCount(predicate: (message: any) => boolean, count: number) { - boundary.resolve('await-idle') - await idle.promise - const matches = frames.filter(predicate) - return matches.length >= count ? matches.at(-1) : null - }, - releaseIdle() { - if (frames.length === 1) frames.push( + async releaseIdle() { + if (capture.session.frames.length !== 1) return + await capture.deliver( { type: 'command_lifecycle', state: 'completed' }, { type: 'system', subtype: 'session_state_changed', state: 'idle' }, ) - idle.resolve() - }, - async close() { - this.closed = true - this.capturedAtClose = frames.map(message => message.state ?? message.type) - boundary.resolve('closed-before-idle') - didClose.resolve() }, } + capture.session.close = async () => { + const closing = originalClose() + boundary.resolve() + await closing + observed.capturedAtClose = capture.events.filter(event => event.k === 'msg').map(event => event.m.state ?? event.m.type) + didClose.resolve() + } + return observed } diff --git a/test/unit/provider-fixtures/claude-session-settlement.test.ts b/test/unit/provider-fixtures/claude-session-settlement.test.ts new file mode 100644 index 000000000..4bcc08135 --- /dev/null +++ b/test/unit/provider-fixtures/claude-session-settlement.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from 'vitest' +import { createClaudeCapture as capture } from '../../helpers/claude-session-capture.js' + +afterEach(() => { vi.useRealTimers() }) + +const running = { type: 'system', subtype: 'session_state_changed', state: 'running' } +const idle = { type: 'system', subtype: 'session_state_changed', state: 'idle' } +const completed = { type: 'command_lifecycle', command_uuid: 'u1', state: 'completed' } +const success = { type: 'result', subtype: 'success', is_error: false, terminal_reason: 'completed', user_message_uuid: 'u1', session_id: 'session-X' } + +it.each([false, true])('keeps a successful result capture open through completed and a later idle (prior idle: %s)', async (priorIdle) => { + const { session, query, events, deliver } = capture() + if (priorIdle) await deliver(idle) + await deliver(running, success) + const closing = session.close() + try { + await Promise.resolve() + expect(query.close).not.toHaveBeenCalled() + expect(events.some(event => event.k === 'close')).toBe(false) + await deliver(completed) + expect(query.close).not.toHaveBeenCalled() + await deliver({ ...idle }) + await closing + const closeAt = events.findIndex(event => event.k === 'close') + expect(events.slice(0, closeAt).filter(event => event.k === 'msg').slice(-3).map(event => event.m)) + .toEqual([success, completed, idle]) + expect(query.close).toHaveBeenCalledOnce() + } finally { + query.close() + await closing + } +}) + +it.each([ + { type: 'result', subtype: 'error_during_execution', is_error: true, terminal_reason: 'aborted_streaming' }, + { type: 'result', subtype: 'error_during_execution', is_error: true, terminal_reason: 'aborted_tools' }, + { ...success, is_error: true, terminal_reason: 'api_error' }, + { type: 'result', subtype: 'error_during_execution', is_error: true, num_turns: 0 }, +])('preserves deliberate close after $subtype/$terminal_reason without requiring successful idle', async (result) => { + const { session, query, events, deliver } = capture() + await deliver(result) + await session.close() + expect(query.close).toHaveBeenCalledOnce() + expect(events.filter(event => event.k === 'msg').map(event => event.m)).toEqual([result]) + expect(events.some(event => event.k === 'wait-missed')).toBe(false) +}) + +it('closes an unfinished new turn without waiting on a previous successful result', async () => { + const { session, query, events, deliver } = capture() + // Queued turns start after the previous result with no idle between them. + await deliver(running, success, completed, + { type: 'command_lifecycle', command_uuid: 'u2', state: 'started' }) + await session.close() + expect(query.close).toHaveBeenCalledOnce() + expect(events.some(event => event.k === 'wait-missed')).toBe(false) +}) + +it('fails a missing successful idle within a finite deadline and still closes the owned query', async () => { + vi.useFakeTimers() + const { session, query, events, deliver } = capture() + await deliver(running, success, completed) + const closing = session.close() + const rejected = expect(closing).rejects.toThrow(/successful result.*idle/) + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(10_000) + await rejected + expect(query.close).toHaveBeenCalledOnce() + expect(events.some(event => event.k === 'wait-missed')).toBe(true) +}) From 47b8bda4b759630542a89b92911d25b660786b2f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:56:54 -0700 Subject: [PATCH 30/92] fix(codex): recover retained state after hosted event loss --- Cargo.lock | 1 + crates/freshell-agent-runtime/Cargo.toml | 1 + .../freshell-agent-runtime/src/host_actor.rs | 55 +- .../src/host_actor_tests.rs | 113 +++ crates/freshell-freshagent/src/codex.rs | 210 +++++- .../freshell-freshagent/src/codex/controls.rs | 158 +++- .../src/codex/controls/tests.rs | 262 +++++++ .../src/codex/projection.rs | 100 ++- crates/freshell-runtime-client/src/lib.rs | 80 ++ crates/freshell-runtime-protocol/src/lib.rs | 2 + .../freshell-server/src/fresh_agent_proxy.rs | 219 +++++- .../src/fresh_agent_proxy_tests.rs | 127 ++++ crates/freshell-session-host/src/main.rs | 4 +- .../src/providers/fresh_agent.rs | 700 +++++++++++++++++- crates/freshell-supervisor/src/service.rs | 35 +- crates/freshell-ws/src/hosted_fresh_agent.rs | 10 + crates/freshell-ws/src/lib.rs | 1 + .../freshagent_codex_attention_replay.rs | 220 +++++- shared/fresh-agent-contract.ts | 1 + src/components/fresh-agent/FreshAgentView.tsx | 31 +- .../fresh-agent/fresh-agent-event-classes.ts | 4 + src/lib/fresh-agent-transcript-projection.ts | 5 + src/lib/fresh-agent-ws.ts | 41 +- src/store/freshAgentLiveSlice.ts | 29 +- src/store/freshAgentLiveTypes.ts | 8 +- .../freshcodex-retained-recovery-rust.spec.ts | 73 ++ .../fresh-agent/FreshAgentView.test.tsx | 179 +++++ test/unit/client/lib/fresh-agent-ws.test.ts | 31 + 28 files changed, 2625 insertions(+), 75 deletions(-) create mode 100644 test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 727bd8b4a..69dd22c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1292,6 +1292,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] diff --git a/crates/freshell-agent-runtime/Cargo.toml b/crates/freshell-agent-runtime/Cargo.toml index 25f21e612..a9409c4fc 100644 --- a/crates/freshell-agent-runtime/Cargo.toml +++ b/crates/freshell-agent-runtime/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } rusqlite = { version = "0.31", features = ["bundled"] } thiserror = "2" async-trait = "0.1" +tracing = "0.1" sha2 = "0.10" tokio = { version = "1", features = ["rt", "sync"] } diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 9874ffa96..fc989c856 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -301,6 +301,10 @@ pub trait FreshAgentTransport: Send + Sync { async fn capture(&self, _max_bytes: usize) -> Result { Err("provider does not expose a hosted snapshot".into()) } + /// Optional authoritative in-memory source. No native RPC or runtime creation. + async fn retained_state(&self) -> Result, String> { + Ok(None) + } async fn snapshot(&self) -> Result { Err("provider does not expose a hosted snapshot".into()) } @@ -1169,7 +1173,16 @@ impl FreshAgentHostActor { Ok(value) } - pub async fn record_event(&self, event: AgentEvent) -> Result { + pub async fn record_event(&self, mut event: AgentEvent) -> Result { + // The provider owns retained bodies. The bounded journal keeps only the + // invalidation edge; reads reconcile against the current source. + if let AgentEvent::Provider { payload } = &mut event { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + if let Some(event) = payload["event"].as_object_mut() { + event.remove("retainedState"); + } + } + } let mut state = self.state.lock().await; let sequence = push_event_bounded(&mut state, event, self.event_byte_cap, self.event_count_cap)?; @@ -1313,20 +1326,34 @@ impl FreshAgentHostActor { } pub async fn read_events(&self, after: u64, max_events: usize) -> EventBatch { - let state = self.state.lock().await; - let max_events = max_events.clamp(1, self.event_count_cap); - EventBatch { - retained_from: state.retained_from, - head: state.next_sequence.saturating_sub(1), - reset_required: after.saturating_add(1) < state.retained_from, - events: state - .events - .iter() - .filter(|event| event.sequence > after) - .take(max_events) - .cloned() - .collect(), + let mut batch = { + let state = self.state.lock().await; + let max_events = max_events.clamp(1, self.event_count_cap); + EventBatch { + retained_state: None, + retained_from: state.retained_from, + head: state.next_sequence.saturating_sub(1), + reset_required: after.saturating_add(1) < state.retained_from, + events: state + .events + .iter() + .filter(|event| event.sequence > after) + .take(max_events) + .cloned() + .collect(), + } + }; + if after == 0 || batch.reset_required || batch.events.iter().any(|entry| + matches!(&entry.event, AgentEvent::Provider { payload } if payload["event"]["type"] == "freshAgent.host.backpressure")) { + batch.retained_state = match self.transport.retained_state().await { + Ok(retained) => retained, + Err(error) => { + tracing::warn!(%error, "freshagent.host.retained_read_failed"); + None + } + }; } + batch } pub async fn interrupt(&self) -> Result<(), ActorError> { diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 763498ed2..7ee9f1e92 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -125,6 +125,16 @@ impl FreshAgentTransport for SnapshotTransport { } Ok(self.value.clone()) } + async fn retained_state(&self) -> Result, String> { + let retained = self.value.get("retainedState").cloned(); + if retained.is_some() { + if let Some((entered, release)) = &self.pause { + entered.notify_one(); + release.notified().await; + } + } + Ok(retained) + } async fn read_child(&self, _: &str) -> Result { self.snapshot().await } @@ -2007,3 +2017,106 @@ async fn configure_without_an_effort_keeps_the_profile_on_the_providers_effort() ); } } + +#[tokio::test] +async fn task6_covered_head_precedes_retained_await_so_a_final_event_remains_readable() { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":{"snapshot":{"turns":[]}}}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("native-held".into()), + registered: None, + pause: Some((entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "held-source", None), + transport, + ) + .await + .unwrap(); + actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.status","status":"running"}})}).await.unwrap(); + let read = tokio::spawn({ + let actor = actor.clone(); + async move { actor.read_events(0, 64).await } + }); + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let final_sequence=actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.turn.complete","at":200}})}).await.unwrap(); + release.notify_one(); + let first = read.await.unwrap(); + assert!( + first.head < final_sequence, + "the covered head must be captured before the held provider read" + ); + assert_eq!( + first.retained_state.as_ref().unwrap()["snapshot"]["turns"], + serde_json::json!([]) + ); + let next = actor.read_events(first.head, 64).await; + assert!(next.events.iter().any(|entry|entry.sequence==final_sequence && matches!(&entry.event,AgentEvent::Provider{payload} if payload["event"]["type"]=="freshAgent.turn.complete")),"advancing through the retained prefix must not skip the final concurrent event"); + actor.stop().await.unwrap(); +} + +#[tokio::test] +async fn task6_default_unsupported_provider_keeps_the_existing_event_wire_shape() { + let dir = tempfile::tempdir().unwrap(); + let transport = FakeTransport::new("legacy-native"); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Claude, "legacy", None), + transport.clone(), + ) + .await + .unwrap(); + let batch = actor.read_events(0, 64).await; + assert!(batch.retained_state.is_none()); + assert!(serde_json::to_value(batch) + .unwrap() + .get("retainedState") + .is_none()); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + actor.stop().await.unwrap(); +} + +#[tokio::test] +async fn task6_large_retained_body_stays_out_of_the_small_durable_journal() { + let dir = tempfile::tempdir().unwrap(); + let body = "retained body ".repeat(200_000); + let retained = serde_json::json!({"snapshot":{"turns":[{"text":body}]}}); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":retained}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("body-native".into()), + registered: None, + pause: None, + }); + let actor = FreshAgentHostActor::open_with_bounds( + dir.path(), + profile(FreshProvider::Codex, "body-store", None), + transport, + 1024, + 8, + ) + .await + .unwrap(); + actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.host.backpressure","retainedState":retained}})}).await.expect("a multi-MiB retained body must not consume the 1KiB event journal budget"); + let batch = actor.read_events(0, 8).await; + assert!( + batch.retained_state == Some(retained), + "the same body is reconstructed from the provider source on read" + ); + assert!( + std::fs::metadata(dir.path().join("fresh-agent-state.json")) + .unwrap() + .len() + < 64 * 1024, + "only the small recovery edge belongs in durable actor state" + ); + actor.stop().await.unwrap(); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index f771f1da4..5b2884ba2 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -367,6 +367,8 @@ pub struct FreshCodexState { /// threadId → live codex session (client + settings + owned sidecar). sessions: Arc>>, child_reads: Arc>>, + retained_changed: Arc>, + attention: Arc, /// The `settings.freshAgent.enabled` gate the WS `freshAgent.create` requires /// (default off; flipped true by `PATCH /api/settings`, as a real freshcodex user does). fresh_agent_enabled: Arc, @@ -804,6 +806,8 @@ impl FreshCodexState { broadcast_tx, sessions: Arc::new(TokioMutex::new(HashMap::new())), child_reads: Arc::new(TokioMutex::new(HashMap::new())), + retained_changed: Arc::new(tokio::sync::watch::channel(0).0), + attention: Arc::new(crate::attention_ledger::AttentionLedger::default()), fresh_agent_enabled: Arc::new(AtomicBool::new(enabled)), settings: Arc::new(TokioMutex::new(settings)), auth_token, @@ -1848,7 +1852,134 @@ impl FreshCodexState { } } + async fn retain_native_exit(&self, session_id: &str, ownership_id: &str) { + let projection = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| session.sidecar_ownership_id == ownership_id) + .map(|session| session.projection.clone()); + if let Some(projection) = projection { + projection + .lock() + .expect("codex projection lock") + .retire_source(); + self.clear_source_controls(session_id, &projection).await; + } + } + + fn notify_retained(&self) { + self.retained_changed + .send_modify(|version| *version = version.wrapping_add(1)); + } + + /// A coalesced wakeup, never a history cursor or a provider RPC. + pub fn subscribe_retained_state(&self, _session_id: &str) -> tokio::sync::watch::Receiver { + self.retained_changed.subscribe() + } + + /// Current native facts, including unfinished event-owned output. Reading + /// this source cannot create/resume a runtime or wait for native history. + pub async fn retained_state(&self, session_id: &str) -> Result { + let (projection, turns, exited, history_mode, quiet) = self + .sessions + .lock() + .await + .get(session_id) + .map(|s| { + ( + s.projection.clone(), + s.turn_state.clone(), + s.exited.clone(), + s.history_mode, + s.quiet_deadman.clone(), + ) + }) + .ok_or(CodexSnapshotError::NotFound)?; + let owner = self.ownership_snapshot(PROVIDER, session_id); + let stuck = quiet.lock().expect("quiet deadman lock").is_stuck(); + let mut snapshot = { + let p = projection.lock().expect("codex projection lock"); + let mut retained = p + .retained_snapshot(exited.load(Ordering::SeqCst)) + .map_err(|_| CodexSnapshotError::NotFound)?; + if let Some(status) = p.live_status() { + if matches!(status, "idle" | "running" | "stuck") { + retained.raw["thread"]["status"] = + json!({"type":if status == "idle" {"idle"} else {"active"}}); + } + } + let mut snapshot = build_codex_snapshot_json( + session_id, + &retained.raw, + false, + history_mode, + None, + stuck, + ) + .map_err(CodexSnapshotError::Protocol)?; + p.overlay(&mut snapshot, retained.metadata); + snapshot + }; + if turns.lock().expect("codex turn state").has_owed_work() && !exited.load(Ordering::SeqCst) + { + snapshot["status"] = json!(if stuck { "stuck" } else { "running" }); + snapshot["capabilities"]["send"] = json!(false); + snapshot["capabilities"]["interrupt"] = json!(true); + } + if exited.load(Ordering::SeqCst) { + snapshot["status"] = json!("exited"); + } + self.overlay_controls(session_id, &mut snapshot).await; + let pending = self.retained_control_events(session_id).await; + let marks = self + .attention_marks() + .await + .into_iter() + .filter(|m| m.session_id == session_id) + .collect::>(); + let after = self.ownership_snapshot(PROVIDER, session_id); + if owner.epoch != after.epoch + || owner.generation != after.generation + || !self + .sessions + .lock() + .await + .get(session_id) + .is_some_and(|s| Arc::ptr_eq(&s.projection, &projection)) + { + return Err(CodexSnapshotError::HandoffInProgress { + generation: after.generation, + }); + } + Ok( + json!({"source":{"projectionId":snapshot["extensions"]["codex"]["projection"]["id"],"ownerEpoch":owner.epoch,"ownerGeneration":owner.generation},"snapshot":snapshot,"attentionMarks":marks,"pendingControls":pending}), + ) + } + fn broadcast(&self, msg: &ServerMessage) { + match msg { + ServerMessage::FreshAgentCreated(created) => { + self.attention.observe(&created.session_id, SESSION_TYPE) + } + ServerMessage::FreshAgentSessionMaterialized(materialized) => { + self.attention + .alias(&materialized.previous_session_id, &materialized.session_id); + let mut controls = self.controls.lock().expect("codex controls registry"); + if let Some(retained) = controls.get(&materialized.previous_session_id).cloned() { + controls.insert(materialized.session_id.clone(), retained); + } + } + ServerMessage::FreshAgentKilled(killed) if killed.success => { + self.attention.forget(&killed.session_id) + } + ServerMessage::FreshAgentRecoveryStopped(stopped) if stopped.success => { + self.attention.forget(&stopped.session_id) + } + _ => {} + } + self.notify_retained(); if let Ok(frame) = serde_json::to_string(msg) { let _ = self.broadcast_tx.send(frame); } @@ -2887,6 +3018,7 @@ impl FreshCodexState { Arc::clone(&self.condemned_priors), self.ownership_watch(), Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( @@ -3622,6 +3754,7 @@ impl FreshCodexState { tracing::warn!(error = %err, session = %session_id, "freshagent.codex.destroy_redo_on_submit_failed"); } + let contextual_answers = self.contextual_answer_targets(&session_id, &msg.text).await; turn_in_flight.store(true, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); let result = if let Some(expected) = expected_turn_id.as_deref() { @@ -3675,6 +3808,8 @@ impl FreshCodexState { return; } }; + self.acknowledge_contextual_answers(&session_id, contextual_answers) + .await; drop(ownership_guard); // Only persist choices once the provider has accepted them. Failed sends @@ -6946,6 +7081,19 @@ impl FreshCodexState { mint_ownership_id(), session_id.to_string(), ))); + let prior_projection = self + .sessions + .lock() + .await + .get(session_id) + .map(|session| session.projection.clone()); + if let Some(prior) = prior_projection { + projection + .lock() + .expect("codex projection lock") + .inherit_finite_history(&prior.lock().expect("prior codex projection lock")); + } + let exited = Arc::new(AtomicBool::new(false)); let consumer = self.spawn_consumer( notifs, @@ -6984,6 +7132,7 @@ impl FreshCodexState { Arc::clone(&self.condemned_priors), self.ownership_watch(), Some(turn_state.clone()), + Some(self.clone()), ); // `HashMap::insert` on an existing key overwrites in place, dropping the old (dead @@ -7304,6 +7453,7 @@ impl FreshCodexState { Arc::clone(&self.condemned_priors), self.ownership_watch(), Some(turn_state.clone()), + Some(self.clone()), ); { @@ -7726,6 +7876,19 @@ impl FreshCodexState { // The deadman feed needs the state handle (window config + waiter spawn); a // clone is all-Arc, cheap, and adds no lifecycle coupling (the state is the // server-global `FreshCodexState`, alive for the process's whole life anyway). + let retained_floor = self + .attention + .snapshot() + .into_iter() + .find(|mark| mark.session_id == thread_id) + .and_then(|mark| mark.last_turn_complete_at); + { + let mut clock = last_turn_complete_at + .lock() + .expect("attention complete clock"); + *clock = (*clock).max(retained_floor); + } + self.attention.observe(&thread_id, SESSION_TYPE); let state = self.clone(); tokio::spawn(async move { if let Some(gate) = created_gate { @@ -7754,6 +7917,13 @@ impl FreshCodexState { continue; } }; + if projection + .lock() + .expect("codex projection lock") + .source_retired() + { + continue; + } state .observe_child_control_turn(&thread_id, &projection, ¬ification) .await; @@ -7841,6 +8011,7 @@ impl FreshCodexState { flush_codex_projection(&projection, &thread_id, &broadcast_tx); } let has_patch = project_codex_notification(&projection, ¬ification); + state.notify_retained(); if has_patch { if !projection_announced { let p = projection.lock().expect("codex projection lock"); @@ -7924,7 +8095,10 @@ impl FreshCodexState { } // DIAG-01: the positive turn-complete chime only -- session_id // alone, never the turn's text/response content. - if let CodexAdapterEvent::TurnComplete { session_id, .. } = &event { + if let CodexAdapterEvent::TurnComplete { session_id, at } = &event { + state + .attention + .record_turn_complete(session_id, SESSION_TYPE, *at); tracing::info!(provider = PROVIDER, session_id = %session_id, "freshagent.turn.complete"); // Unified agent names (Task 2): a completed turn wrote // history, so the thread's rollout has materialized — @@ -7958,14 +8132,23 @@ impl FreshCodexState { ); } } + if let CodexAdapterEvent::StatusSnapshot { status, .. } + | CodexAdapterEvent::Status { status, .. } = &event + { + projection + .lock() + .expect("codex projection lock") + .observe_status(status.as_str()); + } let frame = adapter_event_to_frame(&event, &thread_id); if let Some(frame) = frame { + state.notify_retained(); let _ = broadcast_tx.send(frame); } } } flush_codex_projection(&projection, &thread_id, &broadcast_tx); - state.clear_controls(&thread_id).await; + state.clear_source_controls(&thread_id, &projection).await; }) } @@ -8069,6 +8252,7 @@ impl FreshCodexState { &thread_id, &self.broadcast_tx, "quiet_deadman", + Some(&self), ); } @@ -9253,6 +9437,7 @@ impl FreshCodexState { Arc::clone(&self.condemned_priors), self.ownership_watch(), Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), @@ -9350,6 +9535,7 @@ impl FreshCodexState { Arc::clone(&self.condemned_priors), self.ownership_watch(), Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), @@ -9436,6 +9622,9 @@ pub enum CodexSnapshotError { HandoffInProgress { generation: u64 }, } +/// Retained reads share the existing source/ownership/protocol refusal contract. +pub type RetainedStateError = CodexSnapshotError; + impl std::fmt::Display for CodexSnapshotError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -10477,6 +10666,7 @@ pub(crate) fn spawn_exit_watcher( condemned_priors, ownership, None, + None, ) } @@ -10513,6 +10703,7 @@ fn spawn_exit_watcher_tracking_turns( // its fenced claim (None when the lane is unwired — pre-existing tests). ownership: Option, turn_state: Option, + retained_state: Option, ) -> tokio::task::JoinHandle { // wfah: the thread id is fixed by the time the watcher is constructed at // every successful spawn site; enrich the durable record once, here. @@ -10727,6 +10918,9 @@ fn spawn_exit_watcher_tracking_turns( // PR-4: flip the lazy-restart flag BEFORE broadcasting, so a client that // reacts to the `exited` status by immediately sending/attaching never // races ahead of `ensure_session_alive` observing a stale `false`. + if let Some(state) = &retained_state { + state.retain_native_exit(&thread_id, &ownership_id).await; + } exited.store(true, Ordering::SeqCst); let event = CodexAdapterEvent::Status { session_id: thread_id.clone(), @@ -10760,6 +10954,7 @@ fn spawn_exit_watcher_tracking_turns( &thread_id, &broadcast_tx, "crash_self_heal", + retained_state.as_ref(), ); } stop_result @@ -11087,6 +11282,7 @@ fn reduce_notification( subscription.session_id(), broadcast_tx, "system_error_status", + Some(state), ); let rang_turn_id = active_turn .lock() @@ -11541,6 +11737,7 @@ fn reduce_notification( subscription.session_id(), broadcast_tx, "thread_closed", + Some(state), ); } let rang_turn_id = active_turn @@ -11706,9 +11903,16 @@ fn mint_synthesized_attention_edge( thread_id: &str, broadcast_tx: &tokio::sync::broadcast::Sender, source: &'static str, + state: Option<&FreshCodexState>, ) { let clock = crate::attention::AttentionClock::from(Arc::clone(last_turn_complete_at)); let at = crate::attention::mint_synthesized_edge(&clock, PROVIDER, thread_id, source); + if let Some(state) = state { + state + .attention + .record_turn_complete(thread_id, SESSION_TYPE, at); + state.notify_retained(); + } let event = CodexAdapterEvent::TurnComplete { session_id: thread_id.to_string(), at, @@ -12612,6 +12816,7 @@ pub(crate) mod tests { Arc::clone(&state.condemned_priors), None, Some(turn_state.clone()), + Some(state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), @@ -12734,6 +12939,7 @@ pub(crate) mod tests { Arc::clone(&state.condemned_priors), None, Some(turn_state.clone()), + Some(state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), diff --git a/crates/freshell-freshagent/src/codex/controls.rs b/crates/freshell-freshagent/src/codex/controls.rs index 6fc2b3f65..5db503358 100644 --- a/crates/freshell-freshagent/src/codex/controls.rs +++ b/crates/freshell-freshagent/src/codex/controls.rs @@ -38,6 +38,7 @@ pub(super) struct PendingControls { waiting_clock: crate::attention::AttentionClock, async_question_turn: Option, async_question_ids: HashSet, + async_actions: HashMap<(String, String), i64>, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -222,12 +223,69 @@ impl FreshCodexState { .store(pending.requests.len(), Ordering::SeqCst); if pending.requests.is_empty() { controls.last_waiting_at.store(0, Ordering::SeqCst); + self.attention.clear_waiting(session_id); } if let Some((_, quiet, in_flight, _, _)) = self.quiet_handles(session_id).await { note_codex_activity(self, session_id, &in_flight, &quiet); } } + pub(super) async fn contextual_answer_targets( + &self, + session_id: &str, + text: &str, + ) -> HashMap<(String, String), i64> { + let keys = text + .lines() + .filter_map(|line| { + let value: Value = + serde_json::from_str(line.strip_prefix("Answer to Codex question: ")?).ok()?; + value["question"].as_str()?; + value["answer"].as_str()?; + Some(( + value["itemId"].as_str()?.to_owned(), + value["questionId"].as_str()?.to_owned(), + )) + }) + .collect::>(); + if keys.is_empty() { + return HashMap::new(); + } + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + let Some(controls) = controls else { + return HashMap::new(); + }; + let pending = controls.lock().await; + keys.into_iter() + .filter_map(|key| pending.async_actions.get(&key).map(|at| (key, *at))) + .collect() + } + + pub(super) async fn acknowledge_contextual_answers( + &self, + session_id: &str, + targets: HashMap<(String, String), i64>, + ) { + if targets.is_empty() { + return; + } + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + pending + .async_actions + .retain(|key, at| targets.get(key) != Some(at)); + let available = pending.async_actions.values().copied().max().unwrap_or(0); + controls + .last_question_available_at + .store(available, Ordering::SeqCst); + self.notify_retained(); + } + /// Ready-frame marks read retained runtime state only. They never hydrate or resume native history. pub async fn attention_marks(&self) -> Vec { let sessions = self @@ -238,26 +296,52 @@ impl FreshCodexState { .map(|(id, session)| (id.clone(), session.last_turn_complete_at.clone())) .collect::>(); let controls = self.controls.lock().expect("codex controls registry"); - sessions + let mut marks: std::collections::BTreeMap< + String, + freshell_protocol::FreshAgentAttentionMark, + > = self + .attention + .snapshot() .into_iter() - .map(|(id, complete)| { - let retained = controls.get(&id); - let waiting = retained.map_or(0, |controls| { - controls.last_waiting_at.load(Ordering::SeqCst) - }); - let available = retained.map_or(0, |controls| { - controls.last_question_available_at.load(Ordering::SeqCst) - }); + .map(|mark| { + let id = mark.session_id.clone(); + ( + id, + freshell_protocol::FreshAgentAttentionMark { + provider: PROVIDER.into(), + session_type: mark.session_type, + session_id: mark.session_id, + last_turn_complete_at: mark.last_turn_complete_at, + last_waiting_at: mark.last_waiting_at, + last_question_available_at: None, + }, + ) + }) + .collect(); + for (id, complete) in sessions { + let mark = marks.entry(id.clone()).or_insert_with(|| { freshell_protocol::FreshAgentAttentionMark { provider: PROVIDER.into(), session_type: SESSION_TYPE.into(), session_id: id, - last_turn_complete_at: *complete.lock().expect("attention complete clock"), - last_waiting_at: (waiting > 0).then_some(waiting), - last_question_available_at: (available > 0).then_some(available), + last_turn_complete_at: None, + last_waiting_at: None, + last_question_available_at: None, } - }) - .collect() + }); + mark.last_turn_complete_at = mark + .last_turn_complete_at + .max(*complete.lock().expect("attention complete clock")); + } + for (id, mark) in &mut marks { + if let Some(retained) = controls.get(id) { + let waiting = retained.last_waiting_at.load(Ordering::SeqCst); + let available = retained.last_question_available_at.load(Ordering::SeqCst); + mark.last_waiting_at = (waiting > 0).then_some(waiting); + mark.last_question_available_at = (available > 0).then_some(available); + } + } + marks.into_values().collect() } fn control_event(&self, session_id: &str, event: Value) { @@ -291,6 +375,34 @@ impl FreshCodexState { .await; } + /// Retire only operations owned by this exact source. A late old consumer + /// cannot cancel a replacement incarnation's controls with reused IDs. + pub(super) async fn clear_source_controls( + &self, + session_id: &str, + source: &Arc, + ) { + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + let source = Arc::downgrade(source); + pending.requests.retain(|request| { + if std::sync::Weak::ptr_eq(&request.source, &source) { + self.cancelled_control(session_id, request); + false + } else { + true + } + }); + controls + .child_turns + .lock() + .expect("child control turns") + .retain(|_, child| !std::sync::Weak::ptr_eq(&child.source, &source)); + self.publish_control_membership(session_id, &controls, &pending) + .await; + self.notify_retained(); + } + pub(super) async fn overlay_controls(&self, session_id: &str, snapshot: &mut Value) { snapshot["capabilities"]["approvals"] = json!(true); snapshot["capabilities"]["questions"] = json!(true); @@ -418,6 +530,7 @@ impl FreshCodexState { if was_empty { let at = pending.waiting_clock.mint(); controls.last_waiting_at.store(at, Ordering::SeqCst); + self.attention.record_waiting(session_id, SESSION_TYPE, at); self.control_event(session_id, json!({ "type": "freshAgent.turn.waiting", "sessionId": session_id, "at": at })); } tracing::info!( @@ -463,6 +576,15 @@ impl FreshCodexState { } if pending.async_question_ids.insert(item_id.into()) { let at = pending.waiting_clock.mint(); + for (index, question) in questions.iter().enumerate() { + if question["title"].is_string() { + let id = question["id"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| index.to_string()); + pending.async_actions.insert((item_id.to_owned(), id), at); + } + } controls .last_question_available_at .store(at, Ordering::SeqCst); @@ -637,7 +759,13 @@ impl FreshCodexState { let Some(session) = sessions.get(session_id) else { return false; }; - if !Arc::ptr_eq(&session.client, native_client) || !Arc::ptr_eq(&session.projection, source) + if session.exited.load(Ordering::SeqCst) + || source + .lock() + .expect("codex projection lock") + .source_retired() + || !Arc::ptr_eq(&session.client, native_client) + || !Arc::ptr_eq(&session.projection, source) { return false; } diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index 25ec08072..cdd503556 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -1513,3 +1513,265 @@ async fn task5_control_diagnostics_keep_locators_and_severity_without_secret_pay peer.disconnect(); state.shutdown().await; } + +async fn task6_shutdown(state: &FreshCodexState, peer: ChannelPeer) { + // ChannelTransport recv holds its mutex until the owning peer closes. + // Close exactly once before waiting for the consumer and sidecar watcher. + drop(peer); + tokio::time::timeout(Duration::from_secs(10), state.shutdown()) + .await + .expect("fixture shutdown"); +} + +#[cfg(unix)] +#[tokio::test] +async fn task6_native_death_retains_finite_output_and_completion_but_no_dead_control() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifications) = CodexAppServerClient::connect(transport); + let (state, mut rx) = state_with_bus(); + let child = spawn_sleeper(); + let pid = child.id().unwrap(); + insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(StdMutex::new(Some("turn-1".into()))), + notifications, + child, + "codex-task6-death", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"answer","type":"agentMessage","text":""}})); + peer.emit_notification("item/agentMessage/delta",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer","delta":"Available interrupted output"})); + peer.request_client(&RequestId::Int(91),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"question","isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Pick","options":null}]})); + loop { + if event(&mut rx).await["type"] == "freshAgent.turn.waiting" { + break; + } + } + // This exact PID is the fixture child just transferred to its watcher. + // The entire test runs in the strict disposable process-kill sandbox. + unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + let complete = loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.turn.complete" { + break next; + } + }; + let retained = state.retained_state("thread-1").await.unwrap(); + let before = retained["snapshot"]["turns"].clone(); + peer.emit_notification( + "item/agentMessage/delta", + json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer","delta":" late append"}), + ); + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"late-dead-turn","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"late-dead-turn","item":{"id":"late-dead-item","type":"agentMessage","text":"Queued dead source must stay finite"}})); + + tokio::time::sleep(Duration::from_millis(50)).await; + let after = state.retained_state("thread-1").await.unwrap(); + task6_shutdown(&state, peer).await; + assert!( + retained["pendingControls"].as_array().unwrap().is_empty(), + "dead native controls must be drained before the completion can be replayed: {retained}" + ); + assert!(retained["attentionMarks"][0].get("lastWaitingAt").is_none()); + assert_eq!( + retained["attentionMarks"][0]["lastTurnCompleteAt"], + complete["at"] + ); + assert_eq!( + retained["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"]["turn-1"] + ["diagnostic"], + "interrupted" + ); + assert!(serde_json::to_string(&before) + .unwrap() + .contains("Available interrupted output")); + assert_eq!( + after["snapshot"]["turns"], before, + "late buffered appends cannot reopen a dead source" + ); +} + +#[tokio::test] +async fn task6_admission_continues_the_retained_completion_clock_floor() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifications) = CodexAppServerClient::connect(transport); + let (state, mut rx) = state_with_bus(); + let floor = crate::rollback_record::now_ms() + 60_000; + state + .attention + .record_turn_complete("thread-1", SESSION_TYPE, floor); + insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(StdMutex::new(Some("turn-1".into()))), + notifications, + spawn_sleeper(), + "codex-task6-clock", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}), + ); + let complete = loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.turn.complete" { + break next; + } + }; + task6_shutdown(&state, peer).await; + assert!(complete["at"].as_i64().unwrap()>floor,"an admitted incarnation must not regress below the retained clock even if wall time moved backwards: {complete}"); +} + +#[tokio::test] +async fn task6_retained_source_preserves_synthesized_stuck_status_without_native_read() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(80); + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/started", json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"partial","type":"agentMessage","text":"Available quiet output"}})); + let complete = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let value = event(&mut rx).await; + if value["type"] == "freshAgent.turn.complete" { + break value; + } + } + }) + .await + .expect("actual quiet deadline produces an attention edge"); + let retained = state.retained_state("thread-1").await.unwrap(); + let unsolicited = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()).await; + task6_shutdown(&state, peer).await; + assert_eq!( + retained["snapshot"]["status"], "stuck", + "retained recovery must preserve the authoritative quiet deadline status" + ); + assert_eq!( + retained["attentionMarks"][0]["lastTurnCompleteAt"], + complete["at"] + ); + assert!(retained["pendingControls"].as_array().unwrap().is_empty()); + assert!(retained["snapshot"] + .to_string() + .contains("Available quiet output")); + assert!( + unsolicited.is_err(), + "a retained read cannot hydrate native history" + ); +} + +async fn task6_send_context_answer( + state: &FreshCodexState, + peer: &ChannelPeer, + question_id: &str, + outcome: &str, +) { + let text = format!( + "Answer to Codex question: {}", + json!({"itemId":"async-answerable","questionId":question_id,"question":"A contextual question","answer":"Chosen ordinary answer"}) + ); + let request_id = format!("answer-{question_id}"); + let send = tokio::spawn({ + let state = state.clone(); + let text = text.clone(); + async move { + state.handle_send(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request_id,"text":text})).unwrap()).await + } + }); + let wire = tokio::time::timeout(Duration::from_secs(3), async { + let (mut id, mut method, mut params) = peer.expect_request().await; + if method == "initialize" { + peer.respond(&id, json!({})); + peer.expect_notification().await; + (id, method, params) = peer.expect_request().await; + } + (id, method, params) + }) + .await + .expect("explicit ordinary answer reaches native transport"); + assert_eq!(wire.1, "turn/steer"); + assert_eq!(wire.2["input"][0]["text"], text); + match outcome { + "accepted" => peer.respond(&wire.0, json!({"turnId":"turn-1"})), + "refused" => peer.respond_error(&wire.0, -32600, "native input refused"), + "ambiguous" => peer.respond(&wire.0, json!({})), + _ => unreachable!(), + } + tokio::time::timeout(Duration::from_secs(3), send) + .await + .unwrap() + .unwrap(); +} + +async fn task6_context_answer_outcome(outcome: &str) { + let (state, peer, mut rx) = runtime().await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/completed",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"async-answerable","type":"agentMessage","text":"Ongoing work with contextual choices","delivery":"async","questions":[{"id":"one","title":"First?"},{"id":"two","title":"Second?"}]}})); + loop { + if event(&mut rx).await["type"] == "freshAgent.question.available" { + break; + } + } + let original = state.attention_marks().await[0].last_question_available_at; + task6_send_context_answer(&state, &peer, "one", outcome).await; + let after_one = state.attention_marks().await[0].clone(); + if outcome == "accepted" { + task6_send_context_answer(&state, &peer, "two", outcome).await; + } + let after_two = state.attention_marks().await[0].clone(); + let controls = state.retained_control_events("thread-1").await; + let unsolicited = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()).await; + task6_shutdown(&state, peer).await; + assert_eq!( + after_one.last_question_available_at, original, + "answering one question must preserve the remaining independent action" + ); + assert_eq!( + after_two.last_question_available_at, + if outcome == "accepted" { + None + } else { + original + }, + "only accepted contextual input clears its action; refusal and uncertainty preserve it" + ); + assert!(after_one.last_waiting_at.is_none() && after_two.last_waiting_at.is_none()); + assert!(after_two.last_turn_complete_at.is_none()); + assert!( + controls.is_empty(), + "async content never becomes a durable native decision" + ); + assert!(unsolicited.is_err(),"ordinary answers must never be automatically retried or converted to native control replies"); +} +#[tokio::test] +async fn task6_accepted_contextual_answers_clear_only_their_actions() { + task6_context_answer_outcome("accepted").await; +} +#[tokio::test] +async fn task6_refused_contextual_answer_preserves_action_attention() { + task6_context_answer_outcome("refused").await; +} +#[tokio::test] +async fn task6_ambiguous_contextual_answer_preserves_action_without_resend() { + task6_context_answer_outcome("ambiguous").await; +} diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index d3ff3ca87..fdb5d96e5 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -125,6 +125,7 @@ pub(super) struct ProjectionSnapshot { pub metadata: Value, } +#[derive(Clone)] pub(super) struct CodexProjection { pub id: String, thread_id: String, @@ -133,6 +134,8 @@ pub(super) struct CodexProjection { pub history_generation: u64, revision: u64, active_turn: Option, + retired: bool, + live_status: Option, turns: Vec, // Membership fences outlive payload retention. They are never the eight-turn // attention cache: old native identities cannot reopen a historical operation. @@ -146,6 +149,7 @@ pub(super) struct CodexProjection { pub plan: Option, pub diff: Option, pending_ops: Vec, + pending_after_revision: Option, } impl CodexProjection { @@ -158,6 +162,8 @@ impl CodexProjection { history_generation: 0, revision: 0, active_turn: None, + retired: false, + live_status: None, turns: vec![], known_turns: HashSet::new(), turn_order: vec![], @@ -169,6 +175,7 @@ impl CodexProjection { plan: None, diff: None, pending_ops: vec![], + pending_after_revision: None, } } @@ -228,8 +235,13 @@ impl CodexProjection { } pub(super) fn patch(&self, ops: Vec) -> Value { + let after_revision = ops + .first() + .and_then(|op| op["revision"].as_u64()) + .unwrap_or(self.revision) + .saturating_sub(1); json!({"type":"freshAgent.transcript.patch", "sessionId":self.thread_id, - "projectionId":self.id, "historyGeneration":self.history_generation, "ops":ops}) + "projectionId":self.id, "historyGeneration":self.history_generation, "afterRevision":after_revision, "ops":ops}) } pub(super) fn metadata_event(&self) -> Value { @@ -237,6 +249,12 @@ impl CodexProjection { } pub(super) fn enqueue(&mut self, ops: Vec) { + if self.pending_ops.is_empty() { + self.pending_after_revision = ops + .first() + .and_then(|op| op["revision"].as_u64()) + .map(|revision| revision.saturating_sub(1)); + } for op in ops { if let Some(last) = self.pending_ops.last_mut() { if last["op"] == "appendItem" @@ -259,7 +277,13 @@ impl CodexProjection { None } else { let ops = std::mem::take(&mut self.pending_ops); - Some(self.patch(ops)) + let after_revision = self + .pending_after_revision + .take() + .expect("pending patch prefix"); + let mut patch = self.patch(ops); + patch["afterRevision"] = json!(after_revision); + Some(patch) } } @@ -337,6 +361,58 @@ impl CodexProjection { }) } + pub(super) fn observe_status(&mut self, status: &str) { + if !self.retired { + self.live_status = Some(status.to_owned()); + } + } + + pub(super) fn live_status(&self) -> Option<&str> { + self.live_status.as_deref() + } + + /// A new source can disclose the prior source's finite evidence. It never + /// inherits pending operations, revision baselines, or append authority. + pub(super) fn inherit_finite_history(&mut self, prior: &Self) { + if self.thread_id != prior.thread_id { + return; + } + let mut finite = prior.clone(); + finite.freeze_active("interrupted"); + for turn in &mut finite.turns { + if !turn.terminal { + turn.raw["status"] = json!("interrupted"); + } + turn.terminal = true; + turn.save_reads = MAX_SAVE_READS; + if let Some(id) = turn.raw["id"].as_str() { + finite.terminal_turns.insert(id.to_owned()); + } + } + finite.trim_frozen(); + self.turns = finite.turns; + self.known_turns = finite.known_turns; + self.turn_order = finite.turn_order; + self.terminal_turns = finite.terminal_turns; + self.removed_turns = finite.removed_turns; + self.last_native = finite.last_native; + self.history_generation = finite.history_generation; + self.usage = finite.usage; + self.plan = finite.plan; + self.diff = finite.diff; + self.live_status = Some("idle".into()); + } + + pub(super) fn source_retired(&self) -> bool { + self.retired + } + + pub(super) fn retire_source(&mut self) { + self.retired = true; + self.live_status = Some("exited".into()); + self.freeze_active("interrupted"); + } + pub(super) fn freeze_active(&mut self, status: &str) { if let Some(id) = self.active_turn.take() { self.terminal_turns.insert(id.clone()); @@ -414,6 +490,9 @@ impl CodexProjection { method: &str, params: &Value, ) -> Result, ProjectionError> { + if self.retired { + return Ok(vec![]); + } if params.get("threadId").and_then(Value::as_str) != Some(&self.thread_id) { return Ok(vec![]); } @@ -701,6 +780,23 @@ impl CodexProjection { self.build_snapshot(raw, fence, false) } + /// Clone the current projection so recovery never advances native-read + /// settlement counters or changes the append baseline. + pub(super) fn retained_snapshot( + &self, + unavailable: bool, + ) -> Result { + let mut retained = self.clone(); + let raw = self.last_native.clone().unwrap_or_else( + || json!({"thread":{"id":self.thread_id,"status":{"type":"idle"},"turns":[]}}), + ); + retained.build_snapshot( + &raw, + &self.begin_history_read(), + unavailable || self.retired, + ) + } + pub(super) fn unavailable_history( &mut self, fence: &SnapshotReadFence, diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index c78724e10..320c70231 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -1180,6 +1180,7 @@ impl RuntimeClient { AdminCommand::FreshAgentReadHistory(_) | AdminCommand::FreshAgentReadSnapshot(_) | AdminCommand::FreshAgentReadChild(_) + | AdminCommand::FreshAgentReadEvents(_) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -1659,6 +1660,85 @@ mod tests { assert_eq!(actual.unwrap(), expected); } + #[tokio::test] + async fn task6_retained_event_reply_preserves_body_above_the_old_control_limit() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("retained.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let expected = serde_json::json!({"retainedFrom":1,"head":1,"resetRequired":true,"events":[],"retainedState":{"snapshot":{"turns":[{"text":"x".repeat(2*freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}}}); + let sent: freshell_runtime_protocol::AgentEventBatch = + serde_json::from_value(expected.clone()).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + assert!(matches!( + envelope.body, + AdminCommand::FreshAgentReadEvents(_) + )); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(AdminResult::FreshAgentEvents(sent)), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let actual = client + .fresh_agent_events(SoulId::parse("large-retained").unwrap(), 0, 1) + .await + .unwrap(); + server.await.unwrap(); + assert!( + serde_json::to_value(actual).unwrap() == expected, + "the actual retained event caller must round-trip the entire 2MiB body" + ); + } + + #[tokio::test] + async fn task6_retained_reply_stays_bounded_and_ordinary_control_budget_stays_small() { + use tokio::io::AsyncWriteExt; + for retained in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("bounded.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let budget = if retained { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _: Envelope = read_frame(&mut stream).await.unwrap(); + stream + .write_all(&((budget + 1) as u32).to_be_bytes()) + .await + .unwrap(); + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let body = if retained { + AdminCommand::FreshAgentReadEvents( + freshell_runtime_protocol::FreshAgentReadEventsRequest { + soul_id: SoulId::parse("bounded").unwrap(), + after_sequence: 0, + max_events: 1, + expected_control_epoch: Some(1), + }, + ) + } else { + AdminCommand::Inventory + }; + let actual = client.request(RequestId::new(), body).await; + server.await.unwrap(); + assert!(matches!(actual,Err(ClientError::Protocol(message)) if message==freshell_runtime_protocol::FrameError::TooLarge.to_string()),"oversized lengths must be rejected by the caller's exact budget before reading any body"); + } + } + #[tokio::test] async fn health_authenticates_and_caches_epoch() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 733511fb3..f325b74dd 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1994,6 +1994,8 @@ pub struct AgentJournalEvent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentEventBatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retained_state: Option, pub retained_from: u64, pub head: u64, pub reset_required: bool, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index d5268c10e..ef32a1e52 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -1024,6 +1024,11 @@ impl HostedFreshAgentProxy { let proxy = Arc::clone(self); tokio::spawn(async move { let mut cursor = 0; + let mut source_before = if provider == "codex" { + proxy.current_source(&soul).await + } else { + None + }; loop { match proxy .client @@ -1031,6 +1036,46 @@ impl HostedFreshAgentProxy { .await { Ok(batch) => { + if let Some(retained) = batch.retained_state { + let after = proxy.current_source(&soul).await; + let valid = source_before.as_ref().zip(after.as_ref()).is_some_and( + |(before, after)| { + same_retained_host(before, after) + && retained_matches_view(&retained, before) + && retained_matches_view(&retained, after) + }, + ); + source_before = after; + if !valid { + tracing::warn!(provider, soul_id=%soul, "freshagent.gateway.retained_source_changed"); + cursor = 0; + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + let presentation = proxy + .presentation_ids + .lock() + .await + .get(&soul) + .cloned() + .unwrap_or_else(|| presentation_session_id.clone()); + proxy.forward_retained( + retained, + &provider, + &presentation, + &session_type, + if batch.reset_required { + "freshAgent.host.journal.reset" + } else { + "freshAgent.host.backpressure" + }, + ); + // The retained source covers the complete current prefix, + // including journal entries older than this bounded page. + cursor = batch.head; + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } if batch.reset_required { proxy.send(ServerMessage::FreshAgentEvent(FreshAgentEvent { event: serde_json::json!({ @@ -1085,6 +1130,64 @@ impl HostedFreshAgentProxy { true } + async fn current_source( + &self, + soul: &SoulId, + ) -> Option { + self.client + .inventory() + .await + .ok()? + .into_iter() + .find(|view| &view.soul_id == soul && view.desired_state == DesiredState::Running) + } + + fn forward_retained( + &self, + mut retained: serde_json::Value, + provider: &str, + presentation: &str, + session_type: &str, + event_type: &str, + ) { + retained["source"]["nativeSessionId"] = retained["snapshot"]["threadId"].clone(); + if let Some(ownership) = self.ownership.get() { + let canonical = ownership.resolve_canonical(provider, presentation); + let owner = ownership.observe(provider, &canonical); + retained["source"]["ownerEpoch"] = serde_json::json!(owner.epoch); + retained["source"]["ownerGeneration"] = serde_json::json!(owner.generation); + } + freshell_agent_runtime::snapshot_projection::project_hosted_rest_snapshot( + &mut retained["snapshot"], + provider, + session_type, + ); + for mark in retained["attentionMarks"] + .as_array_mut() + .into_iter() + .flatten() + { + mark["sessionId"] = serde_json::json!(presentation); + } + self.send(ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: provider.into(), + session_type: session_type.into(), + session_id: presentation.into(), + event: serde_json::json!({"type":event_type,"retainedState":retained}), + })); + for payload in retained["pendingControls"].as_array().into_iter().flatten() { + self.forward_host_event( + AgentEvent::PermissionRequested { + decision_id: payload["requestId"].as_str().unwrap_or_default().into(), + payload: payload.clone(), + }, + provider, + presentation, + session_type, + ); + } + } + async fn replay_once( &self, soul: SoulId, @@ -1092,9 +1195,35 @@ impl HostedFreshAgentProxy { presentation_session_id: &str, session_type: &str, ) { - let Ok(batch) = self.client.fresh_agent_events(soul, 0, 4096).await else { + let before = if provider == "codex" { + self.current_source(&soul).await + } else { + None + }; + let Ok(batch) = self.client.fresh_agent_events(soul.clone(), 0, 4096).await else { return; }; + if let Some(retained) = batch.retained_state { + let after = self.current_source(&soul).await; + if before + .as_ref() + .zip(after.as_ref()) + .is_some_and(|(before, after)| { + same_retained_host(before, after) + && retained_matches_view(&retained, before) + && retained_matches_view(&retained, after) + }) + { + self.forward_retained( + retained, + provider, + presentation_session_id, + session_type, + "freshAgent.host.backpressure", + ); + } + return; + } for event in batch.events { self.forward_host_event(event.event, provider, presentation_session_id, session_type); } @@ -1196,6 +1325,70 @@ fn named_provider(provider: &FreshProvider) -> NamedProvider { } impl HostedFreshAgentGateway for HostedFreshAgentProxy { + fn attention_marks( + &self, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + let Ok(views) = self.client.inventory().await else { + return Vec::new(); + }; + let mut marks = Vec::new(); + for before in views.into_iter().filter(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && view.desired_state == DesiredState::Running + }) { + let Ok(batch) = self + .client + .fresh_agent_events(before.soul_id.clone(), 0, 1) + .await + else { + continue; + }; + let Some(retained) = batch.retained_state else { + continue; + }; + let Some(after) = self.current_source(&before.soul_id).await else { + continue; + }; + if !same_retained_host(&before, &after) + || !retained_matches_view(&retained, &before) + || !retained_matches_view(&retained, &after) + { + continue; + } + let presentation = self + .presentation_ids + .lock() + .await + .get(&before.soul_id) + .cloned() + .or(before.fresh_agent_session_id.clone()) + .or(before.native_session_id.clone()); + let Some(presentation) = presentation else { + continue; + }; + if let Ok(current) = serde_json::from_value::< + Vec, + >(retained["attentionMarks"].clone()) + { + marks.extend( + current + .into_iter() + .filter(|mark| { + mark.provider == "codex" && mark.session_type == "freshcodex" + }) + .map(|mut mark| { + mark.session_id = presentation.clone(); + mark + }), + ); + } + } + marks + }) + } fn dispatch( self: Arc, command: HostedFreshAgentCommand, @@ -1391,6 +1584,30 @@ fn fresh_agent_error_payload(code: &str, message: &str) -> serde_json::Value { }) } +fn same_retained_host( + before: &freshell_runtime_protocol::RuntimeView, + after: &freshell_runtime_protocol::RuntimeView, +) -> bool { + before.soul_id == after.soul_id + && before.incarnation_id == after.incarnation_id + && before.host_boot_id == after.host_boot_id +} + +fn retained_matches_view( + retained: &serde_json::Value, + view: &freshell_runtime_protocol::RuntimeView, +) -> bool { + retained["source"]["soulId"].as_str() == Some(view.soul_id.as_str()) + && retained["source"]["incarnationId"].as_str() == Some(view.incarnation_id.as_str()) + && view + .host_boot_id + .as_ref() + .is_some_and(|boot| retained["source"]["hostBootId"].as_str() == Some(boot.as_str())) + && retained["snapshot"]["provider"] == "codex" + && retained["snapshot"]["sessionType"] == "freshcodex" + && retained["snapshot"]["threadId"].as_str() == view.native_session_id.as_deref() +} + fn rewrite_presentation_id(payload: &mut serde_json::Value, presentation_id: &str) { let Some(object) = payload.as_object_mut() else { return; diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index dd2cdd574..393418be0 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -2271,3 +2271,130 @@ async fn task5_hosted_elicitation_reuses_resolve_with_tagged_typed_content_and_o assert_eq!(settled(server).await, vec!["41", "\"41\""]); assert!(receiver.try_recv().is_err()); } + +async fn task6_gateway_source_scenario(scenario: &'static str) { + let replaced = scenario != "current" && scenario != "ready-current"; + let ready = scenario.starts_with("ready-"); + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("retained-gateway.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let read_started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_read_started = read_started.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health { control_epoch:7, installation_id:InstallationId::new() }, + AdminCommand::Inventory => AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"retained-soul", "incarnationId":if scenario == "before-incarnation" || matches!(scenario,"after-incarnation" | "ready-after-incarnation") && server_read_started.load(std::sync::atomic::Ordering::SeqCst) {"replacement-incarnation"} else {"original-incarnation"}, + "hostBootId":if scenario == "after-boot" && server_read_started.load(std::sync::atomic::Ordering::SeqCst) {"replacement-boot"} else {"original-boot"}, "launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0, + "provider":"codex","nativeSessionId":if scenario == "before-native" {"replacement-native"} else {"native-owned"},"freshAgentSessionId":"public-owned","freshAgentSessionType":"freshcodex" + })).unwrap()]), + AdminCommand::FreshAgentReadEvents(_) => { + server_read_started.store(true,std::sync::atomic::Ordering::SeqCst); + AdminResult::FreshAgentEvents(serde_json::from_value(serde_json::json!({ + "retainedFrom":10,"head":10,"resetRequired":true, + "retainedState":{"source":{"soulId":"retained-soul","incarnationId":"original-incarnation","hostBootId":"original-boot","projectionId":"source-projection","ownerEpoch":0,"ownerGeneration":0}, + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-owned","turns":[{"text":"Latest retained body"}]}, + "attentionMarks":[{"provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","lastTurnCompleteAt":100}],"pendingControls":[]}, + "events":if replaced {serde_json::json!([{"sequence":10,"event":{"kind":"provider","data":{"payload":{"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.notice","message":"stale native source"}}}}}])} else {serde_json::json!([])} + })).unwrap()) + } + other => panic!("recovery must read retained source, never native history or resume: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + if ready { + let marks = proxy.attention_marks().await; + server.abort(); + let _ = server.await; + if replaced { + assert!(marks.is_empty(), "ready must reject a replaced source"); + } else { + assert_eq!(marks.len(), 1); + assert_eq!(marks[0].session_id, "public-owned"); + assert_eq!(marks[0].last_turn_complete_at, Some(100)); + } + return; + } + let mut frames = proxy.broadcast.subscribe(); + proxy + .start_poller( + SoulId::parse("retained-soul").unwrap(), + "codex".into(), + "public-owned".into(), + "freshcodex".into(), + ) + .await; + let first = tokio::time::timeout(Duration::from_millis(750), async { + loop { + let value: serde_json::Value = + serde_json::from_str(&frames.recv().await.unwrap()).unwrap(); + if value["event"]["type"] != "freshAgent.host.journal.reset" || !replaced { + break value; + } + } + }) + .await; + server.abort(); + let _ = server.await; + assert!( + read_started.load(std::sync::atomic::Ordering::SeqCst), + "actual controller event read must run" + ); + if replaced { + assert!( + first.is_err(), + "a source replaced during the read must not forward its old events: {first:?}" + ); + } else { + let first = + first.expect("journal reset must independently deliver retained provider state"); + assert_eq!(first["sessionId"], "public-owned"); + assert_eq!( + first["event"]["retainedState"]["snapshot"]["turns"][0]["text"], "Latest retained body", + "gateway must apply the authoritative retained source immediately: {first}" + ); + } +} + +#[tokio::test] +async fn task6_gateway_applies_authoritative_source_on_journal_reset() { + task6_gateway_source_scenario("current").await; +} +#[tokio::test] +async fn task6_gateway_rechecks_source_after_controller_read() { + task6_gateway_source_scenario("after-incarnation").await; +} + +#[tokio::test] +async fn task6_gateway_rejects_an_old_source_at_admission() { + task6_gateway_source_scenario("before-incarnation").await; +} +#[tokio::test] +async fn task6_gateway_rechecks_host_boot_after_controller_read() { + task6_gateway_source_scenario("after-boot").await; +} +#[tokio::test] +async fn task6_gateway_rejects_mismatched_native_identity() { + task6_gateway_source_scenario("before-native").await; +} +#[tokio::test] +async fn task6_gateway_ready_uses_current_provider_retained_attention() { + task6_gateway_source_scenario("ready-current").await; +} +#[tokio::test] +async fn task6_gateway_ready_rejects_a_replaced_provider_source() { + task6_gateway_source_scenario("ready-after-incarnation").await; +} diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 542bf6dd6..0ccf20300 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -302,7 +302,9 @@ async fn handle_connection(mut stream: UnixStream, state: Arc) -> Res } let limit = if matches!( result, - Ok(HostResult::FreshAgentSnapshot(_) | HostResult::FreshAgentChildSnapshot(_)) + Ok(HostResult::FreshAgentSnapshot(_) + | HostResult::FreshAgentChildSnapshot(_) + | HostResult::FreshAgentEvents(_)) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 9d3498559..5192211a7 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -77,6 +77,7 @@ struct HostedTransport { suppressed_kills: Arc>>, event_tx: mpsc::Sender, event_rx: std::sync::Mutex>>, + _bridge: BroadcastBridgeTasks, #[cfg(test)] test_delegate: Option>, } @@ -242,10 +243,14 @@ impl HostedTransport { let (send_outcomes, _) = broadcast::channel(64); let (kill_outcomes, _) = broadcast::channel(64); let suppressed_kills = Arc::new(std::sync::Mutex::new(HashSet::new())); - spawn_broadcast_bridge( + let bridge = spawn_broadcast_bridge( provider.clone(), broadcast_rx, BroadcastBridgeOutputs { + retained_codex: match &state { + ProviderState::Codex(state) => Some(state.clone()), + _ => None, + }, created: created_tx, native: native_tx.clone(), events: event_tx.clone(), @@ -268,6 +273,7 @@ impl HostedTransport { suppressed_kills, event_tx, event_rx: std::sync::Mutex::new(Some(event_rx)), + _bridge: bridge, #[cfg(test)] test_delegate: None, }) @@ -1053,6 +1059,21 @@ impl FreshAgentTransport for HostedTransport { }) } + async fn retained_state(&self) -> Result, String> { + let ProviderState::Codex(state) = &self.state else { + return Ok(None); + }; + let native_id = self.session_id.lock().await.clone(); + match native_id { + Some(id) => state + .retained_state(&id) + .await + .map(Some) + .map_err(|error| format!("codex retained state unavailable: {error:?}")), + None => Ok(None), + } + } + async fn snapshot(&self) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -1208,6 +1229,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { } struct BroadcastBridgeOutputs { + retained_codex: Option, created: watch::Sender>>, native: watch::Sender>, events: mpsc::Sender, @@ -1216,12 +1238,25 @@ struct BroadcastBridgeOutputs { suppressed_kills: Arc>>, } +struct BroadcastBridgeTasks { + recovery: tokio::task::JoinHandle<()>, + forward: tokio::task::JoinHandle<()>, +} + +impl Drop for BroadcastBridgeTasks { + fn drop(&mut self) { + self.recovery.abort(); + self.forward.abort(); + } +} + fn spawn_broadcast_bridge( provider: FreshProvider, mut receiver: broadcast::Receiver, outputs: BroadcastBridgeOutputs, -) { +) -> BroadcastBridgeTasks { let BroadcastBridgeOutputs { + retained_codex, created, native, events, @@ -1236,13 +1271,111 @@ fn spawn_broadcast_bridge( // id and announces nothing. The identity watch records the id last // announced (a fork's transport records its child there, C14). let claude = matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy); - tokio::spawn(async move { + // One coalesced recovery slot, independent of later provider traffic. + let (recovery, mut recovery_rx) = watch::channel(None::); + let recovery_events = events.clone(); + let recovery_native = native.clone(); + let recovery_provider = provider.clone(); + let mut retained_changes = retained_codex + .as_ref() + .map(|state| state.subscribe_retained_state("")); + let recovery_task = tokio::spawn(async move { + loop { + let loss = tokio::select! { + result = recovery_rx.changed() => { if result.is_err() {break;} true }, + _ = async { match &mut retained_changes { Some(changes) => { let _ = changes.changed().await; }, None => std::future::pending::<()>().await } } => false, + _ = recovery_events.closed() => break, + }; + if !loss && recovery_events.capacity() > 0 { + continue; + } + let Ok(permit) = recovery_events.reserve().await else { + break; + }; + let mut payload = recovery_rx.borrow_and_update().clone(); + if let Some(changes) = &mut retained_changes { + changes.borrow_and_update(); + } + let mut pending = Vec::new(); + if let Some(state) = &retained_codex { + let mut ids = recovery_native + .borrow() + .clone() + .into_iter() + .collect::>(); + // A dropped materialization may also have dropped the native + // identity edge. Resolve only registered retained sources. + ids.extend( + state + .attention_marks() + .await + .into_iter() + .map(|mark| mark.session_id), + ); + for session_id in ids { + if let Ok(retained) = state.retained_state(&session_id).await { + pending = retained["pendingControls"] + .as_array() + .cloned() + .unwrap_or_default(); + let dropped = payload + .as_ref() + .and_then(|frame| frame["event"]["dropped"].as_u64()) + .unwrap_or(1); + let mut frame = bridge_recovery_frame( + &recovery_provider, + "freshcodex", + &session_id, + dropped, + ); + frame["event"]["retainedState"] = retained; + payload = Some(frame); + break; + } + } + } + let Some(payload) = payload else { continue }; + tracing::warn!( + provider = recovery_provider.as_str(), + session_id = payload["sessionId"].as_str().unwrap_or_default(), + dropped = payload["event"]["dropped"].as_u64().unwrap_or_default(), + retained = payload["event"]["retainedState"].is_object(), + "freshagent.host.bridge_recovered" + ); + permit.send(AgentEvent::Provider { payload }); + for payload in pending { + if let Some(decision_id) = payload["requestId"].as_str() { + if recovery_events + .send(AgentEvent::PermissionRequested { + decision_id: decision_id.to_owned(), + payload, + }) + .await + .is_err() + { + return; + } + } + } + } + }); + let forward_task = tokio::spawn(async move { let mut dropped = 0u64; + let mut last_route: Option<(String, String)> = None; loop { let frame = match receiver.recv().await { Ok(frame) => frame, Err(broadcast::error::RecvError::Lagged(count)) => { dropped = dropped.saturating_add(count); + if let Some((session_id, session_type)) = &last_route { + recovery.send_replace(Some(bridge_recovery_frame( + &provider, + session_type, + session_id, + dropped, + ))); + dropped = 0; + } continue; } Err(broadcast::error::RecvError::Closed) => break, @@ -1250,6 +1383,11 @@ fn spawn_broadcast_bridge( let Ok(value) = serde_json::from_str::(&frame) else { continue; }; + if let (Some(session_id), Some(session_type)) = + (value["sessionId"].as_str(), value["sessionType"].as_str()) + { + last_route = Some((session_id.to_owned(), session_type.to_owned())); + } if let Some(outcome) = parse_send_outcome(&value) { let _ = send_outcomes.send(outcome); } @@ -1363,26 +1501,44 @@ fn spawn_broadcast_bridge( } } } - if dropped > 0 - && events - .try_send(AgentEvent::Provider { - payload: json!({"type":"freshAgent.host.backpressure","dropped":dropped}), - }) - .is_ok() - { - dropped = 0; - } if captured_decision { continue; } if events - .try_send(AgentEvent::Provider { payload: value }) + .try_send(AgentEvent::Provider { + payload: value.clone(), + }) .is_err() { dropped = dropped.saturating_add(1); } + if dropped > 0 { + if let Some(session_id) = value["sessionId"].as_str() { + recovery.send_replace(Some(bridge_recovery_frame( + &provider, + value["sessionType"].as_str().unwrap_or("freshcodex"), + session_id, + dropped, + ))); + dropped = 0; + } + } } }); + BroadcastBridgeTasks { + recovery: recovery_task, + forward: forward_task, + } +} + +fn bridge_recovery_frame( + provider: &FreshProvider, + session_type: &str, + session_id: &str, + dropped: u64, +) -> Value { + json!({"type":"freshAgent.event", "provider":if matches!(provider, FreshProvider::Kilroy) {"claude"} else {provider.as_str()}, + "sessionType":session_type,"sessionId":session_id,"event":{"type":"freshAgent.host.backpressure","dropped":dropped}}) } /// The claude conversation a `freshAgent.created` names in its `sessionRef`. @@ -1546,13 +1702,31 @@ mod tests { static OPENCODE_SNAPSHOT_ENV_LOCK: Mutex<()> = Mutex::const_new(()); - async fn task5_durable_native_control( - method: &str, - params: Value, - event_type: &str, - invalid_answer: Value, - valid_answer: Value, - ) { + #[tokio::test] + async fn task6_dropping_transport_closes_its_owned_bridge_without_more_provider_frames() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let transport = HostedTransport::new(FreshProvider::Codex).await; + let mut events = transport.take_event_stream().unwrap(); + // No native process/session was started. Dropping this transport must + // release its producers even when the consumer is still waiting. + drop(transport); + let final_event = tokio::time::timeout(Duration::from_millis(300), events.recv()) + .await + .expect("owned bridge must close without another provider frame"); + assert!(final_event.is_none()); + } + + struct NativeCodexFixture { + transport: Arc, + profile: FreshAgentProfile, + diagnostic: broadcast::Receiver, + dir: tempfile::TempDir, + _exec_dir: tempfile::TempDir, + _environment: SnapshotProviderEnv, + _lock: tokio::sync::MutexGuard<'static, ()>, + } + + async fn native_codex_fixture(behavior: Value) -> NativeCodexFixture { let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -1593,7 +1767,7 @@ mod tests { std::fs::create_dir_all(&codex_home).unwrap(); std::env::set_var("CODEX_HOME", codex_home); std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); - std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":41,"method":method,"params":params}}}).to_string()); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()); let transport = HostedTransport::new(FreshProvider::Codex).await; let mut profile = parity_profile(FreshProvider::Codex); profile.cwd = dir.path().to_string_lossy().into(); @@ -1606,7 +1780,30 @@ mod tests { profile.model_selection = None; profile.effort = None; profile.permission_mode = Some("never".into()); - let mut diagnostic = transport.provider_frames.subscribe(); + let diagnostic = transport.provider_frames.subscribe(); + NativeCodexFixture { + transport, + profile, + diagnostic, + dir, + _exec_dir: exec_dir, + _environment, + _lock, + } + } + + async fn task5_durable_native_control( + method: &str, + params: Value, + event_type: &str, + invalid_answer: Value, + valid_answer: Value, + ) { + let mut fixture = native_codex_fixture(json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":41,"method":method,"params":params}}})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let dir = &fixture.dir; + let diagnostic = &mut fixture.diagnostic; let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport.clone()) .await .unwrap_or_else(|error| { @@ -1679,6 +1876,462 @@ mod tests { .await; } + #[tokio::test] + async fn task6_final_loss_recovers_native_retained_transcript_and_attention() { + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let diagnostic = &mut fixture.diagnostic; + transport.start(&profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("retained-send").unwrap(), + "retained prompt", + &profile, + ) + .await + .unwrap(); + let completion = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = serde_json::from_str(&diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break frame["event"]["at"].clone(); + } + } + }) + .await + .unwrap(); + // There are no more native frames; only draining the bounded output + // channel is allowed to drive recovery of this final completion. + tokio::time::sleep(Duration::from_millis(50)).await; + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + break payload; + } + } + } + }) + .await + .unwrap(); + let snapshot = &recovered["event"]["retainedState"]["snapshot"]; + let marks = &recovered["event"]["retainedState"]["attentionMarks"]; + let retained = serde_json::to_string(snapshot).unwrap(); + let native_id = transport.session_id.lock().await.clone().unwrap(); + transport.clone().stop().await.unwrap(); + assert!( + retained.contains("Native streaming fragment and final answer"), + "recover exact event-owned body without thread/read: {recovered}" + ); + assert_eq!(snapshot["threadId"], native_id); + assert_eq!(marks[0]["lastTurnCompleteAt"], completion); + assert_eq!( + snapshot["extensions"]["codex"]["projection"]["turnOutputStates"] + .as_object() + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn task6_journal_reset_reads_authoritative_source_after_final_native_frame() { + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let dir = &fixture.dir; + let diagnostic = &mut fixture.diagnostic; + let actor = FreshAgentHostActor::open_with_bounds( + dir.path().join("actor"), + profile, + transport.clone(), + 256 * 1024, + 8, + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("reset-send").unwrap(), + "retained prompt".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = serde_json::from_str(&diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + for index in 0..20 { + actor + .record_event(AgentEvent::Provider { + payload: json!({"noise":index}), + }) + .await + .unwrap(); + } + let batch = actor.read_events(1, 8).await; + assert!(batch.reset_required); + let batch = serde_json::to_value(batch).unwrap(); + actor.stop().await.unwrap(); + assert!(serde_json::to_string(&batch["retainedState"]["snapshot"]).unwrap().contains("Native streaming fragment and final answer"), "journal reset must read the provider source even after its history was evicted: {batch}"); + assert!( + batch["retainedState"]["attentionMarks"][0]["lastTurnCompleteAt"] + .as_i64() + .is_some() + ); + } + + async fn task6_recover_final_native_question(blocking: Option) { + let witness = tempfile::tempdir().unwrap(); + let operations = witness.path().join("native-operations.jsonl"); + let behavior = if let Some(blocking) = blocking { + json!({"recordTurns":true,"liveTranscript":true,"appendThreadOperationLogPath":operations,"serverRequestsByPrompt":{"ask":{"id":51,"method":"item/tool/requestUserInput","params":{"isBlocking":blocking,"questions":[{"id":"choice","header":"Choice","question":"Where?","options":null}]}}}}) + } else { + json!({"appendThreadOperationLogPath":operations,"overrides":{"turn/start":{"result":{"turn":{"id":"ongoing-turn","status":"inProgress"}}}},"notificationsAfterMethods":{"turn/start":[{"method":"turn/started","params":{"threadId":"thread-new-1","turn":{"id":"ongoing-turn","status":"inProgress"}}},{"method":"item/completed","params":{"threadId":"thread-new-1","turnId":"ongoing-turn","item":{"id":"async-final","type":"agentMessage","text":"Ongoing work with an async question","delivery":"async","questions":[{"title":"Where?","options":["Staging"]}]}}}]}}) + }; + let mut fixture = native_codex_fixture(behavior).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + while events.try_recv().is_ok() {} + let reads_before = std::fs::read_to_string(&operations) + .unwrap_or_default() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("final-question").unwrap(), + "ask", + &fixture.profile, + ) + .await + .unwrap(); + let kind = if blocking.is_some() { + "freshAgent.turn.waiting" + } else { + "freshAgent.question.available" + }; + let at = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == kind { + break frame["event"]["at"].clone(); + } + } + }) + .await + .unwrap(); + // No native frames follow the final waiting/action edge. Only releasing + // downstream capacity can restore the dropped content and current control. + tokio::time::sleep(Duration::from_millis(50)).await; + let (recovery, decisions) = tokio::time::timeout(Duration::from_secs(3), async { + let mut recovery = None; + let mut decisions = std::collections::BTreeMap::new(); + loop { + match events.recv().await.unwrap() { + AgentEvent::Provider { payload } + if payload["event"]["retainedState"].is_object() => + { + recovery = Some(payload["event"]["retainedState"].clone()) + } + AgentEvent::PermissionRequested { + decision_id, + payload, + } => { + decisions.insert(decision_id, payload); + } + _ => {} + } + if recovery.is_some() && (blocking.is_none() || !decisions.is_empty()) { + break (recovery.unwrap(), decisions); + } + } + }) + .await + .expect("the final native question must recover without another provider frame"); + let reads_after = std::fs::read_to_string(&operations) + .unwrap_or_default() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + let mut drained = None; + if blocking.is_some() { + let decision = decisions.keys().next().unwrap(); + transport + .resolve_permission(decision, json!({"answers":{"choice":"Staging"}})) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + drained = transport.retained_state().await.unwrap(); + } + transport.clone().stop().await.unwrap(); + assert_eq!( + reads_after, reads_before, + "bridge recovery must not invoke native history merely to repair loss" + ); + assert_eq!(recovery["snapshot"]["status"], "running"); + let mark = &recovery["attentionMarks"][0]; + assert!(mark.get("lastTurnCompleteAt").is_none()); + if let Some(blocking) = blocking { + assert_eq!(mark["lastWaitingAt"], at); + assert_eq!(recovery["pendingControls"].as_array().unwrap().len(), 1); + assert_eq!(recovery["pendingControls"][0]["isBlocking"], blocking); + assert_eq!( + decisions.len(), + 1, + "duplicate recovery deliveries retain one actual decision identity" + ); + assert_eq!( + decisions.values().next().unwrap()["providerRequest"]["id"], + 51 + ); + assert!(drained.unwrap()["attentionMarks"][0].get("lastWaitingAt").is_none(),"the recovered question remains answerable and its final awaited drain clears waiting"); + } else { + assert_eq!(mark["lastQuestionAvailableAt"], at); + assert!(mark.get("lastWaitingAt").is_none()); + assert!(recovery["pendingControls"].as_array().unwrap().is_empty()); + assert!( + decisions.is_empty(), + "async transcript content must never become a durable permission decision" + ); + assert!(serde_json::to_string(&recovery["snapshot"]) + .unwrap() + .contains("Ongoing work with an async question")); + } + } + + #[tokio::test] + async fn task6_final_false_flag_question_recovers_an_answerable_decision() { + task6_recover_final_native_question(Some(false)).await; + } + #[tokio::test] + async fn task6_final_blocking_question_recovers_an_answerable_decision() { + task6_recover_final_native_question(Some(true)).await; + } + #[tokio::test] + async fn task6_final_async_content_recovers_without_a_permission_or_waiting() { + task6_recover_final_native_question(None).await; + } + + #[tokio::test] + async fn task6_final_completion_overrides_cached_active_native_history_without_an_rpc() { + let witness = tempfile::tempdir().unwrap(); + let release = witness.path().join("release-turn"); + let operations = witness.path().join("operations.jsonl"); + let mut fixture=native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true,"waitForTurnReleasePath":release,"appendThreadOperationLogPath":operations})).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + transport + .dispatch( + &RequestId::parse("cached-active").unwrap(), + "run", + &fixture.profile, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.transcript.patch" + && frame.to_string().contains("Native streaming fragment") + { + break; + } + } + }) + .await + .unwrap(); + let active = transport.snapshot().await.unwrap(); + assert_eq!( + active["status"], "running", + "precondition: exact native history was cached while genuinely active" + ); + let reads_before = std::fs::read_to_string(&operations) + .unwrap() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + std::fs::write(&release, b"complete native turn").unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["retainedState"].is_object() { + break payload["event"]["retainedState"].clone(); + } + } + } + }) + .await + .unwrap(); + let reads_after = std::fs::read_to_string(operations) + .unwrap() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + transport.clone().stop().await.unwrap(); + assert_eq!(reads_after, reads_before); + assert_eq!( + recovered["snapshot"]["status"], "idle", + "a final completion is authoritative over an earlier active native read: {recovered}" + ); + assert_eq!(recovered["snapshot"]["capabilities"]["send"], true); + assert_eq!(recovered["snapshot"]["capabilities"]["interrupt"], false); + } + + #[tokio::test] + async fn task6_upstream_lag_ending_in_a_control_recovers_without_later_frames() { + let (bus, rx) = broadcast::channel(1); + let (created, _) = watch::channel(None); + let (native, _) = watch::channel(Some("thread-upstream".to_string())); + let (events, mut event_rx) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (send_outcomes, _) = broadcast::channel(8); + let (kill_outcomes, _) = broadcast::channel(8); + let _bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + rx, + BroadcastBridgeOutputs { + retained_codex: None, + created, + native, + events, + send_outcomes, + kill_outcomes, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + let request = |id: &str| { + json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-upstream","event":{"type":"freshAgent.question.request","requestId":id}}).to_string() + }; + bus.send(request("first-control")).unwrap(); + // The first durable conversion waits for the full downstream queue. + tokio::time::sleep(Duration::from_millis(50)).await; + for _ in 0..10 { + bus.send(request("final-control")).unwrap(); + } + let recovered = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(AgentEvent::Provider { payload }) = event_rx.recv().await { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + break payload; + } + } + } + }) + .await; + assert!(recovered.is_ok(),"lag before the journal must recover even when the last retained frame is a durable control and the provider becomes silent"); + } + + #[tokio::test] + async fn task6_final_dropped_frame_recovers_without_another_provider_frame() { + let (bus, rx) = broadcast::channel(8); + let (created, _) = watch::channel(None); + let (native, _) = watch::channel(Some("thread-final".to_string())); + let (events, mut event_rx) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (send_outcomes, _) = broadcast::channel(8); + let (kill_outcomes, _) = broadcast::channel(8); + let _bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + rx, + BroadcastBridgeOutputs { + retained_codex: None, + created, + native, + events, + send_outcomes, + kill_outcomes, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + bus.send(json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-final","event":{"type":"freshAgent.turn.complete","at":100}}).to_string()).unwrap(); + // Leave capacity unavailable while the bridge consumes its only input. + tokio::time::sleep(Duration::from_millis(50)).await; + event_rx.recv().await.unwrap(); + let recovery = tokio::time::timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("a final drop must independently await capacity and recover") + .unwrap(); + let AgentEvent::Provider { payload } = recovery else { + panic!("routed recovery event") + }; + assert_eq!(payload["type"], "freshAgent.event"); + assert_eq!(payload["provider"], "codex"); + assert_eq!(payload["sessionType"], "freshcodex"); + assert_eq!(payload["sessionId"], "thread-final"); + assert_eq!(payload["event"]["type"], "freshAgent.host.backpressure"); + } + #[tokio::test] async fn managed_provider_claude_native_identity_uses_actual_adapter_event_name() { let (bus, rx) = broadcast::channel(8); @@ -1687,10 +2340,11 @@ mod tests { let (events, mut event_rx) = mpsc::channel(8); let (send_outcomes, _) = broadcast::channel(8); let (kill_outcomes, _) = broadcast::channel(8); - spawn_broadcast_bridge( + let _bridge = spawn_broadcast_bridge( FreshProvider::Claude, rx, BroadcastBridgeOutputs { + retained_codex: None, created, native, events, diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 86edfefc2..3536b25b4 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -2188,7 +2188,33 @@ impl Supervisor { ) .await? { - HostResult::FreshAgentEvents(events) => { + HostResult::FreshAgentEvents(mut events) => { + let current = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + if current.incarnation_id() != handle.incarnation_id() { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent events owner changed", + )); + } + if let Some(retained) = &mut events.retained_state { + let after = self + .authenticate_host(current.incarnation_id(), current.runtime_dir()) + .await?; + if after.host_boot_id != host.host_boot_id { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent retained source boot changed", + )); + } + retained["source"]["soulId"] = serde_json::json!(soul_id); + retained["source"]["incarnationId"] = + serde_json::json!(handle.incarnation_id()); + retained["source"]["hostBootId"] = serde_json::json!(host.host_boot_id); + } for event in &events.events { if let freshell_runtime_protocol::AgentEvent::Started { native_session_id } = &event.event @@ -3318,7 +3344,8 @@ where reply.result, Ok(AdminResult::FreshAgentHistory(_) | AdminResult::FreshAgentSnapshot(_) - | AdminResult::FreshAgentChildSnapshot(_)) + | AdminResult::FreshAgentChildSnapshot(_) + | AdminResult::FreshAgentEvents(_)) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -3552,7 +3579,9 @@ pub(crate) async fn request_host_reply( .map_err(|error| unreachable(error.to_string()))?; let reply_limit = if matches!( envelope.body, - HostCommand::FreshAgentReadSnapshot { .. } | HostCommand::FreshAgentReadChild { .. } + HostCommand::FreshAgentReadSnapshot { .. } + | HostCommand::FreshAgentReadChild { .. } + | HostCommand::FreshAgentReadEvents { .. } ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { diff --git a/crates/freshell-ws/src/hosted_fresh_agent.rs b/crates/freshell-ws/src/hosted_fresh_agent.rs index bee4e441d..b50c21585 100644 --- a/crates/freshell-ws/src/hosted_fresh_agent.rs +++ b/crates/freshell-ws/src/hosted_fresh_agent.rs @@ -99,6 +99,9 @@ pub(crate) fn hosted_route(message: &ClientMessage) -> HostedRoute { } pub trait HostedFreshAgentGateway: Send + Sync { + fn attention_marks(&self) -> BoxFuture<'_, Vec> { + Box::pin(async { Vec::new() }) + } fn dispatch(self: Arc, command: HostedFreshAgentCommand) -> BoxFuture<'static, ()>; } @@ -113,6 +116,13 @@ pub fn install_gateway(gateway: Arc) -> Result<(), .map_err(|_| "hosted fresh-agent gateway is already installed") } +pub(crate) async fn retained_attention_marks() -> Vec { + match GATEWAY.get() { + Some(gateway) => gateway.attention_marks().await, + None => Vec::new(), + } +} + pub(crate) fn dispatch_if_installed(message: &ClientMessage) -> bool { let Some(gateway) = GATEWAY.get() else { return false; diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index ca4cd589a..844427e15 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -659,6 +659,7 @@ pub async fn build_handshake_with_capabilities( let fresh_agent_attention = if fresh_agent_attention_replay_v1 { let mut marks = state.fresh_claude.attention_marks(); marks.extend(state.fresh_codex.attention_marks().await); + marks.extend(hosted_fresh_agent::retained_attention_marks().await); (!marks.is_empty()).then_some(marks) } else { None diff --git a/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs index 14d754ee9..9216a02bc 100644 --- a/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs +++ b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs @@ -17,9 +17,9 @@ impl Drop for Environment { } } -async fn ready(url: &str) -> Value { +async fn ready_with_replay(url: &str, enabled: bool) -> Value { let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); - ws.send(Message::Text(json!({"type":"hello","token":AUTH_TOKEN,"protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"capabilities":{"freshAgentAttentionReplayV1":true}}).to_string())).await.unwrap(); + ws.send(Message::Text(json!({"type":"hello","token":AUTH_TOKEN,"protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"capabilities":{"freshAgentAttentionReplayV1":enabled}}).to_string())).await.unwrap(); let frame = tokio::time::timeout(FRAME_BUDGET, ws.next()) .await .unwrap() @@ -30,6 +30,9 @@ async fn ready(url: &str) -> Value { }; serde_json::from_str(&raw).unwrap() } +async fn ready(url: &str) -> Value { + ready_with_replay(url, true).await +} async fn event(ws: &mut TestWs, kind: &str) -> Value { loop { let value = next_frame_of_type(ws, "freshAgent.event").await; @@ -81,6 +84,11 @@ async fn run_replay(async_question: bool) { } else { None }; + let unnegotiated = ready_with_replay(&url, false).await; + assert!( + unnegotiated.get("freshAgentAttention").is_none(), + "unnegotiated ready must omit replay" + ); let before = ready(&url).await; state.fresh_codex.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":session_id,"requestId":request["requestId"],"answers":{"choice":"answer"}})).unwrap()).await; let after = ready(&url).await; @@ -116,3 +124,211 @@ async fn task5_ready_replays_awaited_codex_controls_only_while_pending() { async fn task5_ready_separates_nonblocking_async_question_attention() { run_replay(true).await; } + +async fn replay_after_native_exit(mint_new: bool) { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var("CODEX_HOME", dir.path().join("codex")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", json!({"threadStartThreadId":"replay-old","recordTurns":true,"exitProcessAfterMethodsOnce":["turn/start"]}).to_string()); + let (url, _, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut ws, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"recovery-create","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut ws, "freshAgent.created").await; + let old = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"recovery-send","provider":"codex","sessionType":"freshcodex","sessionId":old,"text":"finish"})).unwrap()).await; + let complete = event(&mut ws, "freshAgent.turn.complete").await; + loop { + let status = event(&mut ws, "freshAgent.status").await; + if status["status"] == "exited" { + break; + } + } + let before = ready(&url).await; + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",if mint_new {json!({"threadStartThreadId":"replay-new","overrides":{"thread/resume":{"error":{"code":-32001,"message":"Thread not found"}}}})} else {json!({})}.to_string()); + state.fresh_codex.handle_attach(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":old,"cwd":dir.path()})).unwrap()).await; + let current = if mint_new { + next_frame_of_type(&mut ws, "freshAgent.session.materialized").await["sessionId"] + .as_str() + .unwrap() + .to_string() + } else { + old.to_string() + }; + let after = ready(&url).await; + state + .fresh_codex + .handle_kill( + serde_json::from_value( + json!({"provider":"codex","sessionType":"freshcodex","sessionId":current}), + ) + .unwrap(), + ) + .await; + next_frame_of_type(&mut ws, "freshAgent.killed").await; + let closed = ready(&url).await; + state.fresh_codex.shutdown().await; + let mark = |value: &Value, id: &str| { + value["freshAgentAttention"] + .as_array() + .and_then(|marks| marks.iter().find(|mark| mark["sessionId"] == id)) + .cloned() + .unwrap_or(Value::Null) + }; + assert_eq!(mark(&before, old)["lastTurnCompleteAt"], complete["at"]); + assert_eq!( + mark(&after, old)["lastTurnCompleteAt"], + complete["at"], + "old envelope must retain its exact attention after actual native exit/recovery: {after}" + ); + assert_eq!( + mark(&after, ¤t)["lastTurnCompleteAt"], + complete["at"], + "materialized alias and current identity share one attention history: {after}" + ); + assert!( + mark(&closed, old).is_null() && mark(&closed, ¤t).is_null(), + "explicit close forgets every alias: {closed}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_ready_retains_completion_across_actual_native_exit_and_same_id_recovery() { + replay_after_native_exit(false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_ready_retains_materialization_aliases_and_close_forgets_them() { + replay_after_native_exit(true).await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_actual_same_session_resume_keeps_unsaved_output_under_a_new_finite_source() { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let pid_file = dir.path().join("owned-native.pid"); + let notifications = dir.path().join("native-notifications.jsonl"); + let wrapper = dir.path().join("owned-codex.mjs"); + std::fs::write( + &wrapper, + format!( + "import fs from 'node:fs'; fs.writeFileSync({},String(process.pid)); await import({});", + json!(pid_file), + json!(fixture) + ), + ) + .unwrap(); + std::env::set_var("CODEX_CMD", format!("node {}", wrapper.display())); + std::env::set_var("CODEX_HOME", dir.path().join("codex")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"liveTranscript":true,"threadStartThreadId":"warm-same-session","waitForTurnReleasePath":dir.path().join("never-released"),"appendNativeNotificationLogPath":notifications}).to_string()); + let (url, _, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut ws, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"warm-create","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut ws, "freshAgent.created").await; + let session = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"warm-send","provider":"codex","sessionType":"freshcodex","sessionId":session,"text":"Native partial before crash"})).unwrap()).await; + loop { + let patch = event(&mut ws, "freshAgent.transcript.patch").await; + if serde_json::to_string(&patch) + .unwrap() + .contains("Native streaming fragment") + { + break; + } + } + let before = state.fresh_codex.retained_state(session).await.unwrap(); + let native_turn = before["snapshot"]["extensions"]["codex"]["projection"]["eventOwnedTurnIds"] + [0] + .as_str() + .unwrap() + .to_string(); + let pid = std::fs::read_to_string(&pid_file) + .unwrap() + .parse::() + .unwrap(); + // The test-owned wrapper wrote this PID before importing the native fake. + // This exact child is inside the strict disposable process-kill sandbox. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + event(&mut ws, "freshAgent.turn.complete").await; + let dead = state.fresh_codex.retained_state(session).await.unwrap(); + // Canonical saved history deliberately lacks the event-owned tool/text body. + // A native refresh may reveal only that finite interrupted representation. + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"appendNativeNotificationLogPath":notifications,"threadReadResults":{session:{"id":session,"ephemeral":false,"status":{"type":"idle"},"turns":[{"id":native_turn,"status":"interrupted","itemsView":"full","items":[]}]}}}).to_string()); + state.fresh_codex.handle_attach(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":session,"cwd":dir.path()})).unwrap()).await; + let rebound = state.fresh_codex.retained_state(session).await.unwrap(); + let refreshed = state.fresh_codex.get_snapshot(session, None).await.unwrap(); + state.fresh_codex.shutdown().await; + let starts = std::fs::read_to_string(notifications) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|frame| frame["method"] == "turn/started") + .count(); + assert!(serde_json::to_string(&before) + .unwrap() + .contains("Native streaming fragment")); + assert!(serde_json::to_string(&dead) + .unwrap() + .contains("Native streaming fragment")); + assert_ne!( + before["source"]["projectionId"], rebound["source"]["projectionId"], + "actual resume must allocate a new source, never reuse the old append authority" + ); + assert!(serde_json::to_string(&rebound).unwrap().contains("Native streaming fragment"),"new retained source must preserve available unsaved output without another native history RPC: {rebound}"); + assert_eq!( + rebound["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"][&native_turn] + ["diagnostic"], + "interrupted" + ); + assert_eq!( + rebound["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"][&native_turn] + ["refreshPending"], + false + ); + assert!( + serde_json::to_string(&refreshed) + .unwrap() + .contains("Native streaming fragment"), + "separate native refresh must keep available interrupted evidence" + ); + assert_eq!( + starts, 1, + "resume and retained reconciliation must never replay the prompt" + ); +} diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index c4de7cb66..75555902e 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -284,6 +284,7 @@ export const FreshAgentTranscriptPatchSchema = z.object({ sessionId: z.string().min(1), projectionId: z.string().min(1), historyGeneration: EventRevisionSchema, + afterRevision: EventRevisionSchema.optional(), ops: z.array(z.discriminatedUnion('op', [ z.object({ ...TranscriptPatchBase, op: z.literal('resetTurn'), turns: z.array(FreshAgentTurnSchema), completedItemIds: z.array(z.string()).optional(), nonAppendableItemIds: z.array(z.string()).optional() }).strict(), z.object({ ...TranscriptPatchBase, op: z.literal('upsertItem'), row: FreshAgentTurnSchema, item: FreshAgentTranscriptItemSchema, complete: z.boolean().optional() }).strict(), diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 59e42c6d5..857a8aec3 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -3399,7 +3399,9 @@ export function FreshAgentView({ const resolved = next as FreshAgentSnapshot const projectionMetadata = readTranscriptProjection(resolved) const projectionLocator = { sessionType: requestSessionType, provider, sessionId } - const liveProjection = appStore.getState().freshAgentLive?.bySession[makeFreshAgentSessionKey(projectionLocator)]?.projection + const liveSession = appStore.getState().freshAgentLive?.bySession[makeFreshAgentSessionKey(projectionLocator)] + const liveProjection = liveSession?.projection + if (projectionMetadata && liveSession?.retiredProjectionIds?.[projectionMetadata.id] === true) return if (projectionMetadata && liveProjection?.id === projectionMetadata.id && liveProjection.historyGeneration > projectionMetadata.historyGeneration) { requestSnapshotRefresh('reconnect') return @@ -4037,6 +4039,19 @@ export function FreshAgentView({ ? makeFreshAgentSessionKey({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider }) : undefined const landedTranscript = useMemo(() => landedTranscriptOf(snapshot), [snapshot]) + const retainedSnapshot = useAppSelector((state) => liveSessionKey ? state.freshAgentLive?.bySession[liveSessionKey]?.retainedSnapshot : undefined) + useEffect(() => { + if (!retainedSnapshot) return + // A provider-retained source supersedes history requests already in flight. + snapshotRequestAuthorityRef.current.applied = ++snapshotRequestAuthorityRef.current.next + commitSnapshot(retainedSnapshot) + snapshotDirtyRef.current = false + snapshotDirtyBaseRevisionRef.current = null + setSnapshotDirty(false) + setSnapshotRevealError(null) + if (!hiddenRef.current) requestSnapshotRefresh('event') + else surfaceRefreshOwedRef.current = true + }, [retainedSnapshot, commitSnapshot, requestSnapshotRefresh]) const rawCodexLiveMetadata = useAppSelector((state) => liveSessionKey ? state.freshAgentLive?.bySession[liveSessionKey]?.codexMetadata : undefined) const boundProjectionIdentity = useAppSelector((state) => { const projection = liveSessionKey ? state.freshAgentLive?.bySession[liveSessionKey]?.projection : undefined @@ -4047,9 +4062,8 @@ export function FreshAgentView({ const projectionNeedsResnapshot = useAppSelector((state) => !hidden && liveSessionKey ? state.freshAgentLive?.bySession[liveSessionKey]?.projection?.needsResnapshot : false) useEffect(() => { if (!projectionNeedsResnapshot) return - markSnapshotDirty() - requestRevealRefresh(true) - }, [projectionNeedsResnapshot, markSnapshotDirty, requestRevealRefresh]) + requestSnapshotRefresh('event') + }, [projectionNeedsResnapshot, requestSnapshotRefresh]) const liveTailActive = useAppSelector((state) => ( liveSessionKey ? liveTailShowsContent(state.freshAgentLive?.bySession[liveSessionKey], landedTranscript) : false )) @@ -4500,6 +4514,8 @@ export function FreshAgentView({ })) }, [dispatch, freshOpenCodeRouteCwd, paneContent.checkpointWorkspace]) + const runtimeOwnerEpoch = runtimeOwner?.epoch + const runtimeOwnerGeneration = runtimeOwner?.generation const content = useMemo(() => { const retainedHistory = snapshot?.extensions?.[snapshot.provider]?.nativeHistoryRetention const partialHistory = isRecord(retainedHistory) && retainedHistory.partial === true @@ -4597,7 +4613,7 @@ export function FreshAgentView({ const childParent: FreshAgentChildParent | undefined = paneContent.provider === 'codex' && paneContent.sessionType === 'freshcodex' && contextSessionId ? { provider: 'codex', sessionType: 'freshcodex', parentSessionId: effectiveSessionRef(paneContent)?.sessionId ?? snapshot?.threadId ?? contextSessionId, - ...(runtimeOwner ? { observedEpoch: runtimeOwner.epoch, observedGeneration: runtimeOwner.generation } : {}), + ...(runtimeOwnerEpoch !== undefined && runtimeOwnerGeneration !== undefined ? { observedEpoch: runtimeOwnerEpoch, observedGeneration: runtimeOwnerGeneration } : {}), } : undefined const closeChild = () => dispatch(mergePaneContent({ tabId, paneId, updates: { observedChildThreadId: undefined } })) return ( @@ -5160,8 +5176,9 @@ export function FreshAgentView({ restartStuckSidecar, rewindToTurn, openDelegationSession, - runtimeOwner?.epoch, - runtimeOwner?.generation, + runtimeOwnerEpoch, + runtimeOwnerGeneration, + requestSnapshotRefresh, runShellCommand, sessionEnded, sessionErrorMessage, diff --git a/src/components/fresh-agent/fresh-agent-event-classes.ts b/src/components/fresh-agent/fresh-agent-event-classes.ts index fda412add..2ea8add3d 100644 --- a/src/components/fresh-agent/fresh-agent-event-classes.ts +++ b/src/components/fresh-agent/fresh-agent-event-classes.ts @@ -21,6 +21,8 @@ export const SURFACE_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new // Events that re-drive the pane's snapshot while it is visible. export const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ ...SURFACE_INVALIDATING_FRESH_AGENT_EVENTS, + 'freshAgent.host.backpressure', + 'freshAgent.host.journal.reset', 'freshAgent.session.changed', 'freshAgent.session.snapshot', 'freshAgent.result', @@ -39,6 +41,8 @@ export const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new // hidden pane immediately usable when its last transcript snapshot is still // current. export const TRANSCRIPT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + 'freshAgent.host.backpressure', + 'freshAgent.host.journal.reset', 'freshAgent.session.changed', 'freshAgent.result', 'freshAgent.turn.complete', diff --git a/src/lib/fresh-agent-transcript-projection.ts b/src/lib/fresh-agent-transcript-projection.ts index 1c3a1bf67..a244d1d79 100644 --- a/src/lib/fresh-agent-transcript-projection.ts +++ b/src/lib/fresh-agent-transcript-projection.ts @@ -28,6 +28,11 @@ export function applyFreshAgentTranscriptPatch(state: TranscriptProjection, inpu if (!parsed.success) return state const patch = parsed.data if (patch.projectionId !== state.id || patch.historyGeneration < state.historyGeneration) return state + // Appends can be coalesced across several revisions. The producer states + // their consumed prefix explicitly; a jump in the final revision is legal. + if (patch.afterRevision !== undefined && patch.afterRevision > state.eventRevision) { + return { ...state, needsResnapshot: true, eventRevision: Math.max(state.eventRevision, ...patch.ops.map((op) => op.revision)) } + } const next: TranscriptProjection = patch.historyGeneration > state.historyGeneration ? { id: state.id, historyGeneration: patch.historyGeneration, eventRevision: state.eventRevision, turns: [], turnRows: {}, closedItems: {}, settledTurns: {}, removedTurns: {}, turnRevisions: {}, deferredOps: [] } : { ...state, turns: [...state.turns], turnRows: { ...state.turnRows }, closedItems: { ...state.closedItems }, settledTurns: { ...state.settledTurns }, removedTurns: { ...state.removedTurns }, turnRevisions: { ...state.turnRevisions }, deferredOps: [...state.deferredOps ?? []], turnOrder: [...state.turnOrder ?? []], nonAppendableItemIds: { ...state.nonAppendableItemIds } } diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index 29bce1fd3..cce451a7d 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -1,4 +1,5 @@ import type { AppDispatch } from '@/store/store' +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' import type { SessionRef } from '@shared/session-contract' import type { FreshAgentRecoveryStoppedMessage, ReadyMessage, SessionRuntimeOwnerMessage } from '@shared/ws-protocol' @@ -12,8 +13,8 @@ import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' import type { LiveBlockKind, LiveNotice, LiveNoticeLevel, LiveRateLimit, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' -import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced, transcriptProjectionBound, transcriptPatchApplied, codexMetadataReceived } from '@/store/freshAgentLiveSlice' -import { FreshAgentTranscriptPatchSchema } from '@shared/fresh-agent-contract' +import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced, transcriptProjectionBound, transcriptPatchApplied, codexMetadataReceived, transcriptProjectionSnapshotReceived } from '@/store/freshAgentLiveSlice' +import { FreshAgentSnapshotSchema, FreshAgentTranscriptPatchSchema } from '@shared/fresh-agent-contract' import { addPermissionRequest, addQuestionRequest, @@ -470,6 +471,42 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh if (event.type !== 'freshAgent.stream') streamBatcherFor(dispatch).flush(locator) switch (event.type) { + case 'freshAgent.host.backpressure': + case 'freshAgent.host.journal.reset': { + const retained = event.retainedState + if (!isObject(retained) || !isObject(retained.source)) return true + const source = retained.source + const parsed = FreshAgentSnapshotSchema.safeParse(retained.snapshot) + if (!parsed.success || parsed.data.provider !== locator.provider || parsed.data.sessionType !== locator.sessionType + || parsed.data.threadId !== (source.nativeSessionId ?? sessionId) + || !isObject(parsed.data.extensions?.codex?.projection) + || parsed.data.extensions.codex.projection.id !== source.projectionId) return true + const snapshot = parsed.data + const sourceKey = JSON.stringify([source.soulId, source.incarnationId, source.hostBootId, source.projectionId]) + dispatch((innerDispatch, getState) => { + const root = getState() + const owner = root.freshAgent.runtimeOwners?.[`${locator.provider}:${sessionId}`] + if (owner && (owner.epoch !== source.ownerEpoch || owner.generation !== source.ownerGeneration || owner.ownerKind !== 'fresh-agent')) return + const soul = root.managedRuntime?.souls.find((soul) => soul.soulId === source.soulId) + if (soul && (soul.incarnationId !== source.incarnationId || soul.hostBootId && soul.hostBootId !== source.hostBootId)) return + innerDispatch(transcriptProjectionSnapshotReceived({ ...locator, snapshot, retainedSourceKey: sourceKey })) + const live = getState().freshAgentLive?.bySession[makeFreshAgentSessionKey(locator)] + if (live?.retainedSourceKey !== sourceKey || live.retainedSnapshot !== snapshot) return + const status = (['creating', 'starting', 'connected', 'running', 'idle', 'compacting', 'exited', 'stuck'] as const).find((status) => status === snapshot.status) + if (status) innerDispatch(sessionSnapshotReceived({ ...locator, status, latestTurnId: snapshot.latestTurnId ?? null, revision: snapshot.revision })) + for (const control of Array.isArray(retained.pendingControls) ? retained.pendingControls : []) { + if (isObject(control)) handleFreshAgentTransportEvent(innerDispatch, { ...msg, event: control }) + } + for (const mark of Array.isArray(retained.attentionMarks) ? retained.attentionMarks : []) { + if (!isObject(mark)) continue + for (const [field, apply] of [['lastTurnCompleteAt', applyFreshAgentCompletion], ['lastWaitingAt', applyFreshAgentWaiting], ['lastQuestionAvailableAt', applyFreshAgentQuestionAvailable]] as const) { + const at = mark[field] + if (typeof at === 'number' && Number.isFinite(at)) innerDispatch(apply({ provider: locator.provider, sessionId, at })) + } + } + }) + return true + } case 'freshAgent.transcript.patch': { const parsed = FreshAgentTranscriptPatchSchema.safeParse(event) if (parsed.success && parsed.data.sessionId === sessionId) dispatch(transcriptPatchApplied({ ...locator, patch: parsed.data })) diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index 05344f259..071eae552 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -46,6 +46,15 @@ function sessionFor(state: FreshAgentLiveState, locator: FreshAgentSessionLocato return state.bySession[key] } +function retireRetainedSource(session: FreshAgentLiveSession): void { + if (session.retainedSourceKey) { + session.retiredRetainedSourceKeys ??= {} + session.retiredRetainedSourceKeys[session.retainedSourceKey] = true + } + delete session.retainedSourceKey + delete session.retainedSnapshot +} + /** Session keys are `${sessionType}:${provider}:${sessionId}`; the id may itself contain ':'. */ function sessionIdOfKey(key: string): string { return key.split(':').slice(2).join(':') @@ -193,11 +202,27 @@ const freshAgentLiveSlice = createSlice({ else watermark.gap = true } }, - transcriptProjectionSnapshotReceived(state, action: PayloadAction) { - const { snapshot, ...locator } = action.payload + transcriptProjectionSnapshotReceived(state, action: PayloadAction) { + const { snapshot, retainedSourceKey, ...locator } = action.payload const metadata = readTranscriptProjection(snapshot) if (!metadata) return const session = sessionFor(state, locator) + if (session.retiredProjectionIds?.[metadata.id] === true) return + if (retainedSourceKey) { + if (session.retiredRetainedSourceKeys?.[retainedSourceKey] === true) return + if (session.projection?.id === metadata.id && (session.projection.historyGeneration > metadata.historyGeneration + || session.projection.historyGeneration === metadata.historyGeneration && session.projection.eventRevision > metadata.eventRevision)) return + if (session.retainedSourceKey && session.retainedSourceKey !== retainedSourceKey) { + retireRetainedSource(session) + } + session.retainedSourceKey = retainedSourceKey + session.retainedSnapshot = snapshot + } + if (session.projection && session.projection.id !== metadata.id) { + session.retiredProjectionIds ??= {} + session.retiredProjectionIds[session.projection.id] = true + if (!retainedSourceKey) retireRetainedSource(session) + } if (!session.projection || session.projection.id !== metadata.id || session.projection.historyGeneration < metadata.historyGeneration) { session.projection = bindTranscriptProjection(session.projection, metadata.id, metadata.historyGeneration) } diff --git a/src/store/freshAgentLiveTypes.ts b/src/store/freshAgentLiveTypes.ts index 7a4786b24..cf419381c 100644 --- a/src/store/freshAgentLiveTypes.ts +++ b/src/store/freshAgentLiveTypes.ts @@ -83,9 +83,15 @@ export type LiveNotice = { } import type { TranscriptProjection } from '@/lib/fresh-agent-transcript-projection' -import type { FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' +import type { FreshAgentSnapshot, FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' export type FreshAgentLiveSession = { + retainedSnapshot?: FreshAgentSnapshot + retainedSourceKey?: string + /** Small identity tombstones live until this session scope is removed. + * Transcript bodies and patch buffers have their own finite bounds. */ + retiredRetainedSourceKeys?: Record + retiredProjectionIds?: Record projection?: TranscriptProjection codexMetadata?: Record unboundTranscriptPatches?: FreshAgentTranscriptPatch[] diff --git a/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts b/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts new file mode 100644 index 000000000..09ffaac4a --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts @@ -0,0 +1,73 @@ +/** Hosted loss is injected at the browser's transport boundary; native server + * ownership/history remain real. Task006 Rust tests saturate the actual host + * bridge. This slice proves immediate retained rendering while HTTP is held. */ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } 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 source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +test('Freshcodex renders retained hosted output before native HTTP recovery and keeps composer focus', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-retained-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES:'1', FAKE_CODEX_APP_SERVER_BEHAVIOR:JSON.stringify({recordTurns:true}) }, setupHome:async(home) => { + await fs.mkdir(path.join(home,'.freshell'),{recursive:true}) + await fs.writeFile(path.join(home,'.freshell/config.json'),JSON.stringify({version:1,settings:{freshAgent:{enabled:true},codingCli:{enabledProviders:['codex']}}})) + } }) + let context: Awaited>['context'] | undefined + let release: (() => void) | undefined + const held = new Promise((resolve) => {release=resolve}) + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser,info) + context=owned.context + const page=owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness=new TestHarness(page) + await harness.waitForHarness(); await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({type:'connection/setAvailableClis',payload:{claude:false,codex:true}})) + const picker=await openPanePicker(page) + await picker.getByRole('button',{name:/^Freshcodex$/i}).click({force:true}) + const directory=page.getByRole('combobox',{name:'Starting directory for Freshcodex'}) + await directory.fill(root); await directory.press('Enter') + const pane=page + const composer=pane.getByRole('textbox',{name:'Chat message input'}) + await expect(composer).toBeEnabled() + const sessionId=await page.evaluate(() => { + const layouts=window.__FRESHELL_TEST_HARNESS__!.getState().panes.layouts + const leaf=Object.values(layouts).find((node) => node.type==='leaf' && node.content.kind==='fresh-agent') + if (!leaf || leaf.type!=='leaf' || leaf.content.kind!=='fresh-agent' || !leaf.content.sessionId) throw new Error('Freshcodex not created') + return leaf.content.sessionId + }) + const owner = await page.evaluate((id) => window.__FRESHELL_TEST_HARNESS__!.getState().freshAgent.runtimeOwners[`codex:${id}`], sessionId) + const response=await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${sessionId}`,{headers:{'x-auth-token':info.token}}) + expect(response.ok()).toBe(true) + const snapshot=await response.json() + snapshot.turns=[{id:'recovered-turn',turnId:'recovered-turn',role:'assistant',summary:'',items:[{id:'recovered-answer',kind:'text',text:'Final hosted output recovered without another token'}]}] + snapshot.extensions={codex:{projection:{id:'browser-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['recovered-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'recovered-turn':['recovered-turn']},turnOutputStates:{'recovered-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['recovered-turn']}}} + let refreshes=0 + await page.route('**/api/fresh-agent/threads/freshcodex/codex/**',async(route) => {refreshes++; await held; await route.fulfill({json:snapshot})}) + await composer.fill('Draft stays local') + for (const type of ['freshAgent.host.backpressure','freshAgent.host.journal.reset']) { + await harness.receiveWsMessage({type:'freshAgent.event',provider:'codex',sessionType:'freshcodex',sessionId,event:{type,retainedState:{source:{projectionId:'browser-source',ownerEpoch:owner?.epoch ?? 0,ownerGeneration:owner?.generation ?? 0,soulId:'browser-soul',incarnationId:'browser-incarnation',hostBootId:'browser-host'},snapshot,pendingControls:[],attentionMarks:[]}}}) + } + await expect(pane.getByText('Final hosted output recovered without another token',{exact:true})).toBeVisible() + await expect.poll(() => refreshes).toBeGreaterThan(0) + await expect(composer).toHaveValue('Draft stays local') + await expect(composer).toBeFocused() + await expect(pane.getByRole('note',{name:'Output persistence'})).toHaveText('Retained output. Saved output could not be confirmed.') + } finally { + release?.() + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root,{recursive:true,force:true}) + } +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index aec733b2b..7fc8e17bd 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15033,6 +15033,185 @@ describe('task delegation open-session link (freshopencode tui parity)', () => { }) }) +describe('task6 hosted recovery in the mounted view', () => { + it.each(['freshAgent.host.backpressure', 'freshAgent.host.journal.reset'])('shows retained rows on %s while the native history refresh is pending', async (kind) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retained-view',status:'idle'}})) + render() + await expectRenderedText('Saved native Codex answer') + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + const snapshot = {...native,turns:[{id:'retained-turn',turnId:'retained-turn',role:'assistant',summary:'',items:[{id:'retained-answer',kind:'text',text:'Recovered final hosted answer'}]}],extensions:{codex:{projection:{ + id:'host-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['retained-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'retained-turn':['retained-turn']},turnOutputStates:{'retained-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['retained-turn'], + }}}} + const message = {type:'freshAgent.event',...locator,event:{type:kind,retainedState:{source:{projectionId:'host-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}} + try { + act(() => { handleFreshAgentMessage(store.dispatch, message as never); for (const handler of handlers) handler(message) }) + await expectRenderedText('Recovered final hosted answer') + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + expect(screen.getByText('Recovered final hosted answer')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally { cleanup(); await act(async () => held.resolve(snapshot)) } + }) +}) + +describe('task6 retained source against in-flight native history', () => { + it('keeps the accepted source when an older native history request finishes afterwards', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const metadata = (id: string, revision: number) => ({id,historyGeneration:0,eventRevision:revision,eventOwnedTurnIds:['retained-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'retained-turn':['retained-turn']},turnOutputStates:{'retained-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['retained-turn']}) + const snapshot = {...native,turns:[{id:'retained-turn',turnId:'retained-turn',role:'assistant',summary:'',items:[{id:'retained-answer',kind:'text',text:'Newest retained source output'}]}],extensions:{codex:{projection:metadata('current-source',10)}}} + const stale = {...snapshot,turns:[{...snapshot.turns[0],items:[{id:'retained-answer',kind:'text',text:'Obsolete source history'}]}],extensions:{codex:{projection:metadata('old-source',1)}}} + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retained-race',status:'idle'}})) + render() + await expectRenderedText('Saved native Codex answer') + const held = createDeferred() + const later = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(held.promise).mockReturnValue(later.promise) + const reset = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}} + act(() => {for (const handler of handlers) handler(reset)}) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.results.some((result) => result.value === held.promise)).toBe(true)) + const message = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'current-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}} + try { + act(() => {handleFreshAgentMessage(store.dispatch,message as never);for (const handler of handlers) handler(message)}) + await expectRenderedText('Newest retained source output') + await act(async () => held.resolve(stale)) + await waitFor(() => expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('current-source')) + expect(screen.getByText('Newest retained source output')).toBeVisible() + expect(screen.queryByText('Obsolete source history')).not.toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup();await act(async () => {held.resolve(stale);later.resolve(snapshot)})} + }) +}) + +describe('task6 late retained source after native replacement', () => { + it.each([{earlierRetained:false,late:'retained'},{earlierRetained:true,late:'retained'},{earlierRetained:false,late:'http'},{earlierRetained:true,late:'http'}])('rejects a known retired source after native replacement (earlier retained=$earlierRetained; late=$late)', async ({earlierRetained,late}) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const sourceSnapshot = (id: string, text: string) => FreshAgentSnapshotSchema.parse({...native,turns:[{id:'source-turn',turnId:'source-turn',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text}]}],extensions:{codex:{projection:{id,historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['source-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'source-turn':['source-turn']},turnOutputStates:{'source-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['source-turn']}}}}) + const original = sourceSnapshot('old-source', 'Output from the original source') + const replacement = sourceSnapshot('replacement-source', 'Output from the current native source') + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(original) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'native-replaced-source',status:'idle'}})) + render() + await expectRenderedText('Output from the original source') + const refresh = createDeferred() + const lateNative = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(refresh.promise) + const retained = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'old-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:original,pendingControls:[],attentionMarks:[]}}} + const receive = (message: any) => {handleFreshAgentMessage(store.dispatch,message);for (const handler of handlers) handler(message)} + try { + act(() => receive(earlierRetained ? retained : {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => refresh.resolve(replacement)) + await expectRenderedText('Output from the current native source') + if (late === 'retained') act(() => receive(retained)) + else { + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(lateNative.promise) + act(() => receive({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => lateNative.resolve(original)) + } + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('replacement-source') + expect(screen.getByText('Output from the current native source')).toBeVisible() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup();await act(async () => {refresh.resolve(replacement);lateNative.resolve(replacement)})} + }) +}) + +describe('task6 retired source lifetime', () => { + it.each(['retained', 'http'])('rejects the first source through %s after 33 legitimate source replacements', async (late) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = {provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const sourceSnapshot = (index: number) => FreshAgentSnapshotSchema.parse({...native,turns:[{id:'source-turn',turnId:'source-turn',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text:`Observed native source ${index}`}]}],extensions:{codex:{projection:{id:`scope-source-${index}`,historyGeneration:0,eventRevision:1,eventOwnedTurnIds:['source-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'source-turn':['source-turn']},turnOutputStates:{'source-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['source-turn']}}}}) + const first = sourceSnapshot(0) + let apiSnapshot = first + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockImplementation(async () => apiSnapshot) + const receive = (message: any) => {handleFreshAgentMessage(store.dispatch,message);for (const handler of handlers) handler(message)} + const retained = (snapshot: typeof first) => ({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:snapshot.extensions.codex.projection.id,ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}}) + const reset = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}} + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retired-lifetime',status:'idle'}})) + render() + try { + await expectRenderedText('Observed native source 0') + act(() => receive(retained(first))) + for (let index = 1; index <= 33; index++) { + apiSnapshot = sourceSnapshot(index) + const readsBefore = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + act(() => receive(reset)) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(readsBefore)) + await expectRenderedText(`Observed native source ${index}`) + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe(`scope-source-${index}`) + act(() => receive(retained(apiSnapshot))) + } + if (late === 'retained') act(() => receive(retained(first))) + else { + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + const readsBefore = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + act(() => receive(reset)) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(readsBefore)) + await act(async () => held.resolve(first)) + } + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('scope-source-33') + expect(screen.getByText('Observed native source 33')).toBeVisible() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup()} + }, 45_000) +}) + +describe('task6 owner context assessment', () => { + it('uses the current owner fence for child reads and the current parent when retrying after rebind', async () => { + const native=FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const childId='22222222-2222-4222-8222-222222222222' + const replacement='33333333-3333-4333-8333-333333333333' + const store=createStore() + const owner={type:'session.runtimeOwner' as const,provider:'codex' as const,sessionId:native.threadId,epoch:1,generation:1,ownerKind:'fresh-agent' as const,operationId:'first-owner',transition:'handoff-committed' as const} + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type,_provider,id) => Promise.resolve({...native,threadId:id,childThreads:[{id:childId,threadId:childId,origin:'spawnAgent'}],extensions:{codex:{projection:{availability:{status:'unavailable'}}}}})) + apiMock.get.mockImplementation((url:string) => { + const parent=url.includes(replacement)?replacement:native.threadId + return Promise.resolve(url.includes('/children/')?{parentThreadId:parent,childThreadId:childId,status:'saved',snapshot:{...native,threadId:childId}}:{checkpoints:[]}) + }) + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',provider:'codex',sessionType:'freshcodex',sessionId:native.threadId,createRequestId:'owner-context',status:'idle'}})) + render() + fireEvent.click(await screen.findByRole('button',{name:`Observe child ${childId}`})) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining('observedEpoch=1&observedGeneration=1'),expect.anything())) + act(() => store.dispatch(applyRuntimeOwner({...owner,epoch:2,generation:3,operationId:'next-owner'}))) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining('observedEpoch=2&observedGeneration=3'),expect.anything())) + fireEvent.click(screen.getByRole('button',{name:'Close child conversation'})) + act(() => { + store.dispatch(applyRuntimeOwner({...owner,sessionId:replacement,epoch:3,generation:4,operationId:'replacement-owner'})) + store.dispatch(updatePaneContent({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',provider:'codex',sessionType:'freshcodex',sessionId:replacement,createRequestId:'replacement-context',status:'idle'}})) + }) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenLastCalledWith('freshcodex','codex',replacement,expect.anything())) + apiMock.getFreshAgentThreadSnapshot.mockClear() + fireEvent.click(await screen.findByRole('button',{name:'Retry history'})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex','codex',replacement,expect.anything())) + fireEvent.click(await screen.findByRole('button',{name:`Observe child ${childId}`})) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining(`${replacement}/children/${childId}?observedEpoch=3&observedGeneration=4`),expect.anything())) + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + }) +}) + describe('Codex child observation in the parent view', () => { it('persists only the selected child, preserves the parent draft and actions, and reopens without a child lifecycle', async () => { const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 317c99372..afa15c7a8 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -1,3 +1,4 @@ +import savedCodexNativeHistory from '../../../fixtures/managed-native-history/codex.json' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { configureStore } from '@reduxjs/toolkit' import freshAgentReducer, { materializeSession, sessionCreated } from '@/store/freshAgentSlice' @@ -75,6 +76,36 @@ describe('fresh-agent-ws', () => { send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'upsertItem', nativeTurnId: 't', row: { ...row, items: [] }, item: { id: 'a', kind: 'text', text: 'Hello' }, complete: true, revision: 3 }, { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 4 }] }) expect(store.getState().freshAgentLive.bySession[key].projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) }) + it('task6 applies retained output immediately and rejects a retired source after replacement', () => { + const store = createFreshAgentStore() + const outer = { type: 'freshAgent.event' as const, provider: 'codex' as const, sessionType: 'freshcodex' as const, sessionId: savedCodexNativeHistory.threadId } + const send = (id: string, text: string, incarnationId: string) => handleFreshAgentMessage(store.dispatch, { ...outer, + event: { type: 'freshAgent.host.backpressure', retainedState: { + source: { projectionId: id, ownerEpoch: 0, ownerGeneration: 0, soulId: 'soul', incarnationId, hostBootId: incarnationId }, + snapshot: { ...savedCodexNativeHistory, turns: [{ id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text}] }], extensions: { codex: { projection: { + id,historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['t'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{t:['t']},turnOutputStates:{t:{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['t'],completedItems:{t:['answer']}, + } } } }, pendingControls: [], attentionMarks: [], + } }, + }) + send('p1','Recovered final output','incarnation-one') + const key = `freshcodex:codex:${outer.sessionId}` + expect(store.getState().freshAgentLive.bySession[key]?.projection?.turns[0].items[0]).toMatchObject({text:'Recovered final output'}) + send('p2','Replacement output','incarnation-two') + send('p1','Late retired output','incarnation-one') + expect(store.getState().freshAgentLive.bySession[key]?.projection?.id).toBe('p2') + expect(store.getState().freshAgentLive.bySession[key]?.projection?.turns[0].items[0]).toMatchObject({text:'Replacement output'}) + }) + it('task6 detects a missing consumed patch prefix without applying an unbased append', () => { + const store = createFreshAgentStore() + const outer = {type:'freshAgent.event' as const,provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:'patch-gap'} + const send=(event:Record) => handleFreshAgentMessage(store.dispatch,{...outer,event}) + send({type:'freshAgent.session.init',projectionId:'p',historyGeneration:0}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,ops:[{op:'resetTurn',nativeTurnId:'t',revision:1,turns:[{id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'a',kind:'text',text:'Known prefix'}]}]}]}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,afterRevision:4,ops:[{op:'appendItem',nativeTurnId:'t',rowId:'t',itemId:'a',field:'text',text:' unbased suffix',revision:7}]}) + const projection=store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection + expect(projection?.needsResnapshot).toBe(true) + expect(projection?.turns[0].items[0]).toMatchObject({text:'Known prefix'}) + }) beforeEach(() => { _resetCancelledCreates() }) From c0ba437fcc3c15b8a037eb2216d5d0f9026b4769 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:12:36 -0700 Subject: [PATCH 31/92] fix(codex): preserve gap coverage until recovery snapshot --- src/components/fresh-agent/FreshAgentView.tsx | 11 ++++-- src/lib/fresh-agent-transcript-projection.ts | 4 ++- .../fresh-agent/FreshAgentView.test.tsx | 34 +++++++++++++++++++ test/unit/client/lib/fresh-agent-ws.test.ts | 6 ++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index 857a8aec3..9f3f1eec9 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -3402,9 +3402,14 @@ export function FreshAgentView({ const liveSession = appStore.getState().freshAgentLive?.bySession[makeFreshAgentSessionKey(projectionLocator)] const liveProjection = liveSession?.projection if (projectionMetadata && liveSession?.retiredProjectionIds?.[projectionMetadata.id] === true) return - if (projectionMetadata && liveProjection?.id === projectionMetadata.id && liveProjection.historyGeneration > projectionMetadata.historyGeneration) { - requestSnapshotRefresh('reconnect') - return + if (projectionMetadata && liveProjection?.id === projectionMetadata.id) { + const olderHistory = liveProjection.historyGeneration > projectionMetadata.historyGeneration + const missingCoverage = liveProjection.historyGeneration === projectionMetadata.historyGeneration + && liveProjection.needsResnapshot && projectionMetadata.eventRevision < liveProjection.eventRevision + if (olderHistory || missingCoverage) { + requestSnapshotRefresh(olderHistory ? 'reconnect' : 'event') + return + } } const resolvedHasUserTurns = freshAgentSnapshotHasUserTurn(resolved) if (!resolvedHasUserTurns && !autoTitleSentRef.current) { diff --git a/src/lib/fresh-agent-transcript-projection.ts b/src/lib/fresh-agent-transcript-projection.ts index a244d1d79..1ad79628d 100644 --- a/src/lib/fresh-agent-transcript-projection.ts +++ b/src/lib/fresh-agent-transcript-projection.ts @@ -30,7 +30,9 @@ export function applyFreshAgentTranscriptPatch(state: TranscriptProjection, inpu if (patch.projectionId !== state.id || patch.historyGeneration < state.historyGeneration) return state // Appends can be coalesced across several revisions. The producer states // their consumed prefix explicitly; a jump in the final revision is legal. - if (patch.afterRevision !== undefined && patch.afterRevision > state.eventRevision) { + // After a gap, consumed revisions describe coverage owed to the client. + // They cannot authorize another append until a snapshot covers that prefix. + if (state.needsResnapshot || patch.afterRevision !== undefined && patch.afterRevision > state.eventRevision) { return { ...state, needsResnapshot: true, eventRevision: Math.max(state.eventRevision, ...patch.ops.map((op) => op.revision)) } } const next: TranscriptProjection = patch.historyGeneration > state.historyGeneration diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 7fc8e17bd..52f68d849 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15178,6 +15178,40 @@ describe('task6 retired source lifetime', () => { }, 45_000) }) +describe('task6 gap recovery after the final frame', () => { + it('keeps current rows and requests the latest prefix when an in-flight recovery response predates the final patch', async () => { + const native=FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator={provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const snapshot=(revision:number,text:string)=>FreshAgentSnapshotSchema.parse({...native,status:'running',turns:[{id:'gap-row',turnId:'gap-row',role:'assistant',summary:'',items:[{id:'gap-answer',kind:'text',text}]}],extensions:{codex:{projection:{id:'gap-source',historyGeneration:0,eventRevision:revision,eventOwnedTurnIds:['gap-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'gap-turn':['gap-row']},turnOutputStates:{'gap-turn':{provenance:'retained',refreshPending:false}},settledTurnIds:[]}}}}) + const initial=snapshot(1,'Known visible prefix') + const stale=snapshot(7,'Covered through revision seven') + const latest=snapshot(8,'Authoritative output through the final revision eight') + const store=createStore() + const handlers=new Set<(message:any)=>void>() + wsMock.onMessage.mockImplementation((handler)=>{handlers.add(handler);return()=>{handlers.delete(handler)}}) + const receive=(event:Record)=>{const message={type:'freshAgent.event',...locator,event};handleFreshAgentMessage(store.dispatch,message as never);for(const handler of handlers)handler(message)} + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(initial) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'gap-final-prefix',status:'running'}})) + render() + await expectRenderedText('Known visible prefix') + const held=createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(held.promise).mockResolvedValue(latest) + const patch=(afterRevision:number,revision:number)=>({type:'freshAgent.transcript.patch',sessionId:native.threadId,projectionId:'gap-source',historyGeneration:0,afterRevision,ops:[{op:'appendItem',nativeTurnId:'gap-turn',rowId:'gap-row',itemId:'gap-answer',field:'text',text:' unbased suffix',revision}]}) + try { + act(()=>receive(patch(4,7))) + await waitFor(()=>expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + act(()=>receive(patch(7,8))) + // No more transport/provider frames arrive after this final patch. + await act(async()=>held.resolve(stale)) + await expectRenderedText('Authoritative output through the final revision eight') + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.needsResnapshot).toBe(false) + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup();await act(async()=>held.resolve(stale))} + }) +}) + describe('task6 owner context assessment', () => { it('uses the current owner fence for child reads and the current parent when retrying after rebind', async () => { const native=FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index afa15c7a8..e4c136121 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -105,6 +105,12 @@ describe('fresh-agent-ws', () => { const projection=store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection expect(projection?.needsResnapshot).toBe(true) expect(projection?.turns[0].items[0]).toMatchObject({text:'Known prefix'}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,afterRevision:7,ops:[{op:'appendItem',nativeTurnId:'t',rowId:'t',itemId:'a',field:'text',text:' another unbased suffix',revision:8}]}) + expect(store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection?.turns[0].items[0]).toMatchObject({text:'Known prefix'}) + send({type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'p',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:{...savedCodexNativeHistory,threadId:'patch-gap',turns:[{id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'a',kind:'text',text:'The authoritative complete prefix'}]}],extensions:{codex:{projection:{id:'p',historyGeneration:0,eventRevision:8,eventOwnedTurnIds:['t'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{t:['t']},turnOutputStates:{t:{provenance:'retained',refreshPending:false}},settledTurnIds:[]}}}},pendingControls:[],attentionMarks:[]}}) + const recovered=store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection + expect(recovered?.needsResnapshot).toBe(false) + expect(recovered?.turns[0].items[0]).toMatchObject({text:'The authoritative complete prefix'}) }) beforeEach(() => { _resetCancelledCreates() From 0a60988f231d62b32468de746f034f5bac7b61b1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:02:00 -0700 Subject: [PATCH 32/92] test(codex): diagnose native snapshot recovery at actor and browser boundaries --- .../src/providers/fresh_agent.rs | 64 ++++++++++++++ .../specs/fresh-agent-control-rust.spec.ts | 87 +++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 5192211a7..f7c5c5884 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1792,6 +1792,70 @@ mod tests { } } + #[tokio::test] + async fn task6_production_actor_owns_native_forwarding_through_fork_without_factory_handles() { + let NativeCodexFixture { + transport, + profile, + diagnostic: _, + dir, + _exec_dir, + _environment, + _lock, + } = native_codex_fixture(json!({"recordTurns":true})).await; + // Move the factory's only transport handle into the real production + // actor. No direct fixture handle can keep its bridge guard alive. + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport) + .await + .unwrap(); + let parent = actor.profile().await.native_session_id.unwrap(); + for (request, text, expected_rows) in [ + ("parent-send", "actor parent turn", 2), + ("child-send", "actor child turn", 4), + ] { + let before = actor.read_events(0, 256).await.head; + actor + .dispatch(RequestId::parse(request).unwrap(), text.into(), None) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let batch = actor.read_events(before, 256).await; + if batch.events.iter().any(|entry| matches!(&entry.event, + AgentEvent::Provider {payload} if payload["event"]["type"] == "freshAgent.turn.complete")) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the actor must retain its bridge through native completion"); + let snapshot = actor.snapshot().await.unwrap(); + assert_eq!( + snapshot["turns"].as_array().unwrap().len(), + expected_rows, + "{snapshot}" + ); + assert_eq!(snapshot["status"], "idle"); + if request == "parent-send" { + let fork = actor + .fork( + RequestId::parse("actor-fork").unwrap(), + parent.clone(), + None, + ) + .await + .unwrap(); + assert_ne!(fork.child_session_id, parent); + assert_eq!( + actor.profile().await.native_session_id, + Some(fork.child_session_id) + ); + } + } + actor.stop().await.unwrap(); + } + async fn task5_durable_native_control( method: &str, params: Value, 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 062723ccf..92041ae70 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1330,6 +1330,66 @@ function readCodexOps(opLogPath: string): any[] { return readJsonl(opLogPath) } +/** Bounded fixture-only diagnostics; never include control bodies or answers. */ +async function captureCodexLaneDiagnostics( + page: Page, + lane: Awaited>, +): Promise { + const rows = (turns: any[] = []) => turns.map((turn) => ({ + id: turn.turnId, nativeTurnId: turn.nativeTurnId, role: turn.role, + items: (turn.items ?? []).map((item: any) => ({ id: item.id, kind: item.kind, textBytes: typeof item.text === 'string' ? item.text.length : undefined })), + })) + const summarizeMetadata = (metadata: any) => metadata && ({ + ...metadata, + turnOutputStates: Object.fromEntries(Object.entries(metadata.turnOutputStates ?? {}).map(([id, output]: [string, any]) => [id, { + provenance: output.provenance, diagnostic: output.diagnostic, refreshPending: output.refreshPending, + savedTurns: rows(output.savedTurns), retainedTurns: rows(output.retainedTurns), + }])), + }) + const summarizeSnapshot = (snapshot: any) => snapshot && ({ + threadId: snapshot.threadId, status: snapshot.status, revision: snapshot.revision, + turns: rows(snapshot.turns), + projection: summarizeMetadata(snapshot.extensions?.codex?.projection), + }) + const result = await Promise.allSettled([ + fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1'), + lane.harness.getReceivedWsMessages(), + lane.harness.getState(), + page.locator('[data-context="fresh-agent"]').last().getByRole('button').allTextContents(), + ]) + const value = (index: number): any => { + const item = result[index] + return item.status === 'fulfilled' ? item.value : { diagnosticError: String(item.reason) } + } + const state = value(2) + const live = Object.values(state?.freshAgentLive?.bySession ?? {}).map((session: any) => ({ + sessionId: session.sessionId, + retainedSourceKey: session.retainedSourceKey, + retainedSnapshot: summarizeSnapshot(session.retainedSnapshot), + projection: session.projection && { + id: session.projection.id, historyGeneration: session.projection.historyGeneration, + eventRevision: session.projection.eventRevision, needsResnapshot: session.projection.needsResnapshot, + turns: rows(session.projection.turns), turnRows: session.projection.turnRows, + }, + })) + const frames = Array.isArray(value(1)) ? value(1).slice(-100).map((frame: any) => ({ + type: frame.type, sessionId: frame.sessionId, provider: frame.provider, + event: frame.event && { + type: frame.event.type, status: frame.event.status, + projectionId: frame.event.projectionId, historyGeneration: frame.event.historyGeneration, + afterRevision: frame.event.afterRevision, + ops: frame.event.ops?.map((op: any) => ({ type: op.type, revision: op.revision, nativeTurnId: op.nativeTurnId })), + source: frame.event.retainedState?.source, + snapshot: summarizeSnapshot(frame.event.retainedState?.snapshot), + }, + })) : value(1) + console.log('TASK006_CODEX_STATE_DIAGNOSTIC', JSON.stringify({ + test: test.info().title, nativeOperations: readCodexOps(lane.opLogPath).slice(-100), + restSnapshot: summarizeSnapshot(value(0)), forwardedFrames: frames, + live, sessions: Object.values(state?.freshAgent?.sessions ?? {}).map((session: any) => ({ sessionId: session.sessionId, status: session.status })), visibleButtonLabels: value(3), + })) +} + /** Read a freshAgent session's status from the harness store — the exact * `agentSession.status` the stuck card's `effectiveStatus` renders from. */ async function readFreshAgentSessionStatus( @@ -1494,6 +1554,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 502, result: { decision: 'decline' } }) await expect(edit).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1527,6 +1590,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { .toContainEqual({ id: 602, result: { decision: 'accept' } }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await expect(approval).toBeHidden() + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1659,6 +1725,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect(pane).toContainText('review-1') await expect(pane).toContainText('pending') await expect(pane).toContainText('thread-parent-1') + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1696,6 +1765,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // Usable after compact: a follow-up prompt mints exactly the next turn // (two recorded turns -> four display rows). await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'codex post-compact turn') + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1770,6 +1842,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { ) expect(mutationsAgainstParent, 'no post-fork mutation against the source thread').toEqual([]) expect(await readRollout(lane.info.homeDir, 'thread-new-1')).toBe(parentRolloutBefore) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1939,6 +2014,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }, { timeout: 30_000, message: 'post-restart resume+read evidence for BOTH source and child' }) .toBe(true) sourceRestartWs.close() + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2033,6 +2111,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect.poll(async () => (await fetchSnapshot(lane.info, 'freshcodex', 'codex', threadId))?.turns?.length, { timeout: 15_000 }).toBe(turnsAfter.length + 2) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2067,6 +2148,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // exactly the false positive the bound exists to prevent. await page.waitForTimeout(Math.max(0, 11_000 - (Date.now() - sentAt))) await expect(stuckAlert).toHaveCount(0) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2128,6 +2212,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { expect(turns[0]?.start?.model).not.toBe(turns[1]?.start?.model) expect(turns[0]?.start?.effort).not.toBe(turns[1]?.start?.effort) }).toPass({ timeout: 15_000 }) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) From ca2d133bcbd3d003f2839f01b94edae186d05992 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:38:31 -0700 Subject: [PATCH 33/92] fix(codex): hydrate lifecycle-only turns from full native history --- .../src/codex/projection.rs | 6 ++ .../specs/fresh-agent-control-rust.spec.ts | 62 ++++++++++++++----- .../fresh-agent/FreshAgentView.test.tsx | 31 ++++++++++ 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index fdb5d96e5..1f246aeec 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -73,6 +73,12 @@ impl EventTurn { } fn native_can_finish_unobserved_items(&self, native: &Value, legacy: bool) -> bool { + // Lifecycle notifications establish status, not an empty body. When + // no item was observed, the caller's full terminal native read owns + // the body; there is no event content whose equality it must prove. + if self.raw["items"].as_array().is_some_and(Vec::is_empty) { + return true; + } let mut candidate = self.raw.clone(); let mut saved = native.clone(); let mut finalizable = HashSet::new(); 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 92041ae70..184e06967 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1378,7 +1378,7 @@ async function captureCodexLaneDiagnostics( type: frame.event.type, status: frame.event.status, projectionId: frame.event.projectionId, historyGeneration: frame.event.historyGeneration, afterRevision: frame.event.afterRevision, - ops: frame.event.ops?.map((op: any) => ({ type: op.type, revision: op.revision, nativeTurnId: op.nativeTurnId })), + ops: frame.event.ops?.map((op: any) => ({ op: op.op, revision: op.revision, nativeTurnId: op.nativeTurnId })), source: frame.event.retainedState?.source, snapshot: summarizeSnapshot(frame.event.retainedState?.snapshot), }, @@ -1536,7 +1536,8 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await page.reload() await expect(approval).toHaveCount(1) await expect(approval).toBeVisible() - await approval.getByRole('button', { name: 'Allow tool use', exact: true }).click() + expect(readJsonl(lane.responseLogPath)).toHaveLength(0) + await approval.getByRole('button', { name: 'Allow', exact: true }).click() await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 501, result: { decision: 'accept' } }) await expect(approval).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1550,10 +1551,11 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await sendComposerText(page, 'Deny an edit') const edit = page.getByRole('alert', { name: 'Permission request for Edit' }) await expect(edit).toBeVisible() - await edit.getByRole('button', { name: 'Deny tool use', exact: true }).click() + await edit.getByRole('button', { name: 'Decline', exact: true }).click() await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 502, result: { decision: 'decline' } }) await expect(edit).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + console.log('TASK006_NATIVE_APPROVAL_CONTRACT', JSON.stringify({ test: test.info().title, normalizedNativeResponses: readJsonl(lane.responseLogPath) })) } catch (error) { await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) throw error @@ -1563,33 +1565,59 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { } }) - // Decision D3 (client-alerts G20): codex keeps its client-only bypass, - // labelled for what it is. (The claude lane's former "Always Allow" case is - // replaced by Claude's own "Allow and don't ask again", pinned with no - // client auto-answer in freshclaude-approvals-rust.spec.ts.) - test('Always allow (pane) auto-answers the second raise for that tool', async ({ page }) => { + // Native grants belong to Codex's offered decision, rather than a generic + // pane-local tool bypass. A subsequent native request still needs a choice. + test('Codex native session approval forwards an offered decision and keeps later requests user-gated', async ({ page }) => { const lane = await bootCodexLane(page, { serverRequestsByPrompt: { - 'Approve tests': { id: 601, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests' } }, - 'Approve a build': { id: 602, method: 'item/commandExecution/requestApproval', params: { command: 'npm run build', reason: 'Build the project' } }, + 'Approve tests': { id: 601, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests', availableDecisions: ['accept', 'acceptForSession', 'decline', 'cancel'] } }, + 'Approve a build': { id: 602, method: 'item/commandExecution/requestApproval', params: { command: 'npm run build', reason: 'Build the project', availableDecisions: ['decline', 'cancel'] } }, } }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await sendComposerText(page, 'Approve tests') const approval = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(approval).toBeVisible() - const always = approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true }) - await expect(always).toHaveAttribute('title', 'Until this pane reloads') - await always.click() - await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 601, result: { decision: 'accept' } }) + const beforeReload = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(beforeReload).toMatchObject({ providerRequest: { id: 601, method: 'item/commandExecution/requestApproval' }, native: { availableDecisions: ['accept', 'acceptForSession', 'decline', 'cancel'] } }) + expect(readJsonl(lane.responseLogPath)).toEqual([]) + await expect(approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true })).toHaveCount(0) + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.send' && frame.text === 'Approve tests')).toHaveLength(1) + await page.reload() + await expect(approval).toHaveCount(1) + await expect(approval).toBeVisible() + const afterReload = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(afterReload.requestId).toBe(beforeReload.requestId) + expect(afterReload.native.availableDecisions).toEqual(['accept', 'acceptForSession', 'decline', 'cancel']) + expect(readJsonl(lane.responseLogPath)).toEqual([]) + await approval.getByRole('button', { name: 'Allow for this session', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }]) await expect(approval).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - // The second request for Bash is answered by the pane, with no click. + // The provider raises a different command with a narrower offer. The + // first session choice does not authorize a client-side automatic reply. await sendComposerText(page, 'Approve a build') - await expect.poll(() => readJsonl(lane.responseLogPath), { timeout: 15_000, message: 'the second raise is answered without a click' }) - .toContainEqual({ id: 602, result: { decision: 'accept' } }) + await expect(approval).toHaveCount(1) + await expect(approval).toBeVisible() + const second = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(second).toMatchObject({ providerRequest: { id: 602, method: 'item/commandExecution/requestApproval' }, native: { availableDecisions: ['decline', 'cancel'] } }) + await expect(approval.getByRole('button', { name: 'Allow', exact: true })).toHaveCount(0) + await expect(approval.getByRole('button', { name: 'Allow for this session', exact: true })).toHaveCount(0) + await expect(approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true })).toHaveCount(0) + expect(readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }]) + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.approval.respond' && frame.requestId === second.requestId)).toEqual([]) + await approval.getByRole('button', { name: 'Decline', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }, { id: 602, result: { decision: 'decline' } }]) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await expect(approval).toBeHidden() + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.send' && frame.text === 'Approve a build')).toHaveLength(1) + const recorded = JSON.parse(await fs.readFile(path.join(lane.info.homeDir, '.codex', 'fake-turns', 'thread-new-1.json'), 'utf8')) + expect(recorded).toHaveLength(2) + console.log('TASK006_NATIVE_APPROVAL_CONTRACT', JSON.stringify({ + test: test.info().title, + normalizedRequests: [beforeReload, afterReload, second].map((request) => ({ requestId: request.requestId, providerRequest: request.providerRequest, availableDecisions: request.native.availableDecisions })), + normalizedNativeResponses: readJsonl(lane.responseLogPath), recordedTurns: recorded.length, + })) } catch (error) { await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) throw error diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 52f68d849..2491effd0 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15062,6 +15062,37 @@ describe('task6 hosted recovery in the mounted view', () => { }) describe('task6 retained source against in-flight native history', () => { + it('refreshes saved native history at an unchanged event prefix', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider: 'codex' as const, sessionType: 'freshcodex' as const, sessionId: native.threadId } + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, createRequestId: 'same-event-prefix', status: 'idle' } })) + render() + await expectRenderedText('Saved native Codex answer') + const retained = FreshAgentSnapshotSchema.parse({ ...native, turns: [{ id: 'clock-row', turnId: 'clock-row', role: 'assistant', summary: '', items: [{ id: 'clock-answer', kind: 'text', text: 'Retained before native persistence' }] }], extensions: { codex: { projection: { + id: 'clock-source', historyGeneration: 0, eventRevision: 10, eventOwnedTurnIds: ['clock-turn'], nativePreviewTurnIds: [], nativeFinalTurnIds: [], turnRows: { 'clock-turn': ['clock-row'] }, turnOutputStates: { 'clock-turn': { provenance: 'retained', refreshPending: true } }, + } } } }) + const saved = FreshAgentSnapshotSchema.parse({ ...retained, turns: [{ ...retained.turns[0], items: [{ id: 'clock-answer', kind: 'text', text: 'Later exact saved native history' }] }], extensions: { codex: { projection: { + ...retained.extensions.codex.projection, eventOwnedTurnIds: [], nativeFinalTurnIds: ['clock-turn'], turnOutputStates: { 'clock-turn': { provenance: 'saved', refreshPending: false } }, + } } } }) + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + const message = { type: 'freshAgent.event', ...locator, event: { type: 'freshAgent.host.backpressure', retainedState: { source: { projectionId: 'clock-source', ownerEpoch: 0, ownerGeneration: 0, soulId: 'soul', incarnationId: 'incarnation', hostBootId: 'host' }, snapshot: retained, pendingControls: [], attentionMarks: [] } } } + try { + act(() => { handleFreshAgentMessage(store.dispatch, message as never); for (const handler of handlers) handler(message) }) + await expectRenderedText('Retained before native persistence') + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => held.resolve(saved)) + await expectRenderedText('Later exact saved native history') + expect(screen.queryByText('Retained before native persistence')).not.toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally { cleanup(); await act(async () => held.resolve(saved)) } + }) + it('keeps the accepted source when an older native history request finishes afterwards', async () => { const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) const store = createStore() From 4bb2e1a8b717de19136f45ef639a14e8deedcc5f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:33:19 -0700 Subject: [PATCH 34/92] fix(codex): preserve receipts and scope retained recovery --- .../src/attention_ledger.rs | 15 + crates/freshell-freshagent/src/codex.rs | 18 ++ .../src/codex/controls/tests.rs | 63 +++- .../freshell-server/src/fresh_agent_proxy.rs | 145 +++++++-- .../src/fresh_agent_proxy_tests.rs | 146 +++++++++ .../src/providers/fresh_agent.rs | 292 +++++++++++++++--- .../fresh-agent/FreshAgentView.test.tsx | 35 +++ 7 files changed, 645 insertions(+), 69 deletions(-) diff --git a/crates/freshell-freshagent/src/attention_ledger.rs b/crates/freshell-freshagent/src/attention_ledger.rs index ae98606af..a9f6fe2c2 100644 --- a/crates/freshell-freshagent/src/attention_ledger.rs +++ b/crates/freshell-freshagent/src/attention_ledger.rs @@ -170,6 +170,21 @@ impl AttentionLedger { .collect() } + /// Identities joined by an actual materialization edge. A missing identity + /// has no candidates; unrelated retained conversations are never a fallback. + pub(crate) fn aliases_for(&self, id: &str) -> Vec { + let inner = self.inner.lock().expect("attention ledger lock"); + let Some(group) = inner.entries.get(id).map(|entry| entry.group) else { + return Vec::new(); + }; + inner + .entries + .values() + .filter(|entry| entry.group == group) + .map(|entry| entry.mark.session_id.clone()) + .collect() + } + fn trim(inner: &mut Inner, capacity: usize) { while inner.entries.len() > capacity { let oldest_group = inner diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 5b2884ba2..fd53572b8 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1882,6 +1882,24 @@ impl FreshCodexState { /// Current native facts, including unfinished event-owned output. Reading /// this source cannot create/resume a runtime or wait for native history. pub async fn retained_state(&self, session_id: &str) -> Result { + let resolved = { + let sessions = self.sessions.lock().await; + if sessions.contains_key(session_id) { + session_id.to_owned() + } else { + let mut aliases = self + .attention + .aliases_for(session_id) + .into_iter() + .filter(|id| sessions.contains_key(id)); + let resolved = aliases.next().ok_or(CodexSnapshotError::NotFound)?; + if aliases.next().is_some() { + return Err(CodexSnapshotError::NotFound); + } + resolved + } + }; + let session_id = resolved.as_str(); let (projection, turns, exited, history_mode, quiet) = self .sessions .lock() diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index cdd503556..0ee631fcd 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -1710,7 +1710,8 @@ async fn task6_send_context_answer( assert_eq!(wire.2["input"][0]["text"], text); match outcome { "accepted" => peer.respond(&wire.0, json!({"turnId":"turn-1"})), - "refused" => peer.respond_error(&wire.0, -32600, "native input refused"), + "refused" => peer.respond_error(&wire.0, -32602, "native input refused"), + "rpc-invalid" => peer.respond_error(&wire.0, -32600, "native input refused"), "ambiguous" => peer.respond(&wire.0, json!({})), _ => unreachable!(), } @@ -1739,6 +1740,26 @@ async fn task6_context_answer_outcome(outcome: &str) { task6_send_context_answer(&state, &peer, "two", outcome).await; } let after_two = state.attention_marks().await[0].clone(); + let receipts = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|frame| serde_json::from_str::(&frame).unwrap()) + .filter(|frame| frame["requestId"] == "answer-one" || frame["requestId"] == "answer-two") + .collect::>(); + assert_eq!(receipts.len(), if outcome == "accepted" { 2 } else { 1 }); + for receipt in &receipts { + if outcome == "accepted" { + assert_eq!(receipt["type"], "freshAgent.send.accepted"); + assert_eq!(receipt["submittedTurnId"], "turn-1"); + assert_eq!(receipt["sessionId"], "thread-1"); + } else { + assert_eq!(receipt["type"], "error"); + let prefix = if outcome == "refused" { + "CODEX_TURN_START_FAILED:" + } else { + "CODEX_DELIVERY_AMBIGUOUS:" + }; + assert!(receipt["message"].as_str().unwrap().starts_with(prefix), "the public receipt must distinguish explicit refusal and uncertain admission: {receipt}"); + } + } let controls = state.retained_control_events("thread-1").await; let unsolicited = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()).await; task6_shutdown(&state, peer).await; @@ -1775,3 +1796,43 @@ async fn task6_refused_contextual_answer_preserves_action_attention() { async fn task6_ambiguous_contextual_answer_preserves_action_without_resend() { task6_context_answer_outcome("ambiguous").await; } + +#[tokio::test] +async fn task6_invalid_request_contextual_answer_preserves_exact_ambiguity_without_resend() { + task6_context_answer_outcome("rpc-invalid").await; +} + +#[tokio::test] +async fn task6_retained_read_follows_only_its_materialized_identity() { + let (state, peer, mut rx) = runtime().await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/completed",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"alias-output","type":"agentMessage","text":"Owned materialized output"}})); + loop { + let patch = event(&mut rx).await; + if patch["type"] == "freshAgent.transcript.patch" + && patch.to_string().contains("Owned materialized output") + { + break; + } + } + state.attention.observe("materialization-old", SESSION_TYPE); + state.broadcast(&serde_json::from_value(json!({"type":"freshAgent.session.materialized","provider":"codex","sessionType":"freshcodex","previousSessionId":"materialization-old","sessionId":"thread-1"})).unwrap()); + // Losing that first materialization must not force a scan of all sessions. + let retained = state.retained_state("materialization-old").await; + let unrelated = state.retained_state("unrelated-owner").await; + task6_shutdown(&state, peer).await; + let retained = retained.expect( + "a retained read must follow its actual materialization alias without another frame", + ); + assert_eq!(retained["snapshot"]["threadId"], "thread-1"); + assert!(retained["snapshot"] + .to_string() + .contains("Owned materialized output")); + assert!( + unrelated.is_err(), + "an arbitrary missing identity must never discover another registered source" + ); +} diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index ef32a1e52..3d8562867 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -1024,6 +1024,7 @@ impl HostedFreshAgentProxy { let proxy = Arc::clone(self); tokio::spawn(async move { let mut cursor = 0; + let mut retained_through = 0; let mut source_before = if provider == "codex" { proxy.current_source(&soul).await } else { @@ -1036,22 +1037,23 @@ impl HostedFreshAgentProxy { .await { Ok(batch) => { - if let Some(retained) = batch.retained_state { + if batch.retained_state.is_some() || cursor < retained_through { let after = proxy.current_source(&soul).await; - let valid = source_before.as_ref().zip(after.as_ref()).is_some_and( - |(before, after)| { - same_retained_host(before, after) - && retained_matches_view(&retained, before) - && retained_matches_view(&retained, after) - }, + let valid = retained_batch_matches_source( + source_before.as_ref(), + after.as_ref(), + batch.retained_state.as_ref(), ); source_before = after; if !valid { tracing::warn!(provider, soul_id=%soul, "freshagent.gateway.retained_source_changed"); cursor = 0; + retained_through = 0; tokio::time::sleep(Duration::from_millis(50)).await; continue; } + } + if let Some(retained) = batch.retained_state { let presentation = proxy .presentation_ids .lock() @@ -1070,13 +1072,10 @@ impl HostedFreshAgentProxy { "freshAgent.host.backpressure" }, ); - // The retained source covers the complete current prefix, - // including journal entries older than this bounded page. - cursor = batch.head; - tokio::time::sleep(Duration::from_millis(50)).await; - continue; - } - if batch.reset_required { + // Projection and controls are covered, but request receipts + // still have to drain through every bounded journal page. + retained_through = retained_through.max(batch.head); + } else if batch.reset_required { proxy.send(ServerMessage::FreshAgentEvent(FreshAgentEvent { event: serde_json::json!({ "type":"freshAgent.host.journal.reset", @@ -1087,8 +1086,16 @@ impl HostedFreshAgentProxy { session_type: session_type.clone(), })); } + if batch.events.is_empty() { + cursor = cursor.max(batch.head); + } for event in batch.events { cursor = cursor.max(event.sequence); + if event.sequence <= retained_through + && !is_request_receipt(&event.event) + { + continue; + } // Provider-native identity is nested provider state. The public // routing alias changes only through an explicit same-soul fork // transition or durable inventory reconciliation, never merely @@ -1195,25 +1202,36 @@ impl HostedFreshAgentProxy { presentation_session_id: &str, session_type: &str, ) { - let before = if provider == "codex" { + let mut before = if provider == "codex" { self.current_source(&soul).await } else { None }; - let Ok(batch) = self.client.fresh_agent_events(soul.clone(), 0, 4096).await else { - return; - }; - if let Some(retained) = batch.retained_state { - let after = self.current_source(&soul).await; - if before - .as_ref() - .zip(after.as_ref()) - .is_some_and(|(before, after)| { - same_retained_host(before, after) - && retained_matches_view(&retained, before) - && retained_matches_view(&retained, after) - }) - { + let mut cursor = 0; + let mut replay_through = None; + let mut retained_through = 0; + loop { + let Ok(batch) = self + .client + .fresh_agent_events(soul.clone(), cursor, 4096) + .await + else { + return; + }; + let replay_through = *replay_through.get_or_insert(batch.head); + if batch.retained_state.is_some() || cursor < retained_through { + let after = self.current_source(&soul).await; + let valid = retained_batch_matches_source( + before.as_ref(), + after.as_ref(), + batch.retained_state.as_ref(), + ); + before = after; + if !valid { + return; + } + } + if let Some(retained) = batch.retained_state { self.forward_retained( retained, provider, @@ -1221,11 +1239,28 @@ impl HostedFreshAgentProxy { session_type, "freshAgent.host.backpressure", ); + retained_through = retained_through.max(batch.head); + } + if batch.events.is_empty() { + return; + } + for event in batch.events { + if event.sequence > replay_through { + return; + } + cursor = cursor.max(event.sequence); + if event.sequence > retained_through || is_request_receipt(&event.event) { + self.forward_host_event( + event.event, + provider, + presentation_session_id, + session_type, + ); + } + } + if cursor >= replay_through { + return; } - return; - } - for event in batch.events { - self.forward_host_event(event.event, provider, presentation_session_id, session_type); } } @@ -1593,6 +1628,50 @@ fn same_retained_host( && before.host_boot_id == after.host_boot_id } +/// Retained projection/control state does not carry request acknowledgements. +/// Keep only these receipts when superseding the journal's display prefix. +fn is_request_receipt(event: &AgentEvent) -> bool { + let AgentEvent::Provider { payload } = event else { + return false; + }; + let receipt = if payload["type"] == "freshAgent.event" + && matches!( + payload["event"]["type"].as_str(), + Some( + "checkpointTurnIdentity" + | "freshAgent.rolledBack" + | "freshAgent.redone" + | "freshAgent.error" + ) + ) { + &payload["event"] + } else if matches!( + payload["type"].as_str(), + Some("freshAgent.send.accepted" | "error" | "freshAgent.checkpoint.outcome") + ) { + payload + } else { + return false; + }; + receipt["requestId"] + .as_str() + .is_some_and(|id| !id.is_empty()) +} + +fn retained_batch_matches_source( + before: Option<&freshell_runtime_protocol::RuntimeView>, + after: Option<&freshell_runtime_protocol::RuntimeView>, + retained: Option<&serde_json::Value>, +) -> bool { + before.zip(after).is_some_and(|(before, after)| { + same_retained_host(before, after) + && before.native_session_id == after.native_session_id + && retained.is_none_or(|retained| { + retained_matches_view(retained, before) && retained_matches_view(retained, after) + }) + }) +} + fn retained_matches_view( retained: &serde_json::Value, view: &freshell_runtime_protocol::RuntimeView, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 393418be0..d40040b40 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -2398,3 +2398,149 @@ async fn task6_gateway_ready_uses_current_provider_retained_attention() { async fn task6_gateway_ready_rejects_a_replaced_provider_source() { task6_gateway_source_scenario("ready-after-incarnation").await; } + +async fn task6_gateway_recovery_receipts( + replay: bool, + replace_on_continuation: bool, + replace_native: bool, +) { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("recovery-receipts.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let pages = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server_pages = pages.clone(); + let receipts = vec![ + serde_json::json!({"type":"freshAgent.send.accepted","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-answer","submittedTurnId":"native-turn"}), + serde_json::json!({"type":"freshAgent.send.accepted","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-ordinary","submittedTurnId":"ordinary-turn"}), + serde_json::json!({"type":"error","code":"INTERNAL_ERROR","message":"CODEX_TURN_START_FAILED: native input refused","timestamp":"2026-10-26T00:00:00.000Z","requestId":"refused-answer"}), + serde_json::json!({"type":"error","code":"INTERNAL_ERROR","message":"CODEX_DELIVERY_AMBIGUOUS: native response was lost","timestamp":"2026-10-26T00:00:00.000Z","requestId":"ambiguous-answer"}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"checkpointTurnIdentity","requestId":"accepted-answer","logicalAttemptId":"logical-owned","sessionId":"native-owned","nativeTurnId":"native-turn","displayTurnId":"native-turn:row-0"}}), + serde_json::json!({"type":"freshAgent.checkpoint.outcome","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-answer","logicalAttemptId":"logical-owned","status":"available","checkpointId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","nativeTurnId":"native-turn","displayTurnId":"native-turn:row-0"}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.rolledBack","requestId":"undo-owned","removedPromptText":"restore the exact prompt"}}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.error","requestId":"undo-refused","code":"BUSY_TURN","message":"The agent is still working."}}), + ]; + let expected = receipts.clone(); + let server = tokio::spawn(async move { + let mut payloads = vec![ + AgentEvent::Provider { + payload: serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.transcript.patch","text":"stale transcript must not replay"}}), + }, + AgentEvent::PermissionRequested { + decision_id: "stale-control".into(), + payload: serde_json::json!({"type":"freshAgent.question.request","requestId":"stale-control"}), + }, + ]; + payloads.extend( + receipts + .into_iter() + .map(|payload| AgentEvent::Provider { payload }), + ); + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health {control_epoch:7,installation_id:InstallationId::new()}, + AdminCommand::Inventory => AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"retained-soul","incarnationId":if replace_on_continuation && !replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-incarnation"} else {"original-incarnation"},"hostBootId":"original-boot","launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex","nativeSessionId":if replace_on_continuation && replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-native"} else {"native-owned"},"freshAgentSessionId":"public-owned","freshAgentSessionType":"freshcodex" + })).unwrap()]), + AdminCommand::FreshAgentReadEvents(request) => { + server_pages.fetch_add(1,std::sync::atomic::Ordering::SeqCst); + AdminResult::FreshAgentEvents(freshell_runtime_protocol::AgentEventBatch { + retained_state:(request.after_sequence==0).then(||serde_json::json!({ + "source":{"soulId":"retained-soul","incarnationId":"original-incarnation","hostBootId":"original-boot","projectionId":"source-projection","ownerEpoch":0,"ownerGeneration":0}, + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-owned","turns":[]},"attentionMarks":[], + "pendingControls":[{"type":"freshAgent.question.request","requestId":"current-control"}] + })), + retained_from:1,head:payloads.len() as u64,reset_required:request.after_sequence==0, + // The controller's own bound can be smaller than the requested + // page. Receipts deliberately lie beyond the first page. + events:payloads.iter().enumerate().filter(|(index,_)|(*index as u64)+1>request.after_sequence).take(request.max_events.min(2) as usize).map(|(index,event)|freshell_runtime_protocol::AgentJournalEvent {sequence:index as u64+1,event:event.clone()}).collect(), + })}, + other => panic!("recovery must only read events and source identity: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + let mut frames = proxy.broadcast.subscribe(); + let soul = SoulId::parse("retained-soul").unwrap(); + if replay { + proxy + .replay_once(soul, "codex", "public-owned", "freshcodex") + .await; + } else { + proxy + .start_poller( + soul, + "codex".into(), + "public-owned".into(), + "freshcodex".into(), + ) + .await; + } + let mut observed = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while observed.len() < expected.len() + 2 { + let Ok(Ok(frame)) = tokio::time::timeout_at(deadline, frames.recv()).await else { + break; + }; + observed.push(serde_json::from_str::(&frame).unwrap()); + } + server.abort(); + let _ = server.await; + assert!( + pages.load(std::sync::atomic::Ordering::SeqCst) >= 2, + "the real Unix controller must serve continuation pages" + ); + assert_eq!(observed.len(),if replace_on_continuation {2} else {expected.len()+2},"retained recovery must deliver every request receipt across bounded pages, only under the current owner: {observed:?}"); + assert_eq!( + observed[0]["event"]["retainedState"]["snapshot"]["threadId"], + "native-owned" + ); + assert_eq!(observed[1]["event"]["requestId"], "current-control"); + for (actual, mut expected) in observed.into_iter().skip(2).zip(expected) { + rewrite_presentation_id(&mut expected, "public-owned"); + assert_eq!( + actual, expected, + "keep exact request/attempt/native IDs and refusal/ambiguity reasons" + ); + } +} + +#[tokio::test] +async fn task6_gateway_poller_preserves_request_receipts_beyond_retained_first_page() { + task6_gateway_recovery_receipts(false, false, false).await; +} + +#[tokio::test] +async fn task6_gateway_replay_preserves_request_receipts_beyond_retained_first_page() { + task6_gateway_recovery_receipts(true, false, false).await; +} + +#[tokio::test] +async fn task6_gateway_poller_fences_receipts_after_source_changes_between_pages() { + task6_gateway_recovery_receipts(false, true, false).await; +} + +#[tokio::test] +async fn task6_gateway_replay_fences_receipts_after_source_changes_between_pages() { + task6_gateway_recovery_receipts(true, true, false).await; +} + +#[tokio::test] +async fn task6_gateway_poller_fences_receipts_after_native_changes_between_pages() { + task6_gateway_recovery_receipts(false, true, true).await; +} + +#[tokio::test] +async fn task6_gateway_replay_fences_receipts_after_native_changes_between_pages() { + task6_gateway_recovery_receipts(true, true, true).await; +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index f7c5c5884..97a711609 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -64,7 +64,7 @@ struct HostedTransport { /// journal); an operation subscribes to read the provider's answer. provider_frames: Arc>, profile: std::sync::Mutex>, - session_id: Mutex>, + session_id: Arc>>, created_rx: Mutex>>>, native_rx: Mutex>>, /// The native identity's writer, shared with the bridge: a claude fork @@ -243,10 +243,12 @@ impl HostedTransport { let (send_outcomes, _) = broadcast::channel(64); let (kill_outcomes, _) = broadcast::channel(64); let suppressed_kills = Arc::new(std::sync::Mutex::new(HashSet::new())); + let session_id = Arc::new(Mutex::new(None)); let bridge = spawn_broadcast_bridge( provider.clone(), broadcast_rx, BroadcastBridgeOutputs { + owned_session: Some(session_id.clone()), retained_codex: match &state { ProviderState::Codex(state) => Some(state.clone()), _ => None, @@ -264,7 +266,7 @@ impl HostedTransport { state, provider_frames: broadcast_tx, profile: std::sync::Mutex::new(None), - session_id: Mutex::new(None), + session_id, created_rx: Mutex::new(created_rx), native_rx: Mutex::new(native_rx), native_tx, @@ -1229,6 +1231,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { } struct BroadcastBridgeOutputs { + owned_session: Option>>>, retained_codex: Option, created: watch::Sender>>, native: watch::Sender>, @@ -1256,6 +1259,7 @@ fn spawn_broadcast_bridge( outputs: BroadcastBridgeOutputs, ) -> BroadcastBridgeTasks { let BroadcastBridgeOutputs { + owned_session, retained_codex, created, native, @@ -1298,39 +1302,35 @@ fn spawn_broadcast_bridge( } let mut pending = Vec::new(); if let Some(state) = &retained_codex { - let mut ids = recovery_native - .borrow() - .clone() - .into_iter() - .collect::>(); - // A dropped materialization may also have dropped the native - // identity edge. Resolve only registered retained sources. - ids.extend( - state - .attention_marks() - .await - .into_iter() - .map(|mark| mark.session_id), - ); - for session_id in ids { - if let Ok(retained) = state.retained_state(&session_id).await { - pending = retained["pendingControls"] - .as_array() - .cloned() - .unwrap_or_default(); - let dropped = payload - .as_ref() - .and_then(|frame| frame["event"]["dropped"].as_u64()) - .unwrap_or(1); - let mut frame = bridge_recovery_frame( - &recovery_provider, - "freshcodex", - &session_id, - dropped, - ); - frame["event"]["retainedState"] = retained; - payload = Some(frame); - break; + let owned = match &owned_session { + Some(session) => session.lock().await.clone(), + None => None, + }; + if let Some(session_id) = owned.or_else(|| recovery_native.borrow().clone()) { + match state.retained_state(&session_id).await { + Ok(retained) => { + pending = retained["pendingControls"] + .as_array() + .cloned() + .unwrap_or_default(); + let dropped = payload + .as_ref() + .and_then(|frame| frame["event"]["dropped"].as_u64()) + .unwrap_or(1); + let mut frame = bridge_recovery_frame( + &recovery_provider, + "freshcodex", + retained["snapshot"]["threadId"] + .as_str() + .unwrap_or(&session_id), + dropped, + ); + frame["event"]["retainedState"] = retained; + payload = Some(frame); + } + Err(error) => { + tracing::warn!(provider=recovery_provider.as_str(),session_id,error=?error,"freshagent.host.retained_source_unavailable") + } } } } @@ -1716,6 +1716,156 @@ mod tests { assert!(final_event.is_none()); } + // The real host actor drains the tested bridge; startup is already complete + // in the native fixture, so this adapter only hands over its owned stream. + struct StartedBridgeTransport { + native: String, + events: std::sync::Mutex>>, + } + + #[async_trait] + impl FreshAgentTransport for StartedBridgeTransport { + async fn start(&self, _: &FreshAgentProfile) -> Result { + Ok(TransportStart { + native_session_id: Some(self.native.clone()), + }) + } + async fn dispatch( + &self, + _: &RequestId, + _: &str, + _: &FreshAgentProfile, + ) -> Result { + unreachable!() + } + async fn resolve_permission(&self, _: &str, _: Value) -> Result<(), DispatchFailure> { + Err(DispatchFailure { + message: "this actor does not own that decision".into(), + acceptance_ambiguous: false, + }) + } + async fn interrupt(&self) -> Result<(), String> { + unreachable!() + } + async fn stop(self: Arc) -> Result<(), String> { + Ok(()) + } + fn take_event_stream(&self) -> Option> { + self.events.lock().unwrap().take() + } + } + + #[tokio::test] + async fn task6_recovery_does_not_journal_another_sessions_pending_decision() { + let mut fixture = native_codex_fixture(json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":71,"method":"item/tool/requestUserInput","params":{"isBlocking":true,"questions":[{"id":"choice","header":"Choice","question":"Other session only?","options":null}]}}}})).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + transport + .dispatch( + &RequestId::parse("other-pending").unwrap(), + "ask", + &fixture.profile, + ) + .await + .unwrap(); + let other_control = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.question.request" { + break frame["event"].clone(); + } + } + }) + .await + .unwrap(); + let ProviderState::Codex(state) = &transport.state else { + unreachable!() + }; + let state = state.clone(); + let mut owned_profile = fixture.profile.clone(); + owned_profile.native_session_id = Some("thread-owned-2".into()); + state + .handle_create(create_request_for_profile(&owned_profile), None) + .await; + assert!( + state.retained_state("thread-owned-2").await.is_ok(), + "both native sessions must actually be registered" + ); + state.handle_kill(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-owned-2"})).unwrap()).await; + assert!(state.retained_state("thread-owned-2").await.is_err()); + assert_eq!( + state.retained_state("thread-new-1").await.unwrap()["pendingControls"][0], + other_control + ); + + let (bus, receiver) = broadcast::channel(8); + let (events, stream) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (native, _) = watch::channel(Some("thread-owned-2".into())); + let (created, _) = watch::channel(None); + let bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + receiver, + BroadcastBridgeOutputs { + owned_session: None, + retained_codex: Some(state.clone()), + created, + native, + events: events.clone(), + send_outcomes: broadcast::channel(8).0, + kill_outcomes: broadcast::channel(8).0, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + bus.send(json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-owned-2","event":{"type":"freshAgent.notice","message":"last dropped frame"}}).to_string()).unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("isolated-actor"), + owned_profile, + Arc::new(StartedBridgeTransport { + native: "thread-owned-2".into(), + events: std::sync::Mutex::new(Some(stream)), + }), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3),async { + loop { + if actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["type"]=="freshAgent.host.backpressure")) {break;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("capacity alone must settle the final lost frame"); + // No later provider frame is emitted. Allow the recovered durable-control + // conversion to finish, then inspect the actual actor journal/decision API. + tokio::time::sleep(Duration::from_millis(100)).await; + let batch = actor.read_events(0, 256).await; + let resolution = actor + .resolve_permission( + other_control["requestId"].as_str().unwrap(), + json!({"answers":{"choice":"wrong actor"}}), + ) + .await; + drop(bridge); + drop(events); + actor.stop().await.unwrap(); + transport.stop().await.unwrap(); + assert!(!batch.events.iter().any(|entry|matches!(&entry.event,AgentEvent::PermissionRequested {..})),"another registered source must never become this actor's durable pending decision: {batch:?}"); + assert!( + matches!( + resolution, + Err(freshell_agent_runtime::host_actor::ActorError::UnknownDecision) + ), + "the foreign decision must not exist in this actor: {resolution:?}" + ); + assert!(!batch.events.iter().any(|entry|matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["retainedState"]["snapshot"]["threadId"]=="thread-new-1")),"a recovery marker cannot carry another source's transcript"); + } + struct NativeCodexFixture { transport: Arc, profile: FreshAgentProfile, @@ -1965,9 +2115,15 @@ mod tests { ) .await .unwrap(); + let mut accepted = None; let completion = tokio::time::timeout(Duration::from_secs(5), async { loop { let frame: Value = serde_json::from_str(&diagnostic.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.send.accepted" + && frame["requestId"] == "retained-send" + { + accepted = Some(frame.clone()); + } if frame["event"]["type"] == "freshAgent.turn.complete" { break frame["event"]["at"].clone(); } @@ -1994,6 +2150,13 @@ mod tests { let retained = serde_json::to_string(snapshot).unwrap(); let native_id = transport.session_id.lock().await.clone().unwrap(); transport.clone().stop().await.unwrap(); + let accepted = accepted.expect( + "the real ordinary send must emit its correlated native receipt before completion", + ); + assert_eq!(accepted["sessionId"], native_id); + assert!(accepted["submittedTurnId"] + .as_str() + .is_some_and(|id| !id.is_empty())); assert!( retained.contains("Native streaming fragment and final answer"), "recover exact event-owned body without thread/read: {recovered}" @@ -2009,6 +2172,62 @@ mod tests { ); } + #[tokio::test] + async fn task6_fork_recovery_uses_transport_owner_after_a_lost_native_identity_edge() { + let fixture = native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let parent = transport + .start(&fixture.profile) + .await + .unwrap() + .native_session_id + .unwrap(); + let mut events = transport.take_event_stream().unwrap(); + let child = transport + .fork(&RequestId::parse("recovery-fork").unwrap(), &parent, None) + .await + .unwrap() + .child_session_id; + // The explicit fork already committed the child owner. Simulate losing + // its native identity frame while keeping the old watch value. + transport.native_tx.send_replace(Some(parent)); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("child-final-send").unwrap(), + "child output", + &fixture.profile, + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["retainedState"].is_object() { + break payload["event"]["retainedState"].clone(); + } + } + } + }) + .await; + transport.stop().await.unwrap(); + let recovered = recovered.expect( + "a committed fork owner must recover its final output without another provider frame", + ); + assert_eq!(recovered["snapshot"]["threadId"], child); + assert!(recovered["snapshot"] + .to_string() + .contains("Native streaming fragment and final answer")); + } + #[tokio::test] async fn task6_journal_reset_reads_authoritative_source_after_final_native_frame() { let mut fixture = @@ -2320,6 +2539,7 @@ mod tests { FreshProvider::Codex, rx, BroadcastBridgeOutputs { + owned_session: None, retained_codex: None, created, native, @@ -2369,6 +2589,7 @@ mod tests { FreshProvider::Codex, rx, BroadcastBridgeOutputs { + owned_session: None, retained_codex: None, created, native, @@ -2408,6 +2629,7 @@ mod tests { FreshProvider::Claude, rx, BroadcastBridgeOutputs { + owned_session: None, retained_codex: None, created, native, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 2491effd0..adea81f84 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15034,6 +15034,41 @@ describe('task delegation open-session link (freshopencode tui parity)', () => { }) describe('task6 hosted recovery in the mounted view', () => { + it.each(['accepted', 'refused', 'ambiguous'] as const)('keeps per-request async answer %s visible after retained recovery', async (outcome) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const snapshot = {...native,status:'idle',capabilities:{...native.capabilities,send:true,interrupt:true},extensions:{codex:{ownerKind:'fresh-agent',statusFromLiveState:true}},turns:[{id:'async-turn',turnId:'async-turn',role:'assistant',summary:'',items:[{id:'async-answer',kind:'text',text:'Work can continue.',extensions:{codex:{itemId:'async-answer',delivery:'async',questions:[{id:'where',title:'Where?',options:['Staging']}]}}}]}]} + const store = createStore() + const locator = {provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const handlers = new Set<(message:any)=>void>() + wsMock.onMessage.mockImplementation((handler)=>{handlers.add(handler);return()=>{handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(snapshot) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'receipt-view',status:'idle'}})) + render() + fireEvent.click(await screen.findByRole('button',{name:'Staging',exact:true})) + const composer = screen.getByRole('textbox',{name:'Chat message input'}) + const draft = (composer as HTMLTextAreaElement).value + expect(draft).toContain('Answer to Codex question:') + fireEvent.click(screen.getByRole('button',{name:'Send',exact:true})) + await waitFor(()=>expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1)) + const sent = sentFreshAgentMessages('freshAgent.send')[0] + expect(screen.getByText('Sending answer as a message')).toBeInTheDocument() + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + const retained = {...snapshot,extensions:{codex:{projection:{id:'receipt-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['async-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'async-turn':['async-turn']},turnOutputStates:{},settledTurnIds:[]}}}} + const deliver = (message:any)=>act(()=>{handleFreshAgentMessage(store.dispatch,message);for(const handler of handlers)handler(message)}) + try { + deliver({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'receipt-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:retained,pendingControls:[],attentionMarks:[]}}}) + await expectRenderedText('Work can continue.') + // A fresh transcript cannot decide this request's outcome. + expect(screen.getByText('Sending answer as a message')).toBeInTheDocument() + deliver(outcome==='accepted'?{type:'freshAgent.send.accepted',...locator,requestId:sent.requestId,submittedTurnId:'native-receipt-turn'}:{type:'error',code:'INTERNAL_ERROR',requestId:sent.requestId,message:outcome==='ambiguous'?'CODEX_DELIVERY_AMBIGUOUS: native response was lost':'CODEX_TURN_START_FAILED: native input refused'}) + await waitFor(()=>expect(screen.getByText(outcome==='accepted'?'Answer accepted as a message':outcome==='ambiguous'?'Answer delivery uncertain; review the composer before sending again.':'Answer not sent; your draft is in the composer.')).toBeInTheDocument()) + if(outcome!=='accepted') expect(composer).toHaveValue(draft) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.question.respond')).toHaveLength(0) + } finally {cleanup();await act(async()=>held.resolve(retained))} + }) + it.each(['freshAgent.host.backpressure', 'freshAgent.host.journal.reset'])('shows retained rows on %s while the native history refresh is pending', async (kind) => { const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) const store = createStore() From 1b61e21c2d169b7e7cff176786b631d4877afb5c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:55:21 -0700 Subject: [PATCH 35/92] test(codex): wait for recovery fixture boundaries --- .../src/providers/fresh_agent.rs | 17 +++++++++++++++-- .../fresh-agent/FreshAgentView.test.tsx | 14 ++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 97a711609..de8a7eb80 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -2174,7 +2174,8 @@ mod tests { #[tokio::test] async fn task6_fork_recovery_uses_transport_owner_after_a_lost_native_identity_edge() { - let fixture = native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; let transport = fixture.transport.clone(); let parent = transport .start(&fixture.profile) @@ -2207,7 +2208,19 @@ mod tests { ) .await .unwrap(); - tokio::time::sleep(Duration::from_millis(250)).await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["sessionId"].as_str() == Some(child.as_str()) + && frame["event"]["type"] == "freshAgent.turn.complete" + { + break; + } + } + }) + .await + .expect("observe the child's final native edge before releasing capacity"); let recovered = tokio::time::timeout(Duration::from_secs(3), async { loop { if let Some(AgentEvent::Provider { payload }) = events.recv().await { diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index adea81f84..bd97eb4b7 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -15143,12 +15143,18 @@ describe('task6 retained source against in-flight native history', () => { await expectRenderedText('Saved native Codex answer') const held = createDeferred() const later = createDeferred() - apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(held.promise).mockReturnValue(later.promise) + const heldStarted = vi.fn(() => held.promise) + apiMock.getFreshAgentThreadSnapshot.mockImplementationOnce(heldStarted).mockReturnValue(later.promise) const reset = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}} - act(() => {for (const handler of handlers) handler(reset)}) - await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.results.some((result) => result.value === held.promise)).toBe(true)) const message = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'current-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}} try { + // Establish the older read at the scheduler's actual entry boundary. + // Advancing its debounce avoids making this race depend on wall time. + vi.useFakeTimers() + act(() => {for (const handler of [...handlers]) handler(reset)}) + await act(async () => {await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS)}) + expect(heldStarted).toHaveBeenCalledTimes(1) + vi.useRealTimers() act(() => {handleFreshAgentMessage(store.dispatch,message as never);for (const handler of handlers) handler(message)}) await expectRenderedText('Newest retained source output') await act(async () => held.resolve(stale)) @@ -15156,7 +15162,7 @@ describe('task6 retained source against in-flight native history', () => { expect(screen.getByText('Newest retained source output')).toBeVisible() expect(screen.queryByText('Obsolete source history')).not.toBeInTheDocument() expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) - } finally {cleanup();await act(async () => {held.resolve(stale);later.resolve(snapshot)})} + } finally {vi.useRealTimers();cleanup();await act(async () => {held.resolve(stale);later.resolve(snapshot)})} }) }) From d6c0e7ed91d529b04a5c9cd866df9a814dbfb98d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:13:33 -0700 Subject: [PATCH 36/92] test(codex): define native catalog and exact selection contracts --- crates/freshell-codex/src/model.rs | 9 ++ crates/freshell-freshagent/src/codex.rs | 70 ++++++++++ .../src/model_capabilities_tests.rs | 13 ++ .../src/fresh_agent_proxy_tests.rs | 68 ++++++++++ .../tests/codex_model_catalog.rs | 122 ++++++++++++++++++ .../fresh-agent-model-dialog-parity.spec.ts | 83 ++++++++++++ .../codex-app-server/fake-app-server.mjs | 11 ++ .../FreshAgentModelDialog.test.tsx | 80 ++++++++++++ .../lib/fresh-agent-create-content.test.ts | 11 ++ .../fresh-agent-model-capabilities.test.ts | 28 +++- test/unit/shared/fresh-agent-models.test.ts | 11 ++ 11 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 crates/freshell-server/tests/codex_model_catalog.rs diff --git a/crates/freshell-codex/src/model.rs b/crates/freshell-codex/src/model.rs index 5f5def41f..709924d72 100644 --- a/crates/freshell-codex/src/model.rs +++ b/crates/freshell-codex/src/model.rs @@ -182,6 +182,15 @@ pub fn to_codex_reasoning_effort(value: Option<&str>) -> Result, mod tests { use super::*; + #[test] + fn explicit_native_model_choices_are_never_replaced_by_static_defaults() { + assert_eq!(normalize_freshcodex_model(Some("provider-current")), "provider-current"); + for effort in ["ultra", "none", "xhigh", "max"] { + assert_eq!(normalize_freshcodex_effort(Some("provider-current"), Some(effort)).as_deref(), Some(effort)); + assert_eq!(to_codex_reasoning_effort(Some(effort)).unwrap().as_deref(), Some(effort)); + } + } + // ── DEV-0003: none/minimal are forwarded VERBATIM (no clamp) ─────────────────────── #[test] diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index fd53572b8..6a9bac4dd 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -29158,6 +29158,76 @@ pub(crate) mod tests { // ── freshAgent.configure: live model convergence ───────────────────────── + #[tokio::test(flavor = "multi_thread")] + async fn model_catalog_only_maps_legacy_max_when_selected_model_advertises_xhigh_without_max() { + let _guard = ENV_LOCK.lock().await; + for (levels, expected) in [(vec!["xhigh"], "xhigh"), (vec!["xhigh", "max"], "max"), (vec!["ultra"], "max")] { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath": log,"modelCatalogPages":[{"data":[{ + "id":"model-record","model":"gpt-6-astra","displayName":"Installed Astra", + "supportedReasoningEfforts":levels.iter().map(|effort|json!({"reasoningEffort":effort})).collect::>(), + "defaultReasoningEffort":levels[0],"inputModalities":["text"] + }],"nextCursor":null}]}).to_string()); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("legacy-effort-create"); + create.model = Some("gpt-6-astra".into()); + create.effort = Some("max".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.created").await; + let id = created.frames.iter().find(|f| f["type"] == "freshAgent.created").unwrap()["sessionId"].as_str().unwrap(); + st.handle_send(send_msg(id, "legacy max selection")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["type"] == "freshAgent.send.accepted").await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|line|serde_json::from_str(line).unwrap()).collect(); + let turn = requests.iter().find(|r|r["method"]=="turn/start").unwrap(); + assert_eq!(turn["params"]["effort"], expected, "installed levels: {levels:?}"); + } + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_model_choices_reach_create_send_configure_resume_and_fork_requests() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath": log, "recordTurns": true}).to_string()); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("native-model-create"); + create.model_selection = Some(Some(freshell_protocol::ModelSelection { kind: "exact".into(), model_id: "provider-current".into() })); + create.effort = Some("ultra".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.created").await; + let session_id = created.frames.iter().find(|f| f["type"] == "freshAgent.created").unwrap()["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&session_id, "first native choice")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; + st.handle_configure(configure_msg(&session_id, Some("provider-current"), Some("ultra"))).await; + st.handle_send(send_msg(&session_id, "configured native choice")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; + let (sink, fork_frames) = capturing_sink(); + st.handle_fork(fork_msg(&session_id, "native-model-fork", None), None, sink).await; + let forked = captured_frames(&fork_frames); + let child = forked.iter().find(|f|f["type"]=="freshAgent.forked").unwrap()["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&child, "forked native choice")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; + simulate_unrequested_crash(&st, &session_id).await; + st.handle_send(send_msg(&session_id, "resumed native choice")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.send.accepted").await; + st.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + for method in ["thread/start", "thread/resume", "thread/fork", "turn/start"] { + let rows: Vec<_> = requests.iter().filter(|row| row["method"] == method).collect(); + assert!(!rows.is_empty(), "native method not exercised: {method}: {requests:?}"); + for row in rows { + assert_eq!(row["params"]["model"], "provider-current", "{method}"); + if method == "turn/start" { assert_eq!(row["params"]["effort"], "ultra"); } + } + } + } + fn configure_msg( session_id: &str, model: Option<&str>, diff --git a/crates/freshell-freshagent/src/model_capabilities_tests.rs b/crates/freshell-freshagent/src/model_capabilities_tests.rs index 7c87dc443..8fb09740e 100644 --- a/crates/freshell-freshagent/src/model_capabilities_tests.rs +++ b/crates/freshell-freshagent/src/model_capabilities_tests.rs @@ -724,6 +724,19 @@ async fn post(app: Router, uri: &str, auth: bool) -> (StatusCode, Value) { const CAP: &str = "/api/fresh-agent/model-capabilities"; +#[tokio::test] +async fn codex_model_catalog_without_bound_runtime_is_pending_and_does_not_probe_gateway() { + let probe = Arc::new(RecordingProbe::new()); + let app = app_with_probe(probe.clone()); + for uri in [format!("{CAP}/freshcodex"), format!("{CAP}/freshcodex?sessionId=not-created&cwd=/other-home")] { + let (status, body) = get(app.clone(), &uri, true).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["code"], "RUNTIME_PENDING"); + assert_eq!(body["models"], json!([])); + } + assert_eq!(probe.call_count().await, 0); +} + /// `model-capabilities-router.ts:34-37 + :50-53`. #[tokio::test] async fn invalid_session_type_is_400_for_get_and_refresh() { diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index d40040b40..ab2b7adcf 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -6,6 +6,74 @@ use freshell_runtime_protocol::{ }; use tokio::net::UnixListener; +#[tokio::test] +async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_sources() { + use tower::ServiceExt; + let mut env = crate::test_env_lock::TestEnv::lock().await; + let dir = tempfile::tempdir().unwrap(); + isolate_snapshot_env(&mut env, dir.path()); + for scenario in ["same", "incarnation", "native", "boot", "generation"] { + let socket = dir.path().join(format!("catalog-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::::new())); + let seen = calls.clone(); + let server = tokio::spawn(async move { + let mut inventories = 0; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body["method"].as_str().unwrap() { + "health" => serde_json::to_value(AdminResult::Health { control_epoch:7, installation_id:InstallationId::new() }).unwrap(), + "inventory" => { + inventories += 1; + let changed = inventories >= 2; + serde_json::json!({"kind":"inventory","data":[{ + "soulId":"current-root-soul","incarnationId":if changed && scenario=="incarnation" {"replacement"} else {"original"}, + "hostBootId":if changed && scenario=="boot" {"new-boot"} else {"original-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":1, + "executionGeneration":if changed && scenario=="generation" {2} else {1}, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":if changed && scenario=="native" {"new-native"} else {"current-native"}, + "freshAgentSessionId":"public-conversation","freshAgentSessionType":"freshcodex" + }]}) + } + "fresh_agent_model_catalog" => { + seen.lock().await.push(envelope.body["params"].clone()); + serde_json::json!({"kind":"fresh_agent_model_catalog","data":[{ + "id":"managed-current","displayName":"Managed Current","supportedEffortLevels":["ultra"],"defaultEffort":"ultra","inputModalities":["text","image"] + }]}) + } + other => panic!("catalog attempted unrelated operation: {other}"), + }; + write_frame(&mut stream,&serde_json::json!({"requestId":envelope.request_id,"result":{"Ok":result}})).await.unwrap(); + } + }); + let (broadcast, _) = broadcast::channel(16); + let owner = freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()),Arc::new(broadcast)); + owner.set_hosted_rest_gateway(snapshot_outage_proxy(&socket)).unwrap(); + let app = freshell_freshagent::model_capabilities::router(owner); + let response = app.oneshot(axum::http::Request::builder().uri("/api/fresh-agent/model-capabilities/freshcodex?sessionId=public-conversation&cwd=/managed/project&soulId=stale-frontend-soul").header("x-auth-token","tok").body(axum::body::Body::empty()).unwrap()).await.unwrap(); + let status = response.status(); + let body:serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(response.into_body(),1024*1024).await.unwrap()).unwrap(); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + if scenario=="same" { + assert_eq!(status,axum::http::StatusCode::OK,"{body}"); + assert_eq!(body["models"][0]["id"],"managed-current"); + assert_eq!(body["models"][0]["supportedEffortLevels"],serde_json::json!(["ultra"])); + } else { + assert_eq!(status,axum::http::StatusCode::SERVICE_UNAVAILABLE,"{scenario}: {body}"); + assert_eq!(body["models"],serde_json::json!([])); + } + let calls = calls.lock().await; + assert_eq!(calls.len(),1,"only the current host is queried"); + assert_eq!(calls[0]["soulId"],"current-root-soul"); + assert_eq!(calls[0]["cwd"],"/managed/project"); + assert_eq!(calls[0]["expectedControlEpoch"],7); + } +} + #[tokio::test] async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_sources() { use tower::ServiceExt; diff --git a/crates/freshell-server/tests/codex_model_catalog.rs b/crates/freshell-server/tests/codex_model_catalog.rs new file mode 100644 index 000000000..82ab30999 --- /dev/null +++ b/crates/freshell-server/tests/codex_model_catalog.rs @@ -0,0 +1,122 @@ +//! Public WS creation -> bound HTTP model discovery, using the real server and +//! owned Codex CLI fixture. Every provider process and HOME belongs to this test. +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{path::Path, process::{Child, Command, Stdio}, time::{Duration, Instant}}; +use tokio_tungstenite::tungstenite::Message; + +const TOKEN: &str = "codex-catalog-owned-fixture"; +type Socket = tokio_tungstenite::WebSocketStream>; + +struct OwnedServer(Child); +impl Drop for OwnedServer { + fn drop(&mut self) { + unsafe { libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); } + let end = Instant::now() + Duration::from_secs(10); + while Instant::now() < end { + if self.0.try_wait().ok().flatten().is_some() { return; } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +async fn message(socket: &mut Socket, kind: &str) -> Value { + tokio::time::timeout(Duration::from_secs(15), async { + while let Some(frame) = socket.next().await { + if let Message::Text(text) = frame.unwrap() { + let value: Value = serde_json::from_str(&text).unwrap(); + if value["type"] == kind { return value; } + assert_ne!(value["type"], "freshAgent.create.failed", "{value}"); + } + } + panic!("socket closed before {kind}") + }).await.unwrap() +} + +async fn boot(home: &Path, behavior: Value) -> (OwnedServer, String, Socket) { + let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + std::fs::create_dir_all(home.join(".freshell")).unwrap(); + std::fs::write(home.join(".freshell/config.json"), json!({"settings":{"freshAgent":{"enabled":true},"network":{"configured":true,"host":"127.0.0.1"}}}).to_string()).unwrap(); + let bin = std::env::var_os("FRESHELL_SERVER_BIN").expect("run with run-focused-rust.py --build-server"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); + let server = OwnedServer(Command::new(bin).current_dir(home) + .env("PORT", port.to_string()).env("AUTH_TOKEN", TOKEN).env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home).env("FRESHELL_HOME", home).env("CODEX_HOME", home.join(".codex")) + .env("CODEX_CMD", format!("node {}", fixture.display())) + .env("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()) + .env_remove("FRESHELL_MANAGED_RUNTIME_V1").env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap()); + let base = format!("http://127.0.0.1:{port}"); + let http = reqwest::Client::new(); + let end = Instant::now() + Duration::from_secs(20); + loop { + if http.get(format!("{base}/api/health")).send().await.is_ok_and(|r|r.status().is_success()) {break;} + assert!(Instant::now() < end, "owned server did not start"); + tokio::time::sleep(Duration::from_millis(30)).await; + } + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")).await.unwrap(); + socket.send(Message::Text(json!({"type":"hello","protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"token":TOKEN}).to_string())).await.unwrap(); + message(&mut socket, "ready").await; + (server, base, socket) +} + +async fn assert_catalog_scenario(scenario: &str) { + let home = tempfile::tempdir().unwrap(); + let log = home.path().join("requests.jsonl"); + let first = json!({"id":"record-sol","model":"gpt-6.1-sol","displayName":"GPT 6.1 Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}); + let second = json!({"id":"opaque-record","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"none"},{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}); + let pages = match scenario { + "loop" => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":"1"}]), + "malformed" => json!([{"data":[first],"nextCursor":7}]), + _ => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":null}]), + }; + let (server, base, mut socket) = boot(home.path(), json!({"modelCatalogPages":pages,"appendRequestLogPath":log,"ignoreMethods":if scenario=="deadline" {vec!["model/list"]} else {vec![]}})).await; + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"catalog-create","provider":"codex","sessionType":"freshcodex","cwd":home.path(),"model":"provider-current","effort":"ultra"}).to_string())).await.unwrap(); + let created = message(&mut socket, "freshAgent.created").await; + let http = reqwest::Client::new(); + let mut url = reqwest::Url::parse(&format!("{base}/api/fresh-agent/model-capabilities/freshcodex")).unwrap(); + url.query_pairs_mut().append_pair("sessionId",created["sessionId"].as_str().unwrap()); + let response = http.get(url.clone()).header("x-auth-token", TOKEN).send().await.unwrap(); + let status = response.status().as_u16(); + let body: Value = response.json().await.unwrap(); + let cached: Option = if scenario == "valid" { + Some(http.get(url).header("x-auth-token", TOKEN).send().await.unwrap().json().await.unwrap()) + } else { None }; + socket.close(None).await.unwrap(); + drop(server); + let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|s|serde_json::from_str(s).unwrap()).collect(); + let lists: Vec<_> = requests.iter().filter(|r|r["method"]=="model/list").collect(); + if scenario == "valid" { + assert_eq!(status,200,"{body}"); + assert_eq!(body["models"][0]["id"],"gpt-6.1-sol"); + assert_eq!(body["models"][1]["id"],"provider-current"); + assert_eq!(body["models"][1]["defaultEffort"],"ultra"); + assert_eq!(body["models"][1]["supportedEffortLevels"],json!(["none","ultra"])); + assert_eq!(body["models"][0]["inputModalities"],json!(["text","image"])); + assert_eq!(cached.unwrap()["status"],"cached"); + assert_eq!(lists.len(),2); + assert!(lists[0]["params"].get("cursor").is_none_or(Value::is_null)); + assert_eq!(lists[1]["params"]["cursor"],"1"); + assert_eq!(lists[0]["params"]["includeHidden"],false); + } else { + assert_eq!(status,503,"{scenario}: {body}"); + assert_eq!(body["status"],"unavailable"); + assert_eq!(body["models"],json!([])); + assert!(body["error"]["code"].as_str().unwrap().starts_with("CAPABILITY_")); + assert!(lists.len() <= 2); + } + assert_eq!(requests.iter().filter(|r|r["method"]=="initialize").count(),1,"catalog must reuse its registered process"); + assert_eq!(requests.iter().filter(|r|r["method"]=="thread/start").count(),1); + assert!(!requests.iter().any(|r|r["method"]=="thread/resume")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_paginates_and_caches() { assert_catalog_scenario("valid").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_rejects_looping_cursors() { assert_catalog_scenario("loop").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_rejects_malformed_cursor() { assert_catalog_scenario("malformed").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_bounds_native_deadline() { assert_catalog_scenario("deadline").await; } diff --git a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts index abd18290d..5287adbac 100644 --- a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts @@ -2,6 +2,89 @@ import type { Page } from '@playwright/test' import { test, expect } from '../helpers/fixtures.js' import { FRESHCODEX_DEFAULT_MODEL } from '@shared/fresh-agent-models.js' +const codexCatalog = { + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1234, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex', + supportsEffort: true, supportedEffortLevels: ['none', 'ultra'], defaultEffort: 'ultra', + inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], +} + +test('Codex discovers only the bound runtime and preserves search and exact settings across rebind', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + let releaseOld!: () => void + const oldHeld = new Promise((resolve) => { releaseOld = resolve }) + const contexts: URLSearchParams[] = [] + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async (route) => { + const query = new URL(route.request().url()).searchParams + contexts.push(query) + if (contexts.length === 1) { + await oldHeld + await route.fulfill({ json: { ...codexCatalog, models: [{ ...codexCatalog.models[0], id: 'stale-model', displayName: 'Stale Provider' }] } }) + } else await route.fulfill({ json: codexCatalog }) + }) + await installPane(page, 'codex') + await page.getByRole('button', { name: /^Model:.*change model$/ }).click() + const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect.poll(() => contexts.length).toBe(1) + await dialog.getByRole('searchbox').fill('provider') + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + harness.dispatch({ type: 'panes/mergePaneContent', payload: { tabId, paneId, + updates: { sessionId: 'replacement-native', sessionRef: { provider: 'codex', sessionId: 'replacement-native' } } } }) + }) + await dialog.getByRole('option', { name: 'Provider Current', exact: true }).click() + releaseOld() + await expect(dialog.getByRole('searchbox')).toHaveValue('provider') + await expect(dialog.getByRole('option', { name: 'Stale Provider' })).toHaveCount(0) + await expect.poll(() => contexts.length).toBe(2) + expect(contexts[0].get('sessionId')).toBe('d4430000-0000-4444-8444-000000000001') + expect(contexts[1].get('sessionId')).toBe('replacement-native') + await dialog.getByRole('button', { name: 'Use Provider Current · ultra' }).click() + await expect.poll(() => page.evaluate(() => { + const state = window.__FRESHELL_TEST_HARNESS__!.getState() + const layout = state.panes.layouts[state.tabs.activeTabId!] + return layout?.type === 'leaf' ? { model: layout.content.model, effort: layout.content.effort } : null + })).toEqual({ model: 'provider-current', effort: 'ultra' }) +}) + +test('Codex picker waits for ordinary create and shows honest failure with exact saved input', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + let probes = 0 + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async (route) => { + probes++ + await route.fulfill({ json: codexCatalog }) + }) + await installPane(page, 'codex') + const setStatus = async (status: string, sessionId?: string) => page.evaluate(({ status, sessionId }) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + harness.dispatch({ type: 'panes/mergePaneContent', payload: { tabId, paneId, updates: { + status, sessionId, sessionRef: undefined, resumeSessionId: undefined, + model: 'saved-custom', modelSelection: { kind: 'exact', modelId: 'saved-custom' }, effort: 'xhigh', + } } }) + }, { status, sessionId }) + await setStatus('creating') + await page.getByRole('button', { name: /^Model:.*change model$/ }).click() + const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('searchbox').fill('provider') + expect(probes).toBe(0) + await setStatus('idle', 'acknowledged-native') + await expect(dialog.getByRole('option', { name: 'Provider Current' })).toBeVisible() + await expect(dialog.getByRole('searchbox')).toHaveValue('provider') + expect(probes).toBe(1) + await setStatus('error') + await expect(dialog.getByRole('status')).toContainText(/catalog unavailable/i) + await dialog.getByRole('searchbox').fill('') + await expect(dialog.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() + expect(probes).toBe(1) +}) + async function installPane(page: Page, provider: 'claude' | 'codex') { const sessionType = provider === 'claude' ? 'freshclaude' : 'freshcodex' const sessionId = 'd4430000-0000-4444-8444-000000000001' diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index eea001bca..b76e032ab 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -331,6 +331,14 @@ function successResult(method, params) { } } if (method === 'configRequirements/read') return { requirements: behavior.configRequirements ?? null } + if (method === 'model/list') { + const pages = behavior.modelCatalogPages ?? [{ data: [ + { id: 'native-record', model: 'provider-current', displayName: 'Provider Current', + supportedReasoningEfforts: [{ reasoningEffort: 'none' }, { reasoningEffort: 'ultra' }], + defaultReasoningEffort: 'ultra', inputModalities: ['text', 'image'] }, + ], nextCursor: null }] + return pages[params?.cursor ? Number(params.cursor) : 0] + } if (method === 'thread/start') { const threadId = behavior.threadStartThreadId || (behavior.uniqueThreadIds === true @@ -806,6 +814,9 @@ wss.on('connection', (socket) => { return } const method = message.method + if (behavior.appendRequestLogPath) { + fs.appendFileSync(behavior.appendRequestLogPath, JSON.stringify({ method, params: message.params, codexHome: getCodexHome() }) + '\n') + } if ( behavior.requireInitializeBeforeOtherMethods diff --git a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx index 9997be791..34197c6b6 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx @@ -582,6 +582,86 @@ describe('FreshAgentModelDialog (freshopencode)', () => { }) describe('FreshAgentModelDialog (freshcodex)', () => { + const nativeCatalog = { + ok: true as const, sessionType: 'freshcodex' as const, runtimeProvider: 'codex' as const, + status: 'fresh' as const, fetchedAt: 1234, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex' as const, + supportsEffort: true, supportedEffortLevels: ['none', 'ultra'], defaultEffort: 'ultra', + inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], + } + + it('keeps a custom model and effort staged before create acknowledgement when the later catalog omits them', async () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'creating' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ ...nativeCatalog, models: [] }) + renderDialog(store, { open: true }) + fireEvent.change(screen.getByRole('textbox', { name: 'Exact model ID' }), { target: { value: 'unlisted-custom' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Reasoning effort' }), { target: { value: 'ultra' } }) + expect(paneContent(store).model).toBe('gpt-6-astra') + store.dispatch(mergePaneContent({ tabId:'tab-1',paneId:'pane-1',updates:{sessionId:'ack-custom',status:'idle'} })) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name:'Use unlisted-custom · ultra' })) + expect(paneContent(store)).toMatchObject({model:'unlisted-custom',effort:'ultra'}) + }) + + it('automatically probes the acknowledged runtime without erasing pre-create search or the saved exact choice', async () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'creating', model: 'saved-custom', effort: 'xhigh' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() + expect(screen.getByRole('status')).toHaveTextContent(/waiting.*runtime/i) + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'provider' } }) + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { sessionId: 'bound-native', status: 'idle' } })) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ sessionId: 'bound-native', cwd: '/repo/project-a' }), + }))) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + expect(search).toHaveValue('provider') + fireEvent.click(screen.getByRole('button', { name: 'Use Provider Current · ultra' })) + expect(paneContent(store)).toMatchObject({ model: 'provider-current', effort: 'ultra' }) + expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { model: 'provider-current', effort: 'ultra' } })) + }) + + it('keeps the exact current model and xhigh visible when catalog fails or omits it', async () => { + const store = createStore() + seedFreshcodexPane(store, { model: 'saved-custom', effort: 'xhigh' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ ...nativeCatalog, ok: false, status: 'unavailable', models: [], error: { code: 'CAPABILITY_PROBE_FAILED', message: 'offline' } }) + renderDialog(store, { open: true }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalled()) + expect(screen.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() + expect(screen.queryByRole('option', { name: /GPT-6 Astra/ })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Use saved-custom.*xhigh/ })) + expect(paneContent(store)).toMatchObject({ model: 'saved-custom', effort: 'xhigh' }) + }) + + it('rejects a delayed old-context catalog after rebind while preserving search and staged selection', async () => { + const store = createStore() + seedFreshcodexPane(store, { model: 'saved-custom', effort: 'xhigh' }) + let release!: (v: typeof nativeCatalog) => void + getFreshAgentModelCapabilitiesSpy.mockReturnValueOnce(new Promise((r) => { release = r })).mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1)) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'provider' } }) + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { sessionId: 'replacement-native' } })) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + release({ ...nativeCatalog, models: [{ ...nativeCatalog.models[0], id: 'stale-model', displayName: 'Stale Provider' }] }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(2)) + expect(screen.getByRole('searchbox')).toHaveValue('provider') + expect(screen.queryByRole('option', { name: 'Stale Provider' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Use Provider Current · ultra' })).toBeEnabled() + }) + + it('shows unavailable after failed ordinary creation without creating a catalog runtime', () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'error', model: 'saved-custom', effort: 'ultra' }) + renderDialog(store, { open: true }) + expect(screen.getByRole('status')).toHaveTextContent(/catalog unavailable/i) + expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() + expect(wsSendSpy).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /Use saved-custom.*ultra/ })).toBeEnabled() + }) it('preserves the current thinking level when confirming an unchanged model', () => { const store = createStore() seedFreshcodexPane(store, { effort: 'low' }) diff --git a/test/unit/client/lib/fresh-agent-create-content.test.ts b/test/unit/client/lib/fresh-agent-create-content.test.ts index 4f9d3eef3..12f4c1a0c 100644 --- a/test/unit/client/lib/fresh-agent-create-content.test.ts +++ b/test/unit/client/lib/fresh-agent-create-content.test.ts @@ -5,6 +5,17 @@ import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' describe('fresh-agent create defaults shared with REST', () => { + it('creates Codex with the exact saved custom model and native effort', () => { + const defaults = createDefaultServerSettings() + const content = buildFreshAgentPaneContent({ + entry: resolveFreshAgentType('freshcodex')!, createRequestId: 'native-choice', + codingCliSettings: defaults.codingCli, + freshAgentSettings: { ...defaults.freshAgent, providers: { ...defaults.freshAgent.providers, + freshcodex: { modelSelection: { kind: 'exact', modelId: 'provider-current' }, effort: 'ultra' }, + } }, + }) + expect(content).toMatchObject({ model: 'provider-current', effort: 'ultra', status: 'creating' }) + }) it.each(fixtures)('$name', ({ sessionType, settings, expected }) => { const defaultServerSettings = createDefaultServerSettings() const entry = resolveFreshAgentType(sessionType)! diff --git a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts index 4ceee7264..948936206 100644 --- a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts +++ b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { api, getFreshAgentModelCapabilities } from '@/lib/api' import type { FreshAgentModelCapabilities } from '@shared/fresh-agent-model-capabilities' import { @@ -16,11 +17,36 @@ import { mergeClaudeModelCapabilities, mergeClaudeSelectorOptions, parseFreshAgentSettingsModelValue, + parseFreshAgentModelCapabilitiesResponse, requiresFreshAgentModelCapabilityValidation, resolveFreshAgentModelSelection, resolveFreshOpencodeCapabilityById, } from '@/lib/fresh-agent-model-capabilities' +it('encodes the bound Codex conversation reference and owner fence on the existing HTTP request', async () => { + const get = vi.spyOn(api, 'get').mockResolvedValueOnce({ ok:false, sessionType:'freshcodex',runtimeProvider:'codex',status:'unavailable',models:[],error:{code:'RUNTIME_PENDING',message:'pending'} }) + try { + await getFreshAgentModelCapabilities('freshcodex', { codexContext: { + sessionId: 'pane alias', sessionRef: { provider: 'codex', sessionId: 'native/current' }, cwd: '/project A', observedEpoch: 7, observedGeneration: 3, + } } as Parameters[1]) + const url = new URL(get.mock.calls[0][0], 'http://fixture') + expect(url.searchParams.get('sessionId')).toBe('pane alias') + expect(JSON.parse(url.searchParams.get('sessionRef')!)).toEqual({ provider:'codex',sessionId:'native/current' }) + expect(url.searchParams.get('cwd')).toBe('/project A') + expect(url.searchParams.get('observedEpoch')).toBe('7') + expect(url.searchParams.get('observedGeneration')).toBe('3') + } finally { get.mockRestore() } +}) + +it('accepts native Codex defaults and input modalities without changing opaque effort values', () => { + const response = parseFreshAgentModelCapabilitiesResponse({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex', + supportsEffort: true, supportedEffortLevels: ['ultra'], defaultEffort: 'ultra', inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], + }) + expect(response.models[0]).toMatchObject({ defaultEffort: 'ultra', inputModalities: ['text', 'image'] }) +}) + const capabilities = { sessionType: 'freshclaude', runtimeProvider: 'claude', diff --git a/test/unit/shared/fresh-agent-models.test.ts b/test/unit/shared/fresh-agent-models.test.ts index e075e9a79..4f128b7ff 100644 --- a/test/unit/shared/fresh-agent-models.test.ts +++ b/test/unit/shared/fresh-agent-models.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { FreshAgentSettingsSchema } from '@shared/fresh-agent-contract' import { FRESHCODEX_DEFAULT_MODEL, @@ -10,6 +11,16 @@ import { resolveFreshAgentModelOption, } from '@shared/fresh-agent-models' +describe('Codex exact native model choices', () => { + it('keeps custom and saved models and every explicit native effort for native validation', () => { + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'provider-current')).toBe('provider-current') + for (const effort of ['ultra', 'none', 'xhigh', 'max']) { + expect(normalizeFreshAgentEffort('freshcodex', 'codex', 'provider-current', effort)).toBe(effort) + expect(FreshAgentSettingsSchema.parse({ model: 'provider-current', effort }).effort).toBe(effort) + } + }) +}) + describe('fresh-agent-models freshopencode static menu', () => { it('has no static fallback entries for freshopencode (models come from the live catalog)', () => { expect(FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshopencode).toEqual([]) From 8cd7b8cc60b3523efe8a28e7cf55eea0f9377320 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:10:10 -0700 Subject: [PATCH 37/92] feat(codex): use native model and reasoning capabilities --- .../freshell-agent-runtime/src/host_actor.rs | 37 ++ .../src/host_actor_tests.rs | 50 +++ crates/freshell-codex/src/app_server.rs | 12 + crates/freshell-codex/src/catalog.rs | 404 ++++++++++++++++++ crates/freshell-codex/src/lib.rs | 14 +- crates/freshell-codex/src/model.rs | 385 ++--------------- .../freshell-codex/tests/app_server_drive.rs | 33 ++ crates/freshell-freshagent/src/codex.rs | 371 ++++++++++++++-- crates/freshell-freshagent/src/lib.rs | 12 +- .../src/model_capabilities.rs | 295 +++++++++---- .../src/model_capabilities_tests.rs | 158 +++---- crates/freshell-freshagent/src/rest_lane.rs | 9 + crates/freshell-opencode/src/catalog.rs | 12 + crates/freshell-runtime-client/src/lib.rs | 124 ++++++ crates/freshell-runtime-protocol/src/lib.rs | 17 + .../src/fresh_agent_proxy_rest.rs | 134 ++++++ .../src/fresh_agent_proxy_tests.rs | 54 ++- crates/freshell-server/src/main.rs | 3 + crates/freshell-server/src/opencode_limits.rs | 2 + .../tests/codex_model_catalog.rs | 328 +++++++++++--- crates/freshell-session-host/src/main.rs | 96 +++++ .../src/providers/fresh_agent.rs | 96 +++++ .../src/fresh_agent_configure_tests.rs | 149 ++++++- crates/freshell-supervisor/src/service.rs | 74 ++++ scripts/sandbox-test.sh | 1 + shared/fresh-agent-contract.ts | 2 +- shared/fresh-agent-model-capabilities.ts | 11 + shared/fresh-agent-models.ts | 40 +- .../fresh-agent/FreshAgentModelDialog.tsx | 100 ++++- src/lib/api.ts | 31 +- src/lib/fresh-agent-model-capabilities.ts | 25 +- src/lib/fresh-agent-registry.ts | 3 +- .../fresh-agent-model-dialog-parity.spec.ts | 8 +- .../freshagent-settings-resume-rust.spec.ts | 13 +- .../codex-app-server/fake-app-server.mjs | 4 +- .../providers/codex-cli-contract.mjs | 75 +++- .../FreshAgentModelDialog.test.tsx | 48 ++- .../FreshAgentSettingsButton.test.tsx | 7 +- .../fresh-agent/FreshAgentView.test.tsx | 33 +- .../lib/fresh-agent-create-content.test.ts | 12 + .../fresh-agent-model-capabilities.test.ts | 28 -- test/unit/shared/fresh-agent-models.test.ts | 19 +- test/unit/shared/fresh-agent-registry.test.ts | 6 +- 43 files changed, 2515 insertions(+), 820 deletions(-) create mode 100644 crates/freshell-codex/src/catalog.rs diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index fc989c856..9647ebb53 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -308,6 +308,9 @@ pub trait FreshAgentTransport: Send + Sync { async fn snapshot(&self) -> Result { Err("provider does not expose a hosted snapshot".into()) } + async fn model_catalog(&self, _cwd: Option<&str>) -> Result { + Err("provider does not expose a model catalog".into()) + } async fn read_child(&self, _child_thread_id: &str) -> Result { Err("provider does not expose child observation".into()) @@ -1137,6 +1140,40 @@ impl FreshAgentHostActor { Ok(snapshot) } + pub async fn model_catalog(&self, cwd: Option<&str>) -> Result { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.is_none() + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + let value = self + .transport + .model_catalog(cwd) + .await + .map_err(ActorError::Transport)?; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || before.provider != after.provider + || before.cwd != after.cwd + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + if serde_json::to_vec(&value) + .map_err(|e| ActorError::Transport(e.to_string()))? + .len() + > freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES - 4096 + { + return Err(ActorError::Transport( + "model catalog exceeds control reply frame limit".into(), + )); + } + Ok(value) + } + pub async fn read_child(&self, child_thread_id: &str) -> Result { let before = self.profile().await; if before.provider != FreshProvider::Codex diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 7ee9f1e92..ffd127bca 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -138,6 +138,56 @@ impl FreshAgentTransport for SnapshotTransport { async fn read_child(&self, _: &str) -> Result { self.snapshot().await } + async fn model_catalog(&self, _: Option<&str>) -> Result { + self.snapshot().await + } +} + +#[tokio::test] +async fn model_catalog_preserves_actor_state_and_fences_liveness_size_and_replaced_native_source() { + for scenario in ["live", "exited", "oversized", "replaced"] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!([{"id":"provider-current","displayName":if scenario=="oversized" {"x".repeat(freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)} else {"Provider Current".into()}}]), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: scenario == "exited", + initial_native: Some("catalog-native".into()), + registered: None, + pause: if scenario == "replaced" { + Some((entered.clone(), release.clone())) + } else { + None + }, + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "catalog-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let read = tokio::spawn({ + let actor = actor.clone(); + async move { actor.model_catalog(Some("/workspace")).await } + }); + if scenario == "replaced" { + entered.notified().await; + // Fixture-only replacement while the read is held; this does not + // grant native identity mutation or claim crash recovery coverage. + actor.state.lock().await.profile.native_session_id = Some("replacement-native".into()); + release.notify_one(); + } + let result = read.await.unwrap(); + assert_eq!(result.is_ok(), scenario == "live", "{scenario}: {result:?}"); + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before, + "a catalog is not a durable command or writer transition" + ); + } } #[tokio::test] diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index 50a3a3bc3..54080da85 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -250,6 +250,18 @@ impl Drop for CodexAppServerClient { } impl CodexAppServerClient { + pub async fn is_initialized(&self) -> bool { + self.init.lock().await.is_some() + } + + /// Read visible model metadata from this connection's own provider context. + pub async fn list_models(&self, cursor: Option<&str>) -> Result { + let mut params = json!({"includeHidden":false,"limit":100}); + if let Some(cursor) = cursor { + params["cursor"] = json!(cursor); + } + self.request("model/list", params).await + } /// Wire a client to a transport with the default request timeout and the /// [`SNAPSHOT_READ_TIMEOUT_MS`] read budget. Returns the client plus the /// [`CodexNotification`] stream the background consumer feeds (the adapter's diff --git a/crates/freshell-codex/src/catalog.rs b/crates/freshell-codex/src/catalog.rs new file mode 100644 index 000000000..6ce6a7682 --- /dev/null +++ b/crates/freshell-codex/src/catalog.rs @@ -0,0 +1,404 @@ +//! Catalog reads use an existing initialized app-server. They never create a thread. +use crate::{CodexAppServerClient, CodexAppServerError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::HashSet, time::Duration}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexModel { + pub id: String, + pub display_name: String, + pub supported_effort_levels: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, +} + +fn invalid(detail: &str) -> CodexAppServerError { + CodexAppServerError::InvalidResponse { + method: "model/list".into(), + detail: detail.into(), + } +} + +fn opaque(value: &Value) -> Option<&str> { + value.as_str().filter(|s| !s.trim().is_empty()) +} + +/// `id` is catalog metadata; `model` is the actual runnable provider model ID. +pub fn parse_page(page: &Value) -> Result<(Vec, Option), CodexAppServerError> { + let data = page + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| invalid("missing model data"))?; + let mut models = Vec::with_capacity(data.len()); + for row in data { + let id = row + .get("model") + .and_then(opaque) + .ok_or_else(|| invalid("missing runnable model ID"))?; + let display = row + .get("displayName") + .and_then(opaque) + .ok_or_else(|| invalid("missing model display name"))?; + let efforts = row + .get("supportedReasoningEfforts") + .and_then(Value::as_array) + .ok_or_else(|| invalid("invalid reasoning efforts"))?; + let mut levels = Vec::new(); + for effort in efforts { + let effort = effort + .get("reasoningEffort") + .and_then(opaque) + .ok_or_else(|| invalid("invalid reasoning effort"))?; + if !levels.iter().any(|level| level == effort) { + levels.push(effort.to_string()); + } + } + let default_effort = match row.get("defaultReasoningEffort") { + None | Some(Value::Null) => None, + Some(value) => Some( + opaque(value) + .ok_or_else(|| invalid("invalid default effort"))? + .to_string(), + ), + }; + let input_modalities = match row.get("inputModalities") { + None | Some(Value::Null) => None, + Some(value) => Some( + value + .as_array() + .ok_or_else(|| invalid("invalid input modalities"))? + .iter() + .map(|value| { + opaque(value) + .map(str::to_string) + .ok_or_else(|| invalid("invalid input modality")) + }) + .collect::, _>>()?, + ), + }; + models.push(CodexModel { + id: id.into(), + display_name: display.into(), + supported_effort_levels: levels, + default_effort, + input_modalities, + }); + } + let cursor = match page.get("nextCursor") { + None | Some(Value::Null) => None, + Some(value) => Some( + opaque(value) + .ok_or_else(|| invalid("invalid next cursor"))? + .to_string(), + ), + }; + Ok((models, cursor)) +} + +pub async fn read_models( + client: &CodexAppServerClient, +) -> Result, CodexAppServerError> { + tokio::time::timeout(Duration::from_secs(20), async { + let mut cursor = None; + let mut cursors = HashSet::new(); + let mut ids = HashSet::new(); + let mut models = Vec::new(); + loop { + let page = client.list_models(cursor.as_deref()).await?; + let (rows, next) = parse_page(&page)?; + for row in rows { + if ids.insert(row.id.clone()) { + models.push(row); + } + } + match next { + None => return Ok(models), + Some(next) if cursors.insert(next.clone()) => cursor = Some(next), + Some(_) => return Err(invalid("model pagination cursor repeated")), + } + } + }) + .await + .map_err(|_| CodexAppServerError::Timeout { + method: "model/list".into(), + timeout_ms: 20_000, + })? +} + +pub fn resolve_legacy_effort(model: &str, effort: &str, catalog: &[CodexModel]) -> String { + if effort == "max" + && catalog.iter().any(|row| { + row.id == model + && row.supported_effort_levels.iter().any(|s| s == "xhigh") + && !row.supported_effort_levels.iter().any(|s| s == "max") + }) + { + "xhigh".into() + } else { + effort.into() + } +} + +#[cfg(all(test, feature = "native-policy-qualification"))] +mod native_tests { + use super::*; + use crate::{ + app_server::{BoxFuture, WsTransport}, + CodexNotification, HistoryMode, StartThreadParams, StartTurnParams, ThreadForkParams, + }; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + /// The existing owned release-contract runner supplies this live endpoint. + /// This feature is the established explicit native qualification lane. + #[tokio::test] + async fn native_catalog_and_opaque_effort_use_initialized_client() { + struct Recorded { + transport: crate::transport::TungsteniteTransport, + frames: Arc>>, + } + impl WsTransport for Recorded { + fn send(&self, text: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.frames.lock().unwrap().push(json!({"direction":"controller","frame":serde_json::from_str::(&text).unwrap()})); + self.transport.send(text).await + }) + } + fn recv(&self) -> BoxFuture<'_, Option> { + Box::pin(async move { + let text = self.transport.recv().await?; + self.frames.lock().unwrap().push(json!({"direction":"native","frame":serde_json::from_str::(&text).unwrap()})); + Some(text) + }) + } + fn close(&self) -> BoxFuture<'_, ()> { + self.transport.close() + } + } + let endpoint = std::env::var("FRESHELL_CODEX_CATALOG_PROBE_URL") + .expect("owned native endpoint required"); + assert!(endpoint.starts_with("ws://127.0.0.1:")); + let receipt = + std::env::var("FRESHELL_CODEX_CATALOG_PROBE_RECEIPT").expect("owned receipt required"); + let frames = Arc::new(Mutex::new(Vec::new())); + let transport = Recorded { + transport: crate::transport::TungsteniteTransport::connect(&endpoint) + .await + .unwrap(), + frames: frames.clone(), + }; + let (client, mut notifications) = CodexAppServerClient::connect(Arc::new(transport)); + client.initialize().await.unwrap(); + let models = read_models(&client).await.unwrap(); + assert!(!models.is_empty()); + assert!(models + .iter() + .all(|m| !m.id.is_empty() && !m.display_name.is_empty())); + assert!( + !models.iter().any(|model| model.id == "provider-current"), + "qualification requires an exact model omitted by discovery" + ); + let params = StartThreadParams { + model: Some(crate::normalize_freshcodex_model(Some("provider-current"))), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + history_mode: Some(HistoryMode::Paginated), + ..Default::default() + }; + let parent = client.start_thread(params.clone()).await.unwrap().thread_id; + async fn send_exact( + client: &CodexAppServerClient, + notifications: &mut tokio::sync::mpsc::UnboundedReceiver, + thread: &str, + model: &str, + effort: &str, + ) -> String { + let turn=client.start_turn(StartTurnParams {thread_id:thread.into(),input:vec![json!({"type":"text","text":"Controlled exact model and effort qualification"})],model:Some(crate::normalize_freshcodex_model(Some(model))),effort:crate::to_codex_reasoning_effort(Some(effort)).unwrap(),approval_policy:Some(json!("never")),..Default::default()}).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + while let Some(notification) = notifications.recv().await { + if let CodexNotification::TurnCompleted(event) = notification { + if event.thread_id == thread + && event.turn_id.as_deref() == Some(&turn.turn_id) + { + assert_eq!(event.params["turn"]["status"], "completed"); + return; + } + } + } + panic!("native notifications closed before completion") + }) + .await + .unwrap(); + turn.turn_id + } + let mut completed = vec![ + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ]; + // A second turn checks that the configured native default cannot + // overwrite the exact explicit pair. Adapter configure itself is + // exercised through the full registered-state lifecycle test. + completed.push( + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ); + assert_eq!( + client + .resume_thread(&parent, params) + .await + .unwrap() + .thread_id, + parent + ); + completed.push( + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ); + let fork = client + .fork_thread(ThreadForkParams { + thread_id: parent.clone(), + model: Some("provider-current".into()), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + ..Default::default() + }) + .await + .unwrap(); + let child = fork["thread"]["id"].as_str().unwrap(); + completed.push( + send_exact( + &client, + &mut notifications, + child, + "provider-current", + "ultra", + ) + .await, + ); + // 0.164 accepts this opaque value even though discovery omits it. + // The owned release diagnostic proves its exact native/HTTP forwarding. + let future_turn = send_exact( + &client, + &mut notifications, + child, + "provider-current", + "future-unadvertised-native-effort", + ) + .await; + let advertised_model = models + .iter() + .find(|model| { + ["ultra", "xhigh"].iter().all(|effort| { + model + .supported_effort_levels + .iter() + .any(|supported| supported == effort) + }) + }) + .expect("installed catalog advertises ultra and xhigh"); + let advertised_thread = client + .start_thread(StartThreadParams { + model: Some(advertised_model.id.clone()), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + history_mode: Some(HistoryMode::Paginated), + ..Default::default() + }) + .await + .unwrap() + .thread_id; + let mut advertised_turns = Vec::new(); + for effort in ["ultra", "xhigh"] { + let turn_id = send_exact( + &client, + &mut notifications, + &advertised_thread, + &advertised_model.id, + effort, + ) + .await; + advertised_turns.push(json!({"turnId":turn_id,"effort":effort})); + } + let advertised_selection = + json!({"model":advertised_model,"threadId":advertised_thread,"turns":advertised_turns}); + client.close().await; + let frames = frames.lock().unwrap(); + let requests = frames + .iter() + .filter(|f| f["direction"] == "controller" && f["frame"].get("id").is_some()) + .map(|f| &f["frame"]) + .collect::>(); + assert_eq!( + requests + .iter() + .filter(|f| f["method"] == "initialize") + .count(), + 1 + ); + let first_work = requests + .iter() + .position(|frame| frame["method"] == "thread/start") + .unwrap(); + assert!( + requests[..first_work] + .iter() + .all(|frame| frame["method"] == "initialize" || frame["method"] == "model/list"), + "catalog discovery must create no native conversation" + ); + for method in ["thread/start", "thread/resume", "thread/fork"] { + let request = requests + .iter() + .find(|frame| frame["method"] == method) + .unwrap(); + assert_eq!(request["params"]["model"], "provider-current"); + } + let turns = requests + .iter() + .filter(|frame| frame["method"] == "turn/start") + .collect::>(); + assert_eq!(turns.len(), 7); + for request in &turns[..4] { + assert_eq!(request["params"]["model"], "provider-current"); + assert_eq!(request["params"]["effort"], "ultra"); + } + assert_eq!( + turns[4]["params"]["effort"], + "future-unadvertised-native-effort" + ); + for (request, effort) in turns[5..].iter().zip(["ultra", "xhigh"]) { + assert_eq!(request["params"]["model"], advertised_model.id); + assert_eq!(request["params"]["effort"], effort); + assert_eq!(request["params"]["approvalPolicy"], "never"); + } + std::fs::write( + receipt, + serde_json::to_vec( + &json!({"models":models,"frames":*frames,"advertisedSelection":advertised_selection,"nativeAcceptedFutureEffort":true,"futureEffortCompletedTurn":future_turn,"nativeExactLifecycleAccepted":true,"completedTurns":completed}), + ) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/freshell-codex/src/lib.rs b/crates/freshell-codex/src/lib.rs index 0abcd38c9..e420d4d2a 100644 --- a/crates/freshell-codex/src/lib.rs +++ b/crates/freshell-codex/src/lib.rs @@ -28,16 +28,16 @@ //! production wiring behind the default-off `real-transport` feature (verified to compile; //! wired live in the next step, T2-over-rust). //! -//! ## DEV-0003 (REJECTED — `port/oracle/DEVIATIONS.md`) +//! ## Native selections //! -//! Codex reasoning-effort `none`/`minimal` are VALID codex efforts (`protocol.ts:26`) and are -//! **forwarded VERBATIM** on `turn/start` (`adapter.ts:130-131`). The proposed "clamp -//! none/minimal" fix was rejected with ZERO differ tolerance: any old-vs-new divergence in -//! codex effort handling is a port defect. The seam is [`model::to_codex_reasoning_effort`] -//! and [`app_server::CodexAppServerClient::start_turn`], both pinned by tests here and in -//! `tests/`. +//! The installed initialized client supplies the model catalog. Explicit model +//! and effort IDs remain opaque native values, including none/minimal. Native +//! validation decides whether custom values are usable; catalog omission never +//! substitutes a default. A legacy max alias applies only when the selected +//! installed model advertises xhigh without a distinct max. pub mod app_server; +pub mod catalog; pub mod durability; pub mod events; mod json_scan; diff --git a/crates/freshell-codex/src/model.rs b/crates/freshell-codex/src/model.rs index 709924d72..3571c4a4d 100644 --- a/crates/freshell-codex/src/model.rs +++ b/crates/freshell-codex/src/model.rs @@ -1,380 +1,85 @@ -//! codex model / effort **normalization** — the codex slice of -//! `shared/fresh-agent-models.ts`, plus the wire-level effort mapping -//! `toCodexReasoningEffort` (`adapters/codex/adapter.ts:127-134`). -//! -//! Every freshcodex turn normalizes model+effort on the way in -//! (`adapter.ts:237-243`, and again in `send` `:961-963`). This crate is codex-only, so -//! the session type is always `freshcodex`; the ported functions specialise to that menu -//! but keep the reference's exact clamp semantics, in the reference's two stages: -//! -//! 1. **menu normalization** ([`normalize_freshcodex_model`] + [`normalize_freshcodex_effort`], -//! `fresh-agent-models.ts:101-152`): clamp the model to the freshcodex allowlist -//! (fallback `gpt-6-astra`); rewrite codex `xhigh → max`; then clamp the effort to the -//! (normalized) model's `thinkingEfforts`, else its `defaultEffort`, else the last menu -//! entry. -//! 2. **wire mapping** ([`to_codex_reasoning_effort`], `adapter.ts:127-134`): `max`/`xhigh` -//! → `xhigh`; **`none`/`minimal`/`low`/`medium`/`high` PASS THROUGH VERBATIM**; anything -//! else is an error. -//! -//! ## DEV-0003 (REJECTED — `port/oracle/DEVIATIONS.md`) -//! -//! `CodexReasoningEffortSchema = z.enum(['none','minimal','low','medium','high','xhigh'])` -//! (`protocol.ts:26`) models `none`/`minimal` as VALID codex efforts, governing BOTH the -//! outbound `turn/start.effort` (`protocol.ts:312`) and the inbound `reasoningEffort` echo -//! (`protocol.ts:233`). The proposed "clamp none/minimal" fix was **rejected**: the port -//! MUST reproduce the original's **verbatim forwarding** and must NOT clamp. The differ -//! grants NO tolerance — any old-vs-new divergence here is a port defect. [`to_codex_reasoning_effort`] -//! is the function that must not regress; [`FRESHCODEX_EFFORTS_VERBATIM`] enumerates the -//! five values that pass through unchanged. +//! Codex selections are opaque native values. Static defaults apply only to an +//! omitted model; the installed runtime validates explicit model and effort IDs. -/// `FRESHCODEX_DEFAULT_MODEL` (`fresh-agent-models.ts:15`). pub const FRESHCODEX_DEFAULT_MODEL: &str = "gpt-6-astra"; -/// `FRESHCODEX_DEFAULT_EFFORT` (`fresh-agent-models.ts:16`). pub const FRESHCODEX_DEFAULT_EFFORT: &str = "max"; - -/// The cheapest GPT model REACHABLE through freshcodex — the T2 `codex-gptmini.json` -/// baseline model. `normalizeFreshcodexModel` clamps to the freshcodex allowlist, so this -/// is the only non-flagship model in BOTH the allowlist and the real codex catalog -/// (`codex-gptmini.json` provenance / `modelReachabilityNote`). pub const CHEAPEST_T2_MODEL: &str = "gpt-5.3-codex-spark"; - -/// The five reasoning-effort values codex forwards VERBATIM on the wire (`adapter.ts:130`). -/// Pinned by DEV-0003: the port must not clamp/map any of these. `max`/`xhigh` are handled -/// separately (both map to `xhigh`, `adapter.ts:129`). pub const FRESHCODEX_EFFORTS_VERBATIM: &[&str] = &["none", "minimal", "low", "medium", "high"]; -/// One freshcodex model menu entry (`fresh-agent-models.ts:30-61`). -struct ModelOption { - value: &'static str, - thinking_efforts: &'static [&'static str], - default_effort: &'static str, -} - -/// The freshcodex menu (`FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex`, -/// `fresh-agent-models.ts:30-61`). -const FRESHCODEX_MODEL_OPTIONS: &[ModelOption] = &[ - ModelOption { - value: "gpt-6-astra", - thinking_efforts: &["low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-sol", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-terra", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-luna", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.3-codex-spark", - thinking_efforts: &["none", "minimal", "low", "medium", "high", "max"], - default_effort: "max", - }, -]; - -fn find_option(model: &str) -> Option<&'static ModelOption> { - FRESHCODEX_MODEL_OPTIONS.iter().find(|o| o.value == model) -} - -/// `normalizeFreshcodexModel(model)` (`fresh-agent-models.ts:101-104`): keep the model iff -/// it is in the freshcodex allowlist, else fall back to `gpt-6-astra`. pub fn normalize_freshcodex_model(model: Option<&str>) -> String { - match model.and_then(find_option) { - Some(option) => option.value.to_string(), - None => FRESHCODEX_DEFAULT_MODEL.to_string(), - } -} - -/// `resolveFreshAgentModelOption(freshcodex, model)` (`fresh-agent-models.ts:93-99`): the -/// matching menu entry, else the default (first) entry. Since [`normalize_freshcodex_model`] -/// always returns an allowlisted value this resolves precisely, but the fallback mirrors -/// the reference for defensiveness. -fn resolve_option(model: &str) -> Option<&'static ModelOption> { - find_option(model).or_else(|| FRESHCODEX_MODEL_OPTIONS.first()) + model + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(FRESHCODEX_DEFAULT_MODEL) + .to_string() } -/// `getFreshAgentThinkingOptions(freshcodex, 'codex', model)` (`fresh-agent-models.ts:121-129`): -/// the resolved (normalized) model's `thinkingEfforts`. -fn thinking_options(model: Option<&str>) -> &'static [&'static str] { - let normalized = normalize_freshcodex_model(model); - resolve_option(&normalized) - .map(|o| o.thinking_efforts) - .unwrap_or(&[]) +/// Omission leaves the native/configured effort default in control. A catalog +/// can describe a menu, but cannot prove a custom value invalid. +pub fn normalize_freshcodex_effort(_model: Option<&str>, effort: Option<&str>) -> Option { + effort + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) } -/// `normalizeFreshAgentEffort(freshcodex, 'codex', model, effort)` — the MENU-level -/// normalization (`fresh-agent-models.ts:131-152`). Codex `xhigh → max` (`:142`), then -/// clamp to the model's `thinkingEfforts`, else the model's `defaultEffort` (if on the -/// menu), else the last menu entry. -/// -/// This is stage 1; the wire value is then produced by [`to_codex_reasoning_effort`] -/// (stage 2, which maps the resulting `max` back to `xhigh`). Note `none`/`minimal` survive -/// stage 1 unchanged **because freshcodex declares them in the model's `thinkingEfforts`** — -/// consistent with the protocol treating them as valid (DEV-0003). -pub fn normalize_freshcodex_effort(model: Option<&str>, effort: Option<&str>) -> Option { - let options = thinking_options(model); - - // codex `xhigh → max` (`:142` guards on `provider === 'codex'`). - let normalized_effort = match effort { - Some("xhigh") => Some("max"), - other => other, - }; - if let Some(e) = normalized_effort { - if options.contains(&e) { - return Some(e.to_string()); - } - } - - let normalized_model = normalize_freshcodex_model(model); - if let Some(opt) = resolve_option(&normalized_model) { - if options.contains(&opt.default_effort) { - return Some(opt.default_effort.to_string()); - } - } - options.last().map(|s| s.to_string()) -} - -/// Raised when a reasoning-effort value cannot be mapped to the codex wire vocabulary — -/// the reference's `throw new Error('Freshcodex does not support reasoning effort "…"')` -/// (`adapter.ts:133`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodexEffortError(pub String); - impl std::fmt::Display for CodexEffortError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Freshcodex does not support reasoning effort \"{}\". Choose none, minimal, low, medium, high, or max.", - self.0 - ) + write!(f, "Codex reasoning effort must be a nonempty native value") } } - impl std::error::Error for CodexEffortError {} -/// `toCodexReasoningEffort(value)` — the WIRE-level mapping (`adapter.ts:127-134`). -/// -/// - `None` → `None` (omit `effort` from `turn/start`). -/// - `max` / `xhigh` → `xhigh` (`:129`). -/// - **`none` / `minimal` / `low` / `medium` / `high` → VERBATIM (`:130-131`).** ← DEV-0003. -/// - anything else → [`CodexEffortError`] (`:133`). -/// -/// This function is the DEV-0003 seam: `none`/`minimal` MUST pass through unchanged. The -/// port must never clamp or remap them — the T2 codex differ grants no tolerance here. +/// The wire carries every nonempty value verbatim. The legacy max alias is +/// resolved separately, only against capabilities of the installed selected model. pub fn to_codex_reasoning_effort(value: Option<&str>) -> Result, CodexEffortError> { match value { None => Ok(None), - Some("max") | Some("xhigh") => Ok(Some("xhigh".to_string())), - Some(v) if FRESHCODEX_EFFORTS_VERBATIM.contains(&v) => Ok(Some(v.to_string())), - Some(other) => Err(CodexEffortError(other.to_string())), + Some(v) if !v.trim().is_empty() => Ok(Some(v.to_string())), + Some(v) => Err(CodexEffortError(v.to_string())), } } #[cfg(test)] mod tests { use super::*; - #[test] fn explicit_native_model_choices_are_never_replaced_by_static_defaults() { - assert_eq!(normalize_freshcodex_model(Some("provider-current")), "provider-current"); - for effort in ["ultra", "none", "xhigh", "max"] { - assert_eq!(normalize_freshcodex_effort(Some("provider-current"), Some(effort)).as_deref(), Some(effort)); - assert_eq!(to_codex_reasoning_effort(Some(effort)).unwrap().as_deref(), Some(effort)); - } - } - - // ── DEV-0003: none/minimal are forwarded VERBATIM (no clamp) ─────────────────────── - - #[test] - fn dev_0003_none_and_minimal_forward_verbatim() { - // The heart of DEV-0003: these five values must survive the wire mapping unchanged. - assert_eq!( - to_codex_reasoning_effort(Some("none")), - Ok(Some("none".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("minimal")), - Ok(Some("minimal".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("low")), - Ok(Some("low".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("medium")), - Ok(Some("medium".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("high")), - Ok(Some("high".to_string())) - ); - } - - #[test] - fn dev_0003_full_pipeline_keeps_none_minimal_verbatim_for_spark() { - // The exact T2 model. Menu-normalize (`none`/`minimal` are on spark's menu, so they - // survive) THEN wire-map — still verbatim end-to-end. This is what the T2 differ grades. - let spark = Some(CHEAPEST_T2_MODEL); - for effort in ["none", "minimal", "low", "medium", "high"] { - let menu = normalize_freshcodex_effort(spark, Some(effort)); - assert_eq!( - menu.as_deref(), - Some(effort), - "menu stage must keep {effort} verbatim" - ); - let wire = to_codex_reasoning_effort(menu.as_deref()).expect("wire map"); - assert_eq!( - wire.as_deref(), - Some(effort), - "wire stage must keep {effort} verbatim" - ); + for model in [ + "provider-current", + "gpt-6.1-sol", + CHEAPEST_T2_MODEL, + "gpt-5.4-mini", + ] { + assert_eq!(normalize_freshcodex_model(Some(model)), model); + for effort in [ + "ultra", "none", "minimal", "low", "medium", "high", "xhigh", "max", + ] { + assert_eq!( + normalize_freshcodex_effort(Some(model), Some(effort)).as_deref(), + Some(effort) + ); + assert_eq!( + to_codex_reasoning_effort(Some(effort)).unwrap().as_deref(), + Some(effort) + ); + } } } - #[test] - fn max_and_xhigh_map_to_xhigh_on_the_wire() { - // `max`/`xhigh` → `xhigh` (`adapter.ts:129`) — NOT a DEV-0003 value; correctly mapped. - assert_eq!( - to_codex_reasoning_effort(Some("max")), - Ok(Some("xhigh".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("xhigh")), - Ok(Some("xhigh".to_string())) - ); - } - - #[test] - fn undefined_effort_stays_undefined() { - assert_eq!(to_codex_reasoning_effort(None), Ok(None)); - } - - #[test] - fn unsupported_effort_errors_like_the_reference() { - assert_eq!( - to_codex_reasoning_effort(Some("bogus")), - Err(CodexEffortError("bogus".to_string())) - ); - assert!(to_codex_reasoning_effort(Some("ultra")).is_err()); - } - - // ── model clamp (normalizeFreshcodexModel) ───────────────────────────────────────── - - #[test] - fn model_clamps_to_freshcodex_allowlist() { - // Allowlisted models pass through. - assert_eq!( - normalize_freshcodex_model(Some("gpt-6-astra")), - "gpt-6-astra" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-sol")), - "gpt-5.6-sol" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-terra")), - "gpt-5.6-terra" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-luna")), - "gpt-5.6-luna" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.3-codex-spark")), - "gpt-5.3-codex-spark" - ); - // Retired/unknown models fall back to the current freshcodex default. - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.4-mini")), - FRESHCODEX_DEFAULT_MODEL - ); - // Unknown / missing → the freshcodex default. - assert_eq!( - normalize_freshcodex_model(Some("random")), - FRESHCODEX_DEFAULT_MODEL - ); + fn omitted_model_uses_default_and_omitted_effort_uses_native_default() { assert_eq!(normalize_freshcodex_model(None), FRESHCODEX_DEFAULT_MODEL); - } - - // ── effort menu normalization (normalizeFreshAgentEffort, codex slice) ────────────── - - #[test] - fn effort_menu_normalization_matches_reference() { - let spark = Some("gpt-5.3-codex-spark"); - // On-menu efforts kept. - assert_eq!( - normalize_freshcodex_effort(spark, Some("low")).as_deref(), - Some("low") - ); - assert_eq!( - normalize_freshcodex_effort(spark, Some("none")).as_deref(), - Some("none") - ); assert_eq!( - normalize_freshcodex_effort(spark, Some("max")).as_deref(), - Some("max") - ); - // codex xhigh → max at the menu stage (`:142`), and `max` is on spark's menu. - assert_eq!( - normalize_freshcodex_effort(spark, Some("xhigh")).as_deref(), - Some("max") - ); - // Absent effort → the model's defaultEffort (`max` for spark). - assert_eq!( - normalize_freshcodex_effort(spark, None).as_deref(), - Some("max") - ); - // Off-menu effort → clamped to the default (`max`). - assert_eq!( - normalize_freshcodex_effort(spark, Some("bogus")).as_deref(), - Some("max") - ); - } - - #[test] - fn effort_menu_for_gpt_56_models_keeps_current_reasoning_levels() { - let terra = Some("gpt-5.6-terra"); - assert_eq!( - normalize_freshcodex_effort(terra, Some("xhigh")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("max")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("none")).as_deref(), - Some("none") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("minimal")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, None).as_deref(), - Some("max") - ); - } - - #[test] - fn effort_for_unknown_model_uses_default_model_menu() { - // An unknown model normalizes to gpt-6-astra, whose menu includes `max`. - let unknown = Some("mystery-model"); - assert_eq!( - normalize_freshcodex_effort(unknown, Some("medium")).as_deref(), - Some("medium") + normalize_freshcodex_model(Some(" ")), + FRESHCODEX_DEFAULT_MODEL ); assert_eq!( - normalize_freshcodex_effort(unknown, None).as_deref(), - Some("max") + normalize_freshcodex_effort(Some("provider-current"), None), + None ); + assert_eq!(to_codex_reasoning_effort(None), Ok(None)); + assert!(to_codex_reasoning_effort(Some(" ")).is_err()); } } diff --git a/crates/freshell-codex/tests/app_server_drive.rs b/crates/freshell-codex/tests/app_server_drive.rs index 2adf584d6..dab60bcef 100644 --- a/crates/freshell-codex/tests/app_server_drive.rs +++ b/crates/freshell-codex/tests/app_server_drive.rs @@ -32,6 +32,39 @@ async fn drive_handshake(peer: &freshell_codex::ChannelPeer) { assert_eq!(note, "initialized"); } +#[tokio::test] +async fn model_catalog_reads_all_visible_pages_on_the_initialized_client() { + let (transport, peer) = new_channel_transport(); + let (client, _notifs) = CodexAppServerClient::connect(transport); + let driver = tokio::spawn(async move { freshell_codex::catalog::read_models(&client).await }); + drive_handshake(&peer).await; + for (cursor, next, model, effort) in [ + (None, Some("opaque-next"), "gpt-6.1-sol", "xhigh"), + (Some("opaque-next"), None, "provider-current", "ultra"), + ] { + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "model/list"); + assert_eq!(params["includeHidden"], false); + assert_eq!( + params.get("cursor").and_then(serde_json::Value::as_str), + cursor + ); + peer.respond(&id,json!({"data":[{"id":"metadata-record","model":model,"displayName":format!("Native {model}"),"supportedReasoningEfforts":[{"reasoningEffort":effort}],"defaultReasoningEffort":effort,"inputModalities":["text","image"]}],"nextCursor":next})); + } + let rows = driver.await.unwrap().unwrap(); + drop(peer); + assert_eq!( + rows.iter().map(|r| r.id.as_str()).collect::>(), + vec!["gpt-6.1-sol", "provider-current"] + ); + assert_eq!(rows[1].supported_effort_levels, vec!["ultra"]); + assert_eq!(rows[1].default_effort.as_deref(), Some("ultra")); + assert_eq!( + rows[0].input_modalities, + Some(vec!["text".into(), "image".into()]) + ); +} + #[tokio::test] async fn full_drive_completed_turn_emits_the_positive_edge_with_verbatim_effort() { let (transport, peer) = new_channel_transport(); diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 6a9bac4dd..0e4800431 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1976,6 +1976,93 @@ impl FreshCodexState { ) } + async fn catalog_binding( + &self, + context: &crate::model_capabilities::CodexCatalogContext, + ) -> Result<(String, Arc), crate::model_capabilities::CapabilityError> + { + use crate::model_capabilities::{runtime_pending, source_changed}; + let requested = context.native_reference()?; + let id = self + .ownership + .as_ref() + .map(|registry| registry.resolve_canonical(PROVIDER, requested)) + .unwrap_or_else(|| requested.to_string()); + let owner = self.ownership_snapshot(PROVIDER, &id); + if self.ownership.is_some() + && !matches!(&owner.state, freshell_ownership::OwnershipState::Live {owner,..} if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent) + { + return Err(runtime_pending()); + } + if context.observed_epoch.is_some_and(|v| v != owner.epoch) + || context + .observed_generation + .is_some_and(|v| v != owner.generation) + { + return Err(source_changed()); + } + let (key, client, exited) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(&id).ok_or_else(runtime_pending)?; + ( + json!([ + "codex", + id, + session.sidecar_ownership_id, + format!("{:p}", Arc::as_ptr(&session.client)), + format!("{:p}", Arc::as_ptr(&session.projection)), + session.cwd, + owner.epoch, + owner.generation, + format!("{:?}", owner.state) + ]) + .to_string(), + session.client.clone(), + session.exited.load(Ordering::SeqCst), + ) + }; + if exited || !client.is_initialized().await { + return Err(runtime_pending()); + } + Ok((key, client)) + } + + /// Registered-client discovery only: never spawn, resume, read saved history, + /// or search attention aliases to manufacture a catalog authority. + pub async fn model_catalog_source( + &self, + context: crate::model_capabilities::CodexCatalogContext, + ) -> Result< + crate::model_capabilities::CodexCatalogSource, + crate::model_capabilities::CapabilityError, + > { + let (key, _) = self.catalog_binding(&context).await?; + Ok(crate::model_capabilities::CodexCatalogSource { + key: key.clone(), + probe: Arc::new(LocalCodexCatalogProbe { + state: self.clone(), + context, + expected_source: key, + }), + }) + } + + pub async fn model_catalog( + &self, + context: crate::model_capabilities::CodexCatalogContext, + ) -> Result { + use crate::model_capabilities::{codex_probe_error, source_changed}; + let (key, client) = self.catalog_binding(&context).await?; + let models = freshell_codex::catalog::read_models(&client).await.map_err(|error| { + tracing::warn!(provider=PROVIDER,session_id=%context.session_id,cause=%error,"freshagent.codex.catalog_unavailable"); + codex_probe_error(error) + })?; + if self.catalog_binding(&context).await?.0 != key { + return Err(source_changed()); + } + Ok(serde_json::to_value(models).expect("native catalog rows serialize")) + } + fn broadcast(&self, msg: &ServerMessage) { match msg { ServerMessage::FreshAgentCreated(created) => { @@ -2132,7 +2219,16 @@ impl FreshCodexState { None => crate::identity_sink::FreshAgentSettings::default(), }; let cwd = msg.cwd.clone().or(rec.cwd); - let raw_model = msg.model.clone().or(rec.model); + let raw_model = msg + .model + .clone() + .or_else(|| { + msg.model_selection + .as_ref() + .and_then(|s| s.as_ref()) + .map(|s| s.model_id.clone()) + }) + .or(rec.model); let model = normalize_freshcodex_model(raw_model.as_deref()); let raw_effort = msg.effort.clone().or(rec.effort); let effort = normalize_freshcodex_effort(Some(&model), raw_effort.as_deref()); @@ -2143,8 +2239,8 @@ impl FreshCodexState { return; } - // Validate the effort maps to the codex wire vocabulary (adapter create calls - // toCodexReasoningEffort purely to reject unsupported efforts before spawning). + // Keep nonempty native values for native validation; discovery is not + // exhaustive for custom providers. if let Err(err) = to_codex_reasoning_effort(effort.as_deref()) { self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); return; @@ -3669,6 +3765,19 @@ impl FreshCodexState { // Re-normalize model/effort on send (adapter.ts:961-963) — idempotent for stored values. let model = normalize_freshcodex_model(Some(&model)); let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); + let effort = if effort.as_deref() == Some("max") { + match freshell_codex::catalog::read_models(&client).await { + Ok(catalog) => Some(freshell_codex::catalog::resolve_legacy_effort( + &model, "max", &catalog, + )), + Err(error) => { + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?request_id, cause=%error, "freshagent.codex.effort_catalog_unavailable"); + effort + } + } + } else { + effort + }; let wire_effort = match to_codex_reasoning_effort(effort.as_deref()) { Ok(value) => value, Err(err) => { @@ -3682,7 +3791,7 @@ impl FreshCodexState { input: codex_user_input(&msg.text, msg.images.as_deref()), cwd: turn_cwd.clone(), model: Some(model.clone()), - // DEV-0003: none/minimal/low/medium/high forwarded VERBATIM; max/xhigh → xhigh. + // Preserve exact native intent, with only the advertised legacy alias above. effort: wire_effort, sandbox_policy: sandbox.as_deref().map(sandbox_policy_value), approval_policy: permission_mode.as_deref().map(|p| json!(p)), @@ -4156,8 +4265,8 @@ impl FreshCodexState { /// re-snapshot the durable binding row, and broadcast /// `freshAgent.session.metadata` so every device's model surfaces converge /// immediately. Explicit choices win over the stored values, exactly like - /// `handle_send`'s settings merge; the effort is wire-validated up front - /// so the record never holds a value the next turn could not send. + /// `handle_send`'s settings merge. Nonempty effort strings stay exact for + /// native validation on the next turn. /// /// No busy refusal: recording mid-turn is safe — the in-flight turn keeps /// the parameters it already started with, and the next turn picks up the @@ -12287,6 +12396,27 @@ fn now_iso() -> String { format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{millis:03}Z") } +struct LocalCodexCatalogProbe { + state: FreshCodexState, + context: crate::model_capabilities::CodexCatalogContext, + expected_source: String, +} +impl crate::model_capabilities::ModelCatalogProbe for LocalCodexCatalogProbe { + fn probe<'a>( + &'a self, + _cwd: Option<&'a str>, + ) -> freshell_opencode::serve::BoxFuture<'a, crate::model_capabilities::CatalogOut> { + Box::pin(async move { + if self.state.catalog_binding(&self.context).await?.0 != self.expected_source { + return Err(crate::model_capabilities::source_changed()); + } + let rows = self.state.model_catalog(self.context.clone()).await?; + let models = serde_json::from_value(rows).expect("typed native catalog rows"); + Ok(crate::model_capabilities::codex_capabilities(models)) + }) + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -29161,7 +29291,11 @@ pub(crate) mod tests { #[tokio::test(flavor = "multi_thread")] async fn model_catalog_only_maps_legacy_max_when_selected_model_advertises_xhigh_without_max() { let _guard = ENV_LOCK.lock().await; - for (levels, expected) in [(vec!["xhigh"], "xhigh"), (vec!["xhigh", "max"], "max"), (vec!["ultra"], "max")] { + for (levels, expected) in [ + (vec!["xhigh"], "xhigh"), + (vec!["xhigh", "max"], "max"), + (vec!["ultra"], "max"), + ] { let temp = tempfile::tempdir().unwrap(); let log = temp.path().join("native-requests.jsonl"); configure_fake_codex_cmd(&json!({"appendRequestLogPath": log,"modelCatalogPages":[{"data":[{ @@ -29174,56 +29308,217 @@ pub(crate) mod tests { create.model = Some("gpt-6-astra".into()); create.effort = Some("max".into()); st.handle_create(create, None).await; - let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.created").await; - let id = created.frames.iter().find(|f| f["type"] == "freshAgent.created").unwrap()["sessionId"].as_str().unwrap(); + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.created" + }) + .await; + let id = created + .frames + .iter() + .find(|f| f["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap(); st.handle_send(send_msg(id, "legacy max selection")).await; - let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["type"] == "freshAgent.send.accepted").await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["type"] == "freshAgent.send.accepted" + }) + .await; st.shutdown().await; - let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|line|serde_json::from_str(line).unwrap()).collect(); - let turn = requests.iter().find(|r|r["method"]=="turn/start").unwrap(); - assert_eq!(turn["params"]["effort"], expected, "installed levels: {levels:?}"); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turn = requests + .iter() + .find(|r| r["method"] == "turn/start") + .unwrap(); + assert_eq!( + turn["params"]["effort"], expected, + "installed levels: {levels:?}" + ); } std::env::remove_var("CODEX_CMD"); std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); } + #[tokio::test(flavor = "multi_thread")] + async fn model_catalog_omission_preserves_exact_choice_and_native_refusal_is_typed_without_retry( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"modelCatalogPages":[{"data":[],"nextCursor":null}],"overrides":{"turn/start":{"error":{"code":-32602,"message":"Native provider rejected provider-current effort ultra"}}}}).to_string()); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("native-model-refusal-create"); + create.model = Some("provider-current".into()); + create.effort = Some("ultra".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + let id = created + .frames + .iter() + .find(|frame| frame["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + let catalog = st + .model_catalog(crate::model_capabilities::CodexCatalogContext { + session_id: id.clone(), + session_ref: None, + cwd: None, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + let mut send = send_msg(&id, "Native must validate the exact choice"); + send.request_id = Some("native-model-refusal-send".into()); + st.handle_send(send).await; + let failed = collect_frames_until(&mut rx, Duration::from_secs(5), |frame| { + frame["type"] == "error" && frame["requestId"] == "native-model-refusal-send" + }) + .await; + st.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + assert_eq!(catalog, json!([])); + assert!(failed.matched, "{:?}", failed.frames); + let error = failed + .frames + .iter() + .find(|frame| frame["requestId"] == "native-model-refusal-send") + .unwrap(); + assert!(error["message"] + .as_str() + .unwrap() + .starts_with("CODEX_TURN_START_FAILED:")); + assert!(error["message"] + .as_str() + .unwrap() + .contains("Native provider rejected provider-current effort ultra")); + assert!(!failed + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.send.accepted")); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|frame| frame["method"] == "turn/start") + .collect(); + assert_eq!(turns.len(), 1, "no retry or replacement of exact intent"); + assert_eq!(turns[0]["params"]["model"], "provider-current"); + assert_eq!(turns[0]["params"]["effort"], "ultra"); + assert_eq!( + requests + .iter() + .filter(|frame| frame["method"] == "thread/start") + .count(), + 1 + ); + } + #[tokio::test(flavor = "multi_thread")] async fn native_model_choices_reach_create_send_configure_resume_and_fork_requests() { let _guard = ENV_LOCK.lock().await; let temp = tempfile::tempdir().unwrap(); let log = temp.path().join("native-requests.jsonl"); - configure_fake_codex_cmd(&json!({"appendRequestLogPath": log, "recordTurns": true}).to_string()); + configure_fake_codex_cmd( + &json!({"appendRequestLogPath": log, "recordTurns": true}).to_string(), + ); let (st, mut rx) = state_with_bus(); let mut create = create_msg("native-model-create"); - create.model_selection = Some(Some(freshell_protocol::ModelSelection { kind: "exact".into(), model_id: "provider-current".into() })); + create.model_selection = Some(Some(freshell_protocol::ModelSelection { + kind: "exact".into(), + model_id: "provider-current".into(), + })); create.effort = Some("ultra".into()); st.handle_create(create, None).await; - let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.created").await; - let session_id = created.frames.iter().find(|f| f["type"] == "freshAgent.created").unwrap()["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&session_id, "first native choice")).await; - let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; - st.handle_configure(configure_msg(&session_id, Some("provider-current"), Some("ultra"))).await; - st.handle_send(send_msg(&session_id, "configured native choice")).await; - let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.created" + }) + .await; + let session_id = created + .frames + .iter() + .find(|f| f["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&session_id, "first native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + st.handle_configure(configure_msg( + &session_id, + Some("provider-current"), + Some("ultra"), + )) + .await; + st.handle_send(send_msg(&session_id, "configured native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; let (sink, fork_frames) = capturing_sink(); - st.handle_fork(fork_msg(&session_id, "native-model-fork", None), None, sink).await; + st.handle_fork(fork_msg(&session_id, "native-model-fork", None), None, sink) + .await; let forked = captured_frames(&fork_frames); - let child = forked.iter().find(|f|f["type"]=="freshAgent.forked").unwrap()["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&child, "forked native choice")).await; - let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| f["event"]["type"] == "freshAgent.turn.complete").await; + let child = forked + .iter() + .find(|f| f["type"] == "freshAgent.forked") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&child, "forked native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; simulate_unrequested_crash(&st, &session_id).await; - st.handle_send(send_msg(&session_id, "resumed native choice")).await; - let _ = collect_frames_until(&mut rx, Duration::from_secs(10), |f| f["type"] == "freshAgent.send.accepted").await; + st.handle_send(send_msg(&session_id, "resumed native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.send.accepted" + }) + .await; st.shutdown().await; std::env::remove_var("CODEX_CMD"); std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); - let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); for method in ["thread/start", "thread/resume", "thread/fork", "turn/start"] { - let rows: Vec<_> = requests.iter().filter(|row| row["method"] == method).collect(); - assert!(!rows.is_empty(), "native method not exercised: {method}: {requests:?}"); + let rows: Vec<_> = requests + .iter() + .filter(|row| row["method"] == method) + .collect(); + assert!( + !rows.is_empty(), + "native method not exercised: {method}: {requests:?}" + ); for row in rows { assert_eq!(row["params"]["model"], "provider-current", "{method}"); - if method == "turn/start" { assert_eq!(row["params"]["effort"], "ultra"); } + if method == "turn/start" { + assert_eq!(row["params"]["effort"], "ultra"); + } } } } @@ -29311,11 +29606,9 @@ pub(crate) mod tests { assert_eq!(metadata["event"]["effort"], json!("high")); } - /// An unknown-menu effort clamps to the model's menu default (the same - /// two-stage normalization `handle_send` applies), and the broadcast - /// carries the CLAMPED value — the record never holds a raw pick. + /// An opaque native effort is retained for the next turn and broadcast. #[tokio::test(flavor = "multi_thread")] - async fn configure_normalizes_the_effort_before_recording_it() { + async fn configure_preserves_native_effort_before_recording_it() { let (st, rx) = state_with_bus(); let mut rx = rx; inserted_session(&st, "thread-cfg-clamp").await; @@ -29333,8 +29626,8 @@ pub(crate) mod tests { }; assert_eq!( effort.as_deref(), - Some("max"), - "an unknown-menu effort clamps to gpt-5.3-codex-spark's default" + Some("bogus"), + "native validation, not a static menu, decides support" ); let frames = drain(&mut rx).await; let metadata = frames @@ -29344,7 +29637,7 @@ pub(crate) mod tests { && f["event"]["type"] == "freshAgent.session.metadata" }) .expect("a metadata frame is broadcast"); - assert_eq!(metadata["event"]["effort"], json!("max")); + assert_eq!(metadata["event"]["effort"], json!("bogus")); } /// An idempotent configure (same effective pair) stays silent on the bus. diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index c6b924988..4fa17d4fa 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -2243,12 +2243,13 @@ pub struct FreshAgentState { pub(crate) terminal_created_hook: Option, /// The `GET`/`POST /api/fresh-agent/model-capabilities/*` registry /// ([`model_capabilities`]): cwd-keyed TTL cache + single-flight coalescing - /// over the transient opencode catalog probe, plus the static codex/claude - /// catalogs. Constructed with the REAL transport probe in + /// over the transient OpenCode / Claude SDK catalog probes and bound + /// Codex runtime sources. Constructed with the REAL transport probe in /// [`Self::new`] (there is no wiring ceremony — unlike the Option-until- /// wired fields above, production construction is unconditional); tests /// install a scripted probe via [`Self::with_model_capability_probe`]. pub(crate) model_capabilities: Arc, + pub(crate) codex_catalog: Arc>, /// Task-3 child-session join (freshopencode TUI parity): child session id /// → the parent session id whose live watcher exists (Stage-2 ledger /// LB-2/LB-8). A watcher self-removes its entry on exit, so every parent @@ -2452,6 +2453,7 @@ impl FreshAgentState { sidecar_liveness: None, layout: layout_store::LayoutStore::default(), terminal_created_hook: None, + codex_catalog: Arc::new(std::sync::OnceLock::new()), model_capabilities: Arc::new(model_capabilities::ModelCapabilityRegistry::new( Arc::new(model_capabilities::OpencodeCatalogProbe::default()), )), @@ -2608,6 +2610,12 @@ impl FreshAgentState { self.model_capabilities.clone() } + pub fn set_codex_catalog_state(&self, state: FreshCodexState) -> Result<(), &'static str> { + self.codex_catalog + .set(state) + .map_err(|_| "Codex catalog state already installed") + } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { let _ = self.identity_sink.set(sink); diff --git a/crates/freshell-freshagent/src/model_capabilities.rs b/crates/freshell-freshagent/src/model_capabilities.rs index 8a558276e..eb4829a83 100644 --- a/crates/freshell-freshagent/src/model_capabilities.rs +++ b/crates/freshell-freshagent/src/model_capabilities.rs @@ -337,6 +337,8 @@ fn normalize_claude_catalog(raw: Value) -> CatalogOut { } if !models.iter().any(|model: &ModelCapability| model.id == id) { models.push(ModelCapability { + default_effort: None, + input_modalities: None, id: id.into(), display_name: name.into(), provider: "claude", @@ -422,18 +424,15 @@ impl ModelCapabilityRegistry { /// `getCapabilities` (`model-capability-registry.ts:211-234`): cache hit within /// TTL → `cached`; otherwise single-flight refresh → `fresh`; probe failure → - /// the `unavailable` envelope at 503 (cache untouched). Codex uses its - /// built-in catalog; Claude/Kilroy share their SDK catalog cache. + /// the `unavailable` envelope at 503 (cache untouched). Unbound Codex + /// callers receive RUNTIME_PENDING; bound reads use `codex` below. + /// Claude/Kilroy share their SDK catalog cache. pub async fn get(&self, session_type: SessionType, cwd: Option) -> (StatusCode, Value) { if session_type == SessionType::FreshCodex { + let error = runtime_pending(); return ( - StatusCode::OK, - success_body( - session_type, - "fresh", - (self.now)(), - static_models(session_type), - ), + StatusCode::SERVICE_UNAVAILABLE, + failure_body(session_type, &error), ); } let key = catalog_cache_key(session_type, cwd.as_deref()); @@ -465,14 +464,10 @@ impl ModelCapabilityRegistry { cwd: Option, ) -> (StatusCode, Value) { if session_type == SessionType::FreshCodex { + let error = runtime_pending(); return ( - StatusCode::OK, - success_body( - session_type, - "fresh", - (self.now)(), - static_models(session_type), - ), + StatusCode::SERVICE_UNAVAILABLE, + failure_body(session_type, &error), ); } self.finish_catalog( @@ -486,21 +481,21 @@ impl ModelCapabilityRegistry { /// Typed internal read for server-side consumers (the session-directory /// context meter's model-limit snapshot): the same TTL/single-flight /// path as [`Self::get`], returning rows instead of the HTTP envelope. - /// Codex resolves from its static table; a probe failure returns `Err` - /// and leaves the last successful cache entry in place. + /// Codex requires a bound runtime source rather than this unscoped read. + /// A probe failure leaves the last successful cache entry in place. pub async fn models( &self, session_type: SessionType, cwd: Option, ) -> Result, CapabilityError> { if session_type == SessionType::FreshCodex { - return Ok(static_models(session_type)); + return Err(runtime_pending()); } let key = catalog_cache_key(session_type, cwd.as_deref()); if let Some(cached) = self.cached_within_ttl(&key).await { return Ok(cached.models); } - self.refreshed_catalog(session_type, key, cwd) + self.refreshed_catalog(session_type, key, cwd, None) .await .map(|catalog| catalog.models) } @@ -511,7 +506,7 @@ impl ModelCapabilityRegistry { key: String, cwd: Option, ) -> (StatusCode, Value) { - match self.refreshed_catalog(session_type, key, cwd).await { + match self.refreshed_catalog(session_type, key, cwd, None).await { Ok(catalog) => ( StatusCode::OK, success_body(session_type, "fresh", catalog.fetched_at_ms, catalog.models), @@ -531,17 +526,20 @@ impl ModelCapabilityRegistry { session_type: SessionType, key: String, cwd: Option, + probe_override: Option>, ) -> Result { let shared = { let mut state = self.state.lock().await; match state.inflight.get(&key) { Some(existing) => existing.clone(), None => { - let probe = if session_type == SessionType::FreshOpencode { - self.probe.clone() - } else { - self.claude_probe.clone() - }; + let probe = probe_override.clone().unwrap_or_else(|| { + if session_type == SessionType::FreshOpencode { + self.probe.clone() + } else { + self.claude_probe.clone() + } + }); let now = self.now.clone(); let state_arc = self.state.clone(); let key_for_cleanup = key.clone(); @@ -582,56 +580,6 @@ fn catalog_cache_key(session_type: SessionType, cwd: Option<&str>) -> String { format!("opencode:{}", cwd.unwrap_or("")) } -/// `createStaticSuccess` (`:354-378`): the shared -/// `FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE` tables -/// (`shared/fresh-agent-models.ts:21-84`) as capability rows. -fn static_models(session_type: SessionType) -> Vec { - type Row = (&'static str, &'static str, &'static [&'static str]); - let rows: &[Row] = match session_type { - SessionType::FreshCodex => &[ - ( - "gpt-6-astra", - "GPT-6 Astra", - &["low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-sol", - "GPT-5.6 Sol", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-terra", - "GPT-5.6 Terra", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-luna", - "GPT-5.6 Luna", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.3-codex-spark", - "GPT-5.3 Codex Spark", - &["none", "minimal", "low", "medium", "high", "max"], - ), - ], - SessionType::FreshClaude | SessionType::Kilroy | SessionType::FreshOpencode => &[], - }; - rows.iter() - .map(|(id, display_name, levels)| ModelCapability { - id: id.to_string(), - display_name: display_name.to_string(), - provider: session_type.runtime_provider(), - source: None, - // `:359-366`: effort support derives from the levels list, not the flag. - supports_effort: !levels.is_empty(), - supported_effort_levels: levels.iter().map(|s| s.to_string()).collect(), - supports_adaptive_thinking: !levels.is_empty(), - limit: None, - }) - .collect() -} - /// `FreshAgentModelCapabilitiesSchema.extend({ok:true})` — the six keys, exactly. fn success_body( session_type: SessionType, @@ -666,6 +614,191 @@ fn failure_body(session_type: SessionType, err: &CapabilityError) -> Value { }) } +/// A pane's conversation identity is an observation, never a supplied soul authority. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexCatalogContext { + pub session_id: String, + pub session_ref: Option, + pub cwd: Option, + pub observed_epoch: Option, + pub observed_generation: Option, +} +impl CodexCatalogContext { + pub fn native_reference(&self) -> Result<&str, CapabilityError> { + if let Some(reference) = &self.session_ref { + if reference.provider != "codex" || reference.session_id.trim().is_empty() { + return Err(runtime_pending()); + } + Ok(&reference.session_id) + } else if !self.session_id.trim().is_empty() { + Ok(&self.session_id) + } else { + Err(runtime_pending()) + } + } +} + +#[derive(Clone)] +pub struct CodexCatalogSource { + pub key: String, + pub probe: Arc, +} + +pub fn runtime_pending() -> CapabilityError { + CapabilityError { + code: "RUNTIME_PENDING".into(), + message: "Waiting for the conversation's Codex runtime".into(), + retryable: true, + } +} +pub fn source_changed() -> CapabilityError { + CapabilityError { + code: "CAPABILITY_SOURCE_CHANGED".into(), + message: "The Codex runtime changed during model discovery. Try again.".into(), + retryable: true, + } +} +pub fn codex_probe_error(error: freshell_codex::CodexAppServerError) -> CapabilityError { + let code = match error { + freshell_codex::CodexAppServerError::Timeout { .. } => "CAPABILITY_PROBE_TIMEOUT", + freshell_codex::CodexAppServerError::InvalidResponse { .. } => "CAPABILITY_PAYLOAD_INVALID", + _ => "CAPABILITY_PROBE_FAILED", + }; + CapabilityError { + code: code.into(), + message: error.to_string(), + retryable: true, + } +} +pub fn codex_capabilities( + models: Vec, +) -> Vec { + models + .into_iter() + .map(|model| ModelCapability { + id: model.id, + display_name: model.display_name, + provider: "codex", + source: None, + supports_effort: !model.supported_effort_levels.is_empty(), + supported_effort_levels: model.supported_effort_levels, + default_effort: model.default_effort, + input_modalities: model.input_modalities, + supports_adaptive_thinking: false, + limit: None, + }) + .collect() +} + +impl ModelCapabilityRegistry { + pub async fn codex( + &self, + source: CodexCatalogSource, + cwd: Option, + refresh: bool, + ) -> (StatusCode, Value) { + let key = serde_json::to_string(&("codex", source.key, cwd.as_deref().map(str::trim))) + .expect("catalog key"); + if !refresh { + if let Some(cached) = self.cached_within_ttl(&key).await { + return ( + StatusCode::OK, + success_body( + SessionType::FreshCodex, + "cached", + cached.fetched_at_ms, + cached.models, + ), + ); + } + } + match self + .refreshed_catalog(SessionType::FreshCodex, key, cwd, Some(source.probe)) + .await + { + Ok(catalog) => ( + StatusCode::OK, + success_body( + SessionType::FreshCodex, + "fresh", + catalog.fetched_at_ms, + catalog.models, + ), + ), + Err(error) => ( + StatusCode::SERVICE_UNAVAILABLE, + failure_body(SessionType::FreshCodex, &error), + ), + } + } +} + +async fn bound_codex_source( + state: &FreshAgentState, + context: &CodexCatalogContext, +) -> Result { + if let Some(hosted) = state.hosted_rest_gateway() { + return hosted.model_catalog_source(context.clone()).await; + } + let local = state.codex_catalog.get().ok_or_else(runtime_pending)?; + local.model_catalog_source(context.clone()).await +} +async fn capabilities_for_request( + state: &FreshAgentState, + st: SessionType, + query: &HashMap, + refresh: bool, +) -> (StatusCode, Value) { + if st != SessionType::FreshCodex { + return if refresh { + state + .model_capabilities + .refresh(st, resolve_context(st, query)) + .await + } else { + state + .model_capabilities + .get(st, resolve_context(st, query)) + .await + }; + } + let work = async { + let context = CodexCatalogContext { + session_id: query.get("sessionId").cloned().unwrap_or_default(), + session_ref: query + .get("sessionRef") + .map(|s| serde_json::from_str(s).map_err(|_| source_changed())) + .transpose()?, + cwd: query.get("cwd").cloned(), + observed_epoch: query + .get("observedEpoch") + .map(|s| s.parse::().map_err(|_| source_changed())) + .transpose()?, + observed_generation: query + .get("observedGeneration") + .map(|s| s.parse::().map_err(|_| source_changed())) + .transpose()?, + }; + context.native_reference()?; + if context.observed_epoch.is_some() != context.observed_generation.is_some() { + return Err(source_changed()); + } + let source = bound_codex_source(state, &context).await?; + let key = source.key.clone(); + let response = state + .model_capabilities + .codex(source, context.cwd.clone(), refresh) + .await; + if response.0 == StatusCode::OK && bound_codex_source(state, &context).await?.key != key { + return Err(source_changed()); + } + Ok(response) + } + .await; + work.unwrap_or_else(|error| (StatusCode::SERVICE_UNAVAILABLE, failure_body(st, &error))) +} + // ── HTTP surface (createFreshAgentModelCapabilitiesRouter) ───────────────────── /// The two routes. Merged into [`crate::router`] so `freshell-server`'s `main.rs` @@ -700,10 +833,7 @@ async fn get_capabilities( ) .into_response(); }; - let (status, body) = state - .model_capabilities - .get(st, resolve_context(st, &query)) - .await; + let (status, body) = capabilities_for_request(&state, st, &query, false).await; (status, Json(body)).into_response() } @@ -724,10 +854,7 @@ async fn refresh_capabilities( ) .into_response(); }; - let (status, body) = state - .model_capabilities - .refresh(st, resolve_context(st, &query)) - .await; + let (status, body) = capabilities_for_request(&state, st, &query, true).await; (status, Json(body)).into_response() } diff --git a/crates/freshell-freshagent/src/model_capabilities_tests.rs b/crates/freshell-freshagent/src/model_capabilities_tests.rs index 8fb09740e..8e11cea5f 100644 --- a/crates/freshell-freshagent/src/model_capabilities_tests.rs +++ b/crates/freshell-freshagent/src/model_capabilities_tests.rs @@ -72,6 +72,8 @@ impl ModelCatalogProbe for RecordingProbe { fn cap(id: &str, display: &str, source_id: Option<&str>) -> ModelCapability { ModelCapability { + default_effort: None, + input_modalities: None, id: id.to_string(), display_name: display.to_string(), provider: "opencode", @@ -333,70 +335,6 @@ async fn blank_cwd_maps_to_default_cache_key() { assert_eq!(probe.calls().await, vec![None]); } -/// Codex keeps its built-in catalog without starting a Claude or OpenCode probe. -#[tokio::test] -async fn codex_serves_static_catalog() { - let probe = Arc::new(RecordingProbe::new()); - let (registry, _now) = registry_with(probe.clone()); - - let (status, codex) = registry.get(SessionType::FreshCodex, None).await; - assert_eq!(status, StatusCode::OK); - assert_eq!( - codex, - json!({ - "ok": true, - "sessionType": "freshcodex", - "runtimeProvider": "codex", - "status": "fresh", - "fetchedAt": 1_000, - "models": [ - { - "id": "gpt-6-astra", - "displayName": "GPT-6 Astra", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-sol", - "displayName": "GPT-5.6 Sol", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-terra", - "displayName": "GPT-5.6 Terra", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-luna", - "displayName": "GPT-5.6 Luna", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.3-codex-spark", - "displayName": "GPT-5.3 Codex Spark", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "minimal", "low", "medium", "high", "max"], - "supportsAdaptiveThinking": true, - }, - ], - }) - ); - - assert_eq!(probe.call_count().await, 0, "static paths never probe"); -} - #[test] fn claude_catalog_keeps_live_effort_choices_and_deduplicates_models() { let models = normalize_claude_catalog(json!([ @@ -724,11 +662,87 @@ async fn post(app: Router, uri: &str, auth: bool) -> (StatusCode, Value) { const CAP: &str = "/api/fresh-agent/model-capabilities"; +#[tokio::test] +async fn codex_unbound_registry_is_pending_and_never_probes_another_provider() { + let probe = Arc::new(RecordingProbe::new()); + let (registry, _) = registry_with(probe.clone()); + for (status, body) in [ + registry.get(SessionType::FreshCodex, None).await, + registry.refresh(SessionType::FreshCodex, None).await, + ] { + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["code"], "RUNTIME_PENDING"); + assert_eq!(body["models"], json!([])); + } + assert_eq!( + registry + .models(SessionType::FreshCodex, None) + .await + .unwrap_err() + .code, + "RUNTIME_PENDING" + ); + assert_eq!(probe.call_count().await, 0); +} + +#[tokio::test] +async fn codex_model_catalog_cache_is_source_scoped_and_failed_refresh_preserves_same_source() { + let gateway = Arc::new(RecordingProbe::new()); + let local = Arc::new(RecordingProbe::new()); + let managed = Arc::new(RecordingProbe::new()); + let (registry, _) = registry_with(gateway.clone()); + local + .push_ok(vec![cap("local-current", "Local Current", None)]) + .await; + managed + .push_ok(vec![cap("managed-current", "Managed Current", None)]) + .await; + let local_source = CodexCatalogSource { + key: "native-client-a:epoch1:generation1".into(), + probe: local.clone(), + }; + let managed_source = CodexCatalogSource { + key: "host-boot-b:incarnation2:generation3:volume-owned".into(), + probe: managed.clone(), + }; + let (_, first) = registry + .codex(local_source.clone(), Some("/same-cwd".into()), false) + .await; + let (_, other) = registry + .codex(managed_source, Some("/same-cwd".into()), false) + .await; + assert_eq!(first["models"][0]["id"], "local-current"); + assert_eq!(other["models"][0]["id"], "managed-current"); + local + .push_err(CapabilityError { + code: "CAPABILITY_PROBE_TIMEOUT".into(), + message: "deadline".into(), + retryable: true, + }) + .await; + let (status, failed) = registry + .codex(local_source.clone(), Some("/same-cwd".into()), true) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(failed["error"]["code"], "CAPABILITY_PROBE_TIMEOUT"); + let (_, cached) = registry + .codex(local_source, Some("/same-cwd".into()), false) + .await; + assert_eq!(cached["status"], "cached"); + assert_eq!(cached["models"][0]["id"], "local-current"); + assert_eq!(local.call_count().await, 2); + assert_eq!(managed.call_count().await, 1); + assert_eq!(gateway.call_count().await, 0); +} + #[tokio::test] async fn codex_model_catalog_without_bound_runtime_is_pending_and_does_not_probe_gateway() { let probe = Arc::new(RecordingProbe::new()); let app = app_with_probe(probe.clone()); - for uri in [format!("{CAP}/freshcodex"), format!("{CAP}/freshcodex?sessionId=not-created&cwd=/other-home")] { + for uri in [ + format!("{CAP}/freshcodex"), + format!("{CAP}/freshcodex?sessionId=not-created&cwd=/other-home"), + ] { let (status, body) = get(app.clone(), &uri, true).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body["error"]["code"], "RUNTIME_PENDING"); @@ -833,20 +847,6 @@ async fn probe_failure_maps_to_503_unavailable_envelope() { ); } -/// Static providers work over HTTP too and never probe. -#[tokio::test] -async fn get_freshcodex_serves_static_catalog_over_http() { - let probe = Arc::new(RecordingProbe::new()); - let app = app_with_probe(probe.clone()); - - let (status, body) = get(app, &format!("{CAP}/freshcodex?cwd=/ignored"), true).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["runtimeProvider"], json!("codex")); - assert_eq!(body["models"].as_array().unwrap().len(), 5); - assert_eq!(body["models"][0]["id"], json!("gpt-6-astra")); - assert_eq!(probe.call_count().await, 0); -} - /// POST refresh re-probes even within the TTL window. #[tokio::test] async fn refresh_reprobes_and_reports_fresh() { diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index e3a80f00c..8a4867fe0 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -216,6 +216,15 @@ pub enum RestLaneSnapshotError { #[async_trait] pub trait FreshAgentRestLane: Send + Sync { + async fn model_catalog_source( + &self, + _context: crate::model_capabilities::CodexCatalogContext, + ) -> Result< + crate::model_capabilities::CodexCatalogSource, + crate::model_capabilities::CapabilityError, + > { + Err(crate::model_capabilities::runtime_pending()) + } async fn read_child( &self, _request: RestLaneChildRead, diff --git a/crates/freshell-opencode/src/catalog.rs b/crates/freshell-opencode/src/catalog.rs index 2b065f048..81b89f7f4 100644 --- a/crates/freshell-opencode/src/catalog.rs +++ b/crates/freshell-opencode/src/catalog.rs @@ -54,6 +54,10 @@ pub struct ModelCapability { pub supports_effort: bool, #[serde(rename = "supportedEffortLevels")] pub supported_effort_levels: Vec, + #[serde(rename = "defaultEffort", skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + #[serde(rename = "inputModalities", skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, #[serde(rename = "supportsAdaptiveThinking")] pub supports_adaptive_thinking: bool, /// Raw `limit { context, input?, output }` block when the provider @@ -259,6 +263,8 @@ pub fn normalize_enabled_model_catalog(raw: &Value) -> Vec { let has_levels = !supported_effort_levels.is_empty(); let limit = read_model_limits(model.get("limit")); models.push(ModelCapability { + default_effort: None, + input_modalities: None, id: format!("{provider_id}/{model_id}"), display_name, provider: "opencode", @@ -828,6 +834,8 @@ mod tests { assert_eq!( models, vec![ModelCapability { + default_effort: None, + input_modalities: None, id: "deepseek/deepseek-v4-pro".to_string(), display_name: "DeepSeek V4 Pro".to_string(), provider: "opencode", @@ -1086,6 +1094,8 @@ mod tests { assert_eq!( models, vec![ModelCapability { + default_effort: None, + input_modalities: None, id: "keyprovider/keymodel".to_string(), display_name: "Keyed Name".to_string(), provider: "opencode", @@ -1203,6 +1213,8 @@ mod tests { #[test] fn model_limits_never_serialize_to_the_strict_wire_schema() { let cap = ModelCapability { + default_effort: None, + input_modalities: None, id: "lunaroute/glm-5.3".into(), display_name: "GLM 5.3".into(), provider: "opencode", diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index 320c70231..95d97d3a7 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -854,6 +854,44 @@ impl RuntimeClient { } } + pub async fn fresh_agent_model_catalog( + &self, + soul_id: SoulId, + cwd: Option, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentModelCatalogRequest { + soul_id, + cwd, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentModelCatalog(request), + ) + .await + { + Ok(AdminResult::FreshAgentModelCatalog(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentModelCatalog(request), + ) + .await? + { + AdminResult::FreshAgentModelCatalog(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_snapshot( &self, soul_id: SoulId, @@ -1221,6 +1259,7 @@ mod tests { Snapshot, History, Child, + Catalog, } impl FreshAgentReadKind { @@ -1232,6 +1271,11 @@ mod tests { match self { Self::Snapshot => client.fresh_agent_snapshot(soul).await, Self::History => client.fresh_agent_history(soul).await, + Self::Catalog => { + client + .fresh_agent_model_catalog(soul, Some("/owned/project".into())) + .await + } Self::Child => { client .fresh_agent_read_child(soul, "native-child".into()) @@ -1244,6 +1288,7 @@ mod tests { match self { Self::Snapshot => AdminResult::FreshAgentSnapshot(value), Self::History => AdminResult::FreshAgentHistory(value), + Self::Catalog => AdminResult::FreshAgentModelCatalog(value), Self::Child => AdminResult::FreshAgentChildSnapshot(value), } } @@ -1260,6 +1305,10 @@ mod tests { assert_eq!(read.child_thread_id, "native-child"); (&read.soul_id, read.expected_control_epoch) } + (AdminCommand::FreshAgentModelCatalog(read), Self::Catalog) => { + assert_eq!(read.cwd.as_deref(), Some("/owned/project")); + (&read.soul_id, read.expected_control_epoch) + } _ => panic!("expected the original exact read operation: {envelope:?}"), }; assert_eq!(soul, &SoulId::parse("owned-read-soul").unwrap()); @@ -1274,6 +1323,81 @@ mod tests { } } + #[tokio::test] + async fn model_catalog_retries_only_stale_epoch_with_exact_soul_and_cwd() { + let kind = FreshAgentReadKind::Catalog; + let expected = + serde_json::json!([{"id":"provider-current","supportedEffortLevels":["ultra"]}]); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![ + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old", + )), + Ok(epoch_health()), + Ok(kind.reply(expected.clone())), + ], + ) + .await; + assert_refreshed_read(kind, &requests); + assert_eq!(cached, Some(2)); + assert_eq!(actual.unwrap(), expected); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![Ok(AdminResult::FreshAgentSnapshot(serde_json::json!({})))], + ) + .await; + assert_cached_read(kind, &requests, cached); + assert!(matches!(actual, Err(ClientError::UnexpectedResult))); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![Err(RuntimeError::new( + RuntimeErrorCode::HostUnreachable, + "down", + ))], + ) + .await; + assert_cached_read(kind, &requests, cached); + assert_eq!( + actual.unwrap_err().runtime_code(), + Some(RuntimeErrorCode::HostUnreachable) + ); + } + + #[tokio::test] + async fn model_catalog_reply_keeps_the_ordinary_control_frame_bound() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("catalog.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + assert!(matches!( + request.body, + AdminCommand::FreshAgentModelCatalog(_) + )); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(AdminResult::FreshAgentModelCatalog(serde_json::json!([ + "x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES) + ]))), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let actual = client + .fresh_agent_model_catalog(SoulId::parse("catalog-owned").unwrap(), None) + .await; + let _ = server.await.unwrap(); + assert!(actual.is_err()); + } + #[tokio::test] async fn fresh_agent_send_preserves_checkpoint_identity_on_the_control_request() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index f325b74dd..6caac4ced 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -2665,6 +2665,16 @@ pub struct FreshAgentReadSnapshotRequest { pub expected_control_epoch: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentModelCatalogRequest { + pub soul_id: SoulId, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentReadChildRequest { @@ -2970,6 +2980,7 @@ pub enum AdminCommand { FreshAgentReadEvents(FreshAgentReadEventsRequest), FreshAgentReadHistory(FreshAgentReadHistoryRequest), FreshAgentReadSnapshot(FreshAgentReadSnapshotRequest), + FreshAgentModelCatalog(FreshAgentModelCatalogRequest), FreshAgentReadChild(FreshAgentReadChildRequest), RuntimeMetrics(RuntimeMetricsRequest), ProbeRecovery(RecoveryProbeRequest), @@ -3485,6 +3496,7 @@ pub enum AdminResult { FreshAgentEvents(AgentEventBatch), FreshAgentHistory(serde_json::Value), FreshAgentSnapshot(serde_json::Value), + FreshAgentModelCatalog(serde_json::Value), FreshAgentChildSnapshot(serde_json::Value), RuntimeMetrics(RuntimeMetrics), RecoveryProbe(RecoveryProbe), @@ -3604,6 +3616,10 @@ pub enum HostCommand { FreshAgentReadSnapshot { incarnation_id: IncarnationId, }, + FreshAgentModelCatalog { + incarnation_id: IncarnationId, + cwd: Option, + }, FreshAgentReadChild { incarnation_id: IncarnationId, child_thread_id: String, @@ -3672,6 +3688,7 @@ pub enum HostResult { FreshAgentEvents(AgentEventBatch), RuntimeMetrics(RuntimeMetrics), FreshAgentSnapshot(serde_json::Value), + FreshAgentModelCatalog(serde_json::Value), FreshAgentChildSnapshot(serde_json::Value), Status { host_boot_id: HostBootId, diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 7c62f5d11..13d79d629 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -4,6 +4,64 @@ use super::*; #[async_trait::async_trait] impl FreshAgentRestLane for HostedFreshAgentProxy { + async fn model_catalog_source( + &self, + context: freshell_freshagent::model_capabilities::CodexCatalogContext, + ) -> Result< + freshell_freshagent::model_capabilities::CodexCatalogSource, + freshell_freshagent::model_capabilities::CapabilityError, + > { + use freshell_freshagent::model_capabilities::{ + runtime_pending, source_changed, CodexCatalogSource, + }; + let requested = context.native_reference()?; + let ownership = self.ownership.get().cloned(); + let owner = ownership.as_ref().map(|registry| { + let id = registry.resolve_canonical("codex", requested); + registry.observe("codex", &id) + }); + if owner.as_ref().is_some_and(|owner| { + context.observed_epoch.is_some_and(|v| v != owner.epoch) + || context + .observed_generation + .is_some_and(|v| v != owner.generation) + }) { + return Err(source_changed()); + } + let inventory = self + .client + .inventory() + .await + .map_err(|_| runtime_pending())?; + let view = inventory + .iter() + .rev() + .find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && (view.fresh_agent_session_id.as_deref() == Some(requested) + || view.native_session_id.as_deref() == Some(requested)) + }) + .ok_or_else(runtime_pending)?; + if view.native_session_id.is_none() + || view.launch_state != freshell_runtime_protocol::LaunchState::Running + || view.recovery_state != freshell_runtime_protocol::RecoveryState::Live + || view.desired_state != DesiredState::Running + { + return Err(runtime_pending()); + } + let key = catalog_source_key(view, owner.as_ref()); + Ok(CodexCatalogSource { + key, + probe: Arc::new(HostedCodexCatalogProbe { + client: self.client.clone(), + view: view.clone(), + context, + ownership, + owner, + }), + }) + } async fn read_child( &self, request: freshell_freshagent::rest_lane::RestLaneChildRead, @@ -358,6 +416,82 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { } } +fn catalog_source_key( + view: &freshell_runtime_protocol::RuntimeView, + owner: Option<&freshell_ownership::OwnershipSnapshot>, +) -> String { + serde_json::json!([ + "codex", + view.soul_id, + view.incarnation_id, + view.host_boot_id, + view.execution_generation, + view.intent_revision, + view.native_session_id, + view.fresh_agent_session_id, + view.fresh_agent_session_type, + view.recovery_state, + view.desired_state, + owner.map(|o| (o.epoch, o.generation, format!("{:?}", o.state))) + ]) + .to_string() +} + +struct HostedCodexCatalogProbe { + client: RuntimeClient, + view: freshell_runtime_protocol::RuntimeView, + context: freshell_freshagent::model_capabilities::CodexCatalogContext, + ownership: Option>, + owner: Option, +} + +impl freshell_freshagent::model_capabilities::ModelCatalogProbe for HostedCodexCatalogProbe { + fn probe<'a>( + &'a self, + cwd: Option<&'a str>, + ) -> freshell_opencode::serve::BoxFuture<'a, freshell_freshagent::model_capabilities::CatalogOut> + { + Box::pin(async move { + use freshell_freshagent::model_capabilities::{ + codex_capabilities, source_changed, CapabilityError, + }; + let value = self.client.fresh_agent_model_catalog(self.view.soul_id.clone(),cwd.map(str::to_string)).await.map_err(|error| { + tracing::warn!(provider="codex",session_id=?self.view.native_session_id,soul_id=%self.view.soul_id,code=?error.runtime_code(),"fresh_agent.hosted_catalog_unavailable"); + CapabilityError {code:"CAPABILITY_PROBE_FAILED".into(),message:"The conversation's Codex model catalog is unavailable".into(),retryable:true} + })?; + let inventory = self + .client + .inventory() + .await + .map_err(|_| source_changed())?; + let current = inventory + .iter() + .rev() + .find(|row| row.soul_id == self.view.soul_id) + .ok_or_else(source_changed)?; + let owner = self.ownership.as_ref().map(|registry| { + let id = registry.resolve_canonical( + "codex", + self.context.native_reference().expect("validated context"), + ); + registry.observe("codex", &id) + }); + if !same_snapshot_source(&self.view, current) + || catalog_source_key(current, owner.as_ref()) + != catalog_source_key(&self.view, self.owner.as_ref()) + { + return Err(source_changed()); + } + let rows = serde_json::from_value(value).map_err(|_| CapabilityError { + code: "CAPABILITY_PAYLOAD_INVALID".into(), + message: "Codex host returned an invalid model catalog".into(), + retryable: true, + })?; + Ok(codex_capabilities(rows)) + }) + } +} + fn same_snapshot_source( before: &freshell_runtime_protocol::RuntimeView, after: &freshell_runtime_protocol::RuntimeView, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index ab2b7adcf..68652d3ab 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -23,7 +23,11 @@ async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_so let (mut stream, _) = listener.accept().await.unwrap(); let envelope: Envelope = read_frame(&mut stream).await.unwrap(); let result = match envelope.body["method"].as_str().unwrap() { - "health" => serde_json::to_value(AdminResult::Health { control_epoch:7, installation_id:InstallationId::new() }).unwrap(), + "health" => serde_json::to_value(AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }) + .unwrap(), "inventory" => { inventories += 1; let changed = inventories >= 2; @@ -46,31 +50,51 @@ async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_so } other => panic!("catalog attempted unrelated operation: {other}"), }; - write_frame(&mut stream,&serde_json::json!({"requestId":envelope.request_id,"result":{"Ok":result}})).await.unwrap(); + write_frame( + &mut stream, + &serde_json::json!({"requestId":envelope.request_id,"result":{"Ok":result}}), + ) + .await + .unwrap(); } }); let (broadcast, _) = broadcast::channel(16); - let owner = freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()),Arc::new(broadcast)); - owner.set_hosted_rest_gateway(snapshot_outage_proxy(&socket)).unwrap(); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), Arc::new(broadcast)); + owner + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); let app = freshell_freshagent::model_capabilities::router(owner); let response = app.oneshot(axum::http::Request::builder().uri("/api/fresh-agent/model-capabilities/freshcodex?sessionId=public-conversation&cwd=/managed/project&soulId=stale-frontend-soul").header("x-auth-token","tok").body(axum::body::Body::empty()).unwrap()).await.unwrap(); let status = response.status(); - let body:serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(response.into_body(),1024*1024).await.unwrap()).unwrap(); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); server.abort(); assert!(server.await.unwrap_err().is_cancelled()); - if scenario=="same" { - assert_eq!(status,axum::http::StatusCode::OK,"{body}"); - assert_eq!(body["models"][0]["id"],"managed-current"); - assert_eq!(body["models"][0]["supportedEffortLevels"],serde_json::json!(["ultra"])); + if scenario == "same" { + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + assert_eq!(body["models"][0]["id"], "managed-current"); + assert_eq!( + body["models"][0]["supportedEffortLevels"], + serde_json::json!(["ultra"]) + ); } else { - assert_eq!(status,axum::http::StatusCode::SERVICE_UNAVAILABLE,"{scenario}: {body}"); - assert_eq!(body["models"],serde_json::json!([])); + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{scenario}: {body}" + ); + assert_eq!(body["models"], serde_json::json!([])); } let calls = calls.lock().await; - assert_eq!(calls.len(),1,"only the current host is queried"); - assert_eq!(calls[0]["soulId"],"current-root-soul"); - assert_eq!(calls[0]["cwd"],"/managed/project"); - assert_eq!(calls[0]["expectedControlEpoch"],7); + assert_eq!(calls.len(), 1, "only the current host is queried"); + assert_eq!(calls[0]["soulId"], "current-root-soul"); + assert_eq!(calls[0]["cwd"], "/managed/project"); + assert_eq!(calls[0]["expectedControlEpoch"], 7); } } diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 97db3e9bb..80086100c 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1579,6 +1579,9 @@ async fn main() -> ExitCode { fresh_opencode_state.set_terminal_liveness(terminal_liveness); let fresh_claude_state = fresh_claude_state; let fresh_codex_state = fresh_codex_state; + fresh_agent_state + .set_codex_catalog_state(fresh_codex_state.clone()) + .expect("one shared registered Codex state"); let fresh_opencode_state = fresh_opencode_state; // kata b8ke Task 3: the bounded Starting-state timeout watchdog (5s diff --git a/crates/freshell-server/src/opencode_limits.rs b/crates/freshell-server/src/opencode_limits.rs index 680c88d90..a3a3713e1 100644 --- a/crates/freshell-server/src/opencode_limits.rs +++ b/crates/freshell-server/src/opencode_limits.rs @@ -240,6 +240,8 @@ mod tests { limit: Option<(i64, Option, Option)>, ) -> ModelCapability { ModelCapability { + default_effort: None, + input_modalities: None, id: id.to_string(), display_name: id.to_string(), provider: "opencode", diff --git a/crates/freshell-server/tests/codex_model_catalog.rs b/crates/freshell-server/tests/codex_model_catalog.rs index 82ab30999..8151318bd 100644 --- a/crates/freshell-server/tests/codex_model_catalog.rs +++ b/crates/freshell-server/tests/codex_model_catalog.rs @@ -2,19 +2,28 @@ //! owned Codex CLI fixture. Every provider process and HOME belongs to this test. use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; -use std::{path::Path, process::{Child, Command, Stdio}, time::{Duration, Instant}}; +use std::{ + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; use tokio_tungstenite::tungstenite::Message; const TOKEN: &str = "codex-catalog-owned-fixture"; -type Socket = tokio_tungstenite::WebSocketStream>; +type Socket = + tokio_tungstenite::WebSocketStream>; struct OwnedServer(Child); impl Drop for OwnedServer { fn drop(&mut self) { - unsafe { libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); } + unsafe { + libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); + } let end = Instant::now() + Duration::from_secs(10); while Instant::now() < end { - if self.0.try_wait().ok().flatten().is_some() { return; } + if self.0.try_wait().ok().flatten().is_some() { + return; + } std::thread::sleep(Duration::from_millis(20)); } let _ = self.0.kill(); @@ -23,100 +32,279 @@ impl Drop for OwnedServer { } async fn message(socket: &mut Socket, kind: &str) -> Value { - tokio::time::timeout(Duration::from_secs(15), async { + let mut observed = Vec::new(); + let result = tokio::time::timeout(Duration::from_secs(15), async { while let Some(frame) = socket.next().await { if let Message::Text(text) = frame.unwrap() { let value: Value = serde_json::from_str(&text).unwrap(); - if value["type"] == kind { return value; } + observed.push(value.clone()); + if value["type"] == kind { + return value; + } assert_ne!(value["type"], "freshAgent.create.failed", "{value}"); } } panic!("socket closed before {kind}") - }).await.unwrap() + }) + .await; + result.unwrap_or_else(|_| panic!("socket did not receive {kind}; observed: {observed:?}")) } async fn boot(home: &Path, behavior: Value) -> (OwnedServer, String, Socket) { - let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); std::fs::create_dir_all(home.join(".freshell")).unwrap(); std::fs::write(home.join(".freshell/config.json"), json!({"settings":{"freshAgent":{"enabled":true},"network":{"configured":true,"host":"127.0.0.1"}}}).to_string()).unwrap(); - let bin = std::env::var_os("FRESHELL_SERVER_BIN").expect("run with run-focused-rust.py --build-server"); - let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); - let server = OwnedServer(Command::new(bin).current_dir(home) - .env("PORT", port.to_string()).env("AUTH_TOKEN", TOKEN).env("FRESHELL_BIND_HOST", "127.0.0.1") - .env("HOME", home).env("FRESHELL_HOME", home).env("CODEX_HOME", home.join(".codex")) - .env("CODEX_CMD", format!("node {}", fixture.display())) - .env("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()) - .env_remove("FRESHELL_MANAGED_RUNTIME_V1").env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") - .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap()); + let bin = std::env::var_os("FRESHELL_SERVER_BIN") + .expect("run with run-focused-rust.py --build-server"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); + let server = OwnedServer( + Command::new(bin) + .current_dir(home) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", TOKEN) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home) + .env("FRESHELL_HOME", home) + .env("CODEX_HOME", home.join(".codex")) + .env("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1") + .env("CODEX_CMD", format!("node {}", fixture.display())) + .env("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()) + .env_remove("FRESHELL_MANAGED_RUNTIME_V1") + .env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); let base = format!("http://127.0.0.1:{port}"); let http = reqwest::Client::new(); let end = Instant::now() + Duration::from_secs(20); loop { - if http.get(format!("{base}/api/health")).send().await.is_ok_and(|r|r.status().is_success()) {break;} + if http + .get(format!("{base}/api/health")) + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + break; + } assert!(Instant::now() < end, "owned server did not start"); tokio::time::sleep(Duration::from_millis(30)).await; } - let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")).await.unwrap(); + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")) + .await + .unwrap(); socket.send(Message::Text(json!({"type":"hello","protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"token":TOKEN}).to_string())).await.unwrap(); message(&mut socket, "ready").await; (server, base, socket) } async fn assert_catalog_scenario(scenario: &str) { - let home = tempfile::tempdir().unwrap(); - let log = home.path().join("requests.jsonl"); - let first = json!({"id":"record-sol","model":"gpt-6.1-sol","displayName":"GPT 6.1 Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}); - let second = json!({"id":"opaque-record","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"none"},{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}); - let pages = match scenario { - "loop" => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":"1"}]), - "malformed" => json!([{"data":[first],"nextCursor":7}]), - _ => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":null}]), - }; - let (server, base, mut socket) = boot(home.path(), json!({"modelCatalogPages":pages,"appendRequestLogPath":log,"ignoreMethods":if scenario=="deadline" {vec!["model/list"]} else {vec![]}})).await; - socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"catalog-create","provider":"codex","sessionType":"freshcodex","cwd":home.path(),"model":"provider-current","effort":"ultra"}).to_string())).await.unwrap(); - let created = message(&mut socket, "freshAgent.created").await; - let http = reqwest::Client::new(); - let mut url = reqwest::Url::parse(&format!("{base}/api/fresh-agent/model-capabilities/freshcodex")).unwrap(); - url.query_pairs_mut().append_pair("sessionId",created["sessionId"].as_str().unwrap()); - let response = http.get(url.clone()).header("x-auth-token", TOKEN).send().await.unwrap(); - let status = response.status().as_u16(); - let body: Value = response.json().await.unwrap(); - let cached: Option = if scenario == "valid" { - Some(http.get(url).header("x-auth-token", TOKEN).send().await.unwrap().json().await.unwrap()) - } else { None }; - socket.close(None).await.unwrap(); - drop(server); - let requests: Vec = std::fs::read_to_string(log).unwrap().lines().map(|s|serde_json::from_str(s).unwrap()).collect(); - let lists: Vec<_> = requests.iter().filter(|r|r["method"]=="model/list").collect(); - if scenario == "valid" { - assert_eq!(status,200,"{body}"); - assert_eq!(body["models"][0]["id"],"gpt-6.1-sol"); - assert_eq!(body["models"][1]["id"],"provider-current"); - assert_eq!(body["models"][1]["defaultEffort"],"ultra"); - assert_eq!(body["models"][1]["supportedEffortLevels"],json!(["none","ultra"])); - assert_eq!(body["models"][0]["inputModalities"],json!(["text","image"])); - assert_eq!(cached.unwrap()["status"],"cached"); - assert_eq!(lists.len(),2); - assert!(lists[0]["params"].get("cursor").is_none_or(Value::is_null)); - assert_eq!(lists[1]["params"]["cursor"],"1"); - assert_eq!(lists[0]["params"]["includeHidden"],false); - } else { - assert_eq!(status,503,"{scenario}: {body}"); - assert_eq!(body["status"],"unavailable"); - assert_eq!(body["models"],json!([])); - assert!(body["error"]["code"].as_str().unwrap().starts_with("CAPABILITY_")); - assert!(lists.len() <= 2); - } - assert_eq!(requests.iter().filter(|r|r["method"]=="initialize").count(),1,"catalog must reuse its registered process"); - assert_eq!(requests.iter().filter(|r|r["method"]=="thread/start").count(),1); - assert!(!requests.iter().any(|r|r["method"]=="thread/resume")); + let home = tempfile::tempdir().unwrap(); + let log = home.path().join("requests.jsonl"); + let first = json!({"id":"record-sol","model":"gpt-6.1-sol","displayName":"GPT 6.1 Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}); + let second = json!({"id":"opaque-record","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"none"},{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}); + let pages = match scenario { + "loop" => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":"1"}]), + "malformed" => json!([{"data":[first],"nextCursor":7}]), + _ => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":null}]), + }; + let (server, base, mut socket) = boot(home.path(), json!({"modelCatalogPages":pages,"appendRequestLogPath":log,"ignoreMethods":if scenario=="deadline" {vec!["model/list"]} else {vec![]}})).await; + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"catalog-create","provider":"codex","sessionType":"freshcodex","cwd":home.path(),"model":"provider-current","effort":"ultra"}).to_string())).await.unwrap(); + let created = message(&mut socket, "freshAgent.created").await; + let http = reqwest::Client::new(); + let mut url = reqwest::Url::parse(&format!( + "{base}/api/fresh-agent/model-capabilities/freshcodex" + )) + .unwrap(); + url.query_pairs_mut() + .append_pair("sessionId", created["sessionId"].as_str().unwrap()); + let response = http + .get(url.clone()) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let body: Value = response.json().await.unwrap(); + let cached: Option = if scenario == "valid" { + Some( + http.get(url) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .json() + .await + .unwrap(), + ) + } else { + None + }; + // A failed or timed-out catalog must leave the existing provider usable. + socket.send(Message::Text(json!({"type":"freshAgent.send","provider":"codex","sessionType":"freshcodex","sessionId":created["sessionId"],"requestId":"after-catalog","text":"reuse the existing provider"}).to_string())).await.unwrap(); + message(&mut socket, "freshAgent.send.accepted").await; + socket.close(None).await.unwrap(); + drop(server); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|s| serde_json::from_str(s).unwrap()) + .collect(); + let lists: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "model/list") + .collect(); + if scenario == "valid" { + assert_eq!(status, 200, "{body}"); + assert_eq!(body["models"][0]["id"], "gpt-6.1-sol"); + assert_eq!(body["models"][1]["id"], "provider-current"); + assert_eq!(body["models"][1]["defaultEffort"], "ultra"); + assert_eq!( + body["models"][1]["supportedEffortLevels"], + json!(["none", "ultra"]) + ); + assert_eq!( + body["models"][0]["inputModalities"], + json!(["text", "image"]) + ); + assert_eq!(cached.unwrap()["status"], "cached"); + assert_eq!(lists.len(), 2); + assert!(lists[0]["params"].get("cursor").is_none_or(Value::is_null)); + assert_eq!(lists[1]["params"]["cursor"], "1"); + assert_eq!(lists[0]["params"]["includeHidden"], false); + } else { + assert_eq!(status, 503, "{scenario}: {body}"); + assert_eq!(body["status"], "unavailable"); + assert_eq!(body["models"], json!([])); + assert!(body["error"]["code"] + .as_str() + .unwrap() + .starts_with("CAPABILITY_")); + assert!(lists.len() <= 2); + } + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 1, + "catalog must reuse its registered process" + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 1 + ); + assert!(!requests.iter().any(|r| r["method"] == "thread/resume")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn bound_native_model_catalog_paginates_and_caches() { assert_catalog_scenario("valid").await; } +async fn bound_native_model_catalog_paginates_and_caches() { + assert_catalog_scenario("valid").await; +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn bound_native_model_catalog_rejects_looping_cursors() { assert_catalog_scenario("loop").await; } +async fn bound_native_model_catalog_rejects_looping_cursors() { + assert_catalog_scenario("loop").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_rejects_malformed_cursor() { + assert_catalog_scenario("malformed").await; +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn bound_native_model_catalog_rejects_malformed_cursor() { assert_catalog_scenario("malformed").await; } +async fn bound_native_model_catalog_bounds_native_deadline() { + assert_catalog_scenario("deadline").await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn bound_native_model_catalog_bounds_native_deadline() { assert_catalog_scenario("deadline").await; } +async fn bound_native_model_catalog_separates_actual_provider_contexts_in_one_registry() { + let home = tempfile::tempdir().unwrap(); + let log = home.path().join("context-requests.jsonl"); + let mut pages = serde_json::Map::new(); + for name in ["a", "b"] { + let cwd = home.path().join(name); + std::fs::create_dir_all(&cwd).unwrap(); + pages.insert(cwd.to_string_lossy().into(),json!([{"data":[{"id":"opaque-record","model":format!("model-from-{name}"),"displayName":format!("Context {name}"),"supportedReasoningEfforts":[{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}],"nextCursor":null}])); + } + let (server, base, mut socket) = boot( + home.path(), + json!({"uniqueThreadIds":true,"appendRequestLogPath":log,"modelCatalogPagesByCwd":pages}), + ) + .await; + let http = reqwest::Client::new(); + let mut identities = Vec::new(); + for name in ["a", "b"] { + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":format!("create-context-{name}"),"provider":"codex","sessionType":"freshcodex","cwd":home.path().join(name),"model":format!("model-from-{name}"),"effort":"ultra"}).to_string())).await.unwrap(); + let created = message(&mut socket, "freshAgent.created").await; + identities.push(created["sessionId"].as_str().unwrap().to_string()); + } + let mut results = Vec::new(); + for index in [0, 1, 0] { + let mut url = reqwest::Url::parse(&format!( + "{base}/api/fresh-agent/model-capabilities/freshcodex" + )) + .unwrap(); + url.query_pairs_mut() + .append_pair("sessionId", &identities[index]); + // Deliberately no cwd query: the live provider client is the authority, + // so two contexts must not collide in the default-cwd cache bucket. + let response = http + .get(url) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap(); + results.push((response.status(), response.json::().await.unwrap())); + } + socket.close(None).await.unwrap(); + drop(server); + for (index, (status, body)) in results.iter().enumerate() { + assert!(status.is_success(), "{body}"); + assert_eq!( + body["models"][0]["id"], + if index == 1 { + "model-from-b" + } else { + "model-from-a" + } + ); + } + assert_eq!(results[2].1["status"], "cached"); + let requests = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let lists = requests + .iter() + .filter(|r| r["method"] == "model/list") + .collect::>(); + assert_eq!(lists.len(), 2); + for name in ["a", "b"] { + assert!(lists + .iter() + .any(|row| row["cwd"].as_str() == Some(home.path().join(name).to_str().unwrap()))); + } + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 2 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 2 + ); +} diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 0ccf20300..8a50fe55c 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -726,6 +726,24 @@ async fn dispatch( .map_err(map_actor_error)?, )) } + HostCommand::FreshAgentModelCatalog { + incarnation_id, + cwd, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentModelCatalog( + actor + .model_catalog(cwd.as_deref()) + .await + .map_err(map_actor_error)?, + )) + } HostCommand::FreshAgentReadSnapshot { incarnation_id } => { ensure_incarnation(&incarnation_id, state)?; let actor = state @@ -10281,6 +10299,13 @@ mod tests { ) } + async fn model_catalog(&self, cwd: Option<&str>) -> Result { + assert_eq!(cwd, Some("/workspace")); + Ok( + serde_json::json!([{"id":"rpc-catalog","displayName":"RPC Catalog","supportedEffortLevels":["ultra"],"defaultEffort":"ultra","inputModalities":["text"]}]), + ) + } + async fn read_child(&self, child_thread_id: &str) -> Result { assert_eq!(child_thread_id, "fixture-child"); Ok( @@ -10564,6 +10589,77 @@ mod tests { assert_eq!(transport.stops.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn authenticated_model_catalog_reads_the_parent_actor_without_starting_work() { + let state = test_host_state(); + let transport = Arc::new(RpcFixtureTransport { + dispatches: AtomicUsize::new(0), + checkpoint_attempts: std::sync::Mutex::new(Vec::new()), + decisions: AtomicUsize::new(0), + operations: AtomicUsize::new(0), + captures: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + }); + let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( + state.state_dir.join("catalog-observer"), + FreshAgentProfile { + provider: FreshProvider::Codex, + runtime_variant: "freshcodex".into(), + cwd: "/workspace".into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "parent-volume".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }, + transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + *state.fresh_agent.lock().await = Some(actor.clone()); + let (mut client_stream, server_stream) = UnixStream::pair().unwrap(); + let serving = tokio::spawn(handle_connection(server_stream, state.clone())); + write_frame( + &mut client_stream, + &authenticated_host_envelope( + &state, + HostCommand::FreshAgentModelCatalog { + incarnation_id: state.incarnation_id.clone(), + cwd: Some("/workspace".into()), + }, + ), + ) + .await + .unwrap(); + let reply: HostReply = freshell_runtime_protocol::read_frame_with_limit( + &mut client_stream, + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, + ) + .await + .unwrap(); + serving.await.unwrap().unwrap(); + let HostResult::FreshAgentModelCatalog(value) = reply.result.unwrap() else { + panic!("expected model catalog"); + }; + assert_eq!(value[0]["id"], "rpc-catalog"); + assert_eq!(value[0]["defaultEffort"], "ultra"); + assert_eq!( + actor.profile().await.native_session_id, + before.native_session_id + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0); + assert_eq!(transport.operations.load(Ordering::SeqCst), 0); + assert_eq!(transport.stops.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn authenticated_fresh_agent_rpc_keeps_one_host_owned_transport_until_explicit_stop() { let state = test_host_state(); diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index de8a7eb80..cd35bda19 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1098,6 +1098,29 @@ impl FreshAgentTransport for HostedTransport { Ok(snapshot) } + async fn model_catalog(&self, cwd: Option<&str>) -> Result { + let before = self.current_native_id().await?; + let ProviderState::Codex(state) = &self.state else { + return Err("model catalog requires a bound Codex runtime".into()); + }; + let value = state + .model_catalog( + freshell_freshagent::model_capabilities::CodexCatalogContext { + session_id: before.clone(), + session_ref: None, + cwd: cwd.map(str::to_string), + observed_epoch: None, + observed_generation: None, + }, + ) + .await + .map_err(|error| format!("{}: {}", error.code, error.message))?; + if self.current_native_id().await? != before { + return Err("model catalog source changed".into()); + } + Ok(value) + } + async fn read_child(&self, child_thread_id: &str) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -1942,6 +1965,79 @@ mod tests { } } + #[tokio::test] + async fn model_catalog_uses_the_same_initialized_native_actor_process_without_a_turn() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("requests.jsonl"); + let fixture=native_codex_fixture(json!({"appendRequestLogPath":log,"modelCatalogPages":[ + {"data":[{"id":"catalog-record","model":"gpt-6.1-sol","displayName":"Native Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}],"nextCursor":"1"}, + {"data":[{"id":"record-current","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}],"nextCursor":null}, + ]})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("catalog-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + // Ordinary creation queues its created + idle snapshot frames. Join + // that existing startup boundary before testing a read-only operation. + tokio::time::timeout(Duration::from_secs(10),async { + loop { + if actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event, + AgentEvent::Provider {payload} if payload["event"]["type"]=="freshAgent.session.snapshot" + && payload["event"]["status"]=="idle" && payload["sessionId"].as_str()==before.native_session_id.as_deref())) {break;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("ordinary native creation reaches its idle snapshot"); + let head = actor.read_events(0, 256).await.head; + let value = actor.model_catalog(Some(&before.cwd)).await.unwrap(); + let after = actor.profile().await; + let after_events = actor.read_events(head, 256).await; + let after_head = after_events.head; + actor.stop().await.unwrap(); + let requests = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(value[0]["id"], "gpt-6.1-sol"); + assert_eq!(value[1]["id"], "provider-current"); + assert_eq!(value[1]["defaultEffort"], "ultra"); + assert_eq!(before.native_session_id, after.native_session_id); + assert_eq!(before.model, after.model); + assert_eq!( + head, after_head, + "events during catalog read: {:?}", + after_events.events + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "model/list") + .count(), + 2 + ); + assert!(!requests.iter().any(|r| r["method"] == "turn/start" + || r["method"] == "thread/resume" + || r["method"] == "thread/fork")); + } + #[tokio::test] async fn task6_production_actor_owns_native_forwarding_through_fork_without_factory_handles() { let NativeCodexFixture { diff --git a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs index c00498502..b73782777 100644 --- a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs +++ b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs @@ -49,7 +49,7 @@ impl RuntimeBackend for NoBackend { } } -struct RunningFreshClaude { +struct RunningFreshAgent { _root: tempfile::TempDir, supervisor: Supervisor, soul: SoulId, @@ -57,7 +57,10 @@ struct RunningFreshClaude { runtime_dir: std::path::PathBuf, } -async fn running_fresh_claude() -> RunningFreshClaude { +async fn running_fresh_claude() -> RunningFreshAgent { + running_fresh_provider(FreshProvider::Claude).await +} +async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { let root = tempfile::tempdir().unwrap(); let workspace = std::fs::canonicalize(root.path()).unwrap(); let registry = Registry::open(root.path().join("registry"), None).unwrap(); @@ -65,7 +68,7 @@ async fn running_fresh_claude() -> RunningFreshClaude { let prepared = registry .prepare_launch(LaunchPreparation { soul_id: soul.clone(), - provider: "claude".into(), + provider: provider.as_str().into(), provider_store_id: "fresh-store".into(), native_session_id: None, creation_seed_ref: "configure-seed".into(), @@ -78,9 +81,19 @@ async fn running_fresh_claude() -> RunningFreshClaude { terminal: None, fresh_agent: Some(FreshAgentLaunchSpec { session_id: "fresh-session".into(), - provider: FreshProvider::Claude, - session_type: "freshclaude".into(), - runtime_variant: "claude-agent-sdk".into(), + provider: provider.clone(), + session_type: if provider == FreshProvider::Codex { + "freshcodex" + } else { + "freshclaude" + } + .into(), + runtime_variant: if provider == FreshProvider::Codex { + "codex-app-server" + } else { + "claude-agent-sdk" + } + .into(), provider_store_id: "fresh-store".into(), cwd: workspace.to_string_lossy().into_owned(), workspace_path: workspace.to_string_lossy().into_owned(), @@ -149,7 +162,7 @@ async fn running_fresh_claude() -> RunningFreshClaude { }, ) .unwrap(); - RunningFreshClaude { + RunningFreshAgent { _root: root, supervisor, soul, @@ -161,6 +174,12 @@ async fn running_fresh_claude() -> RunningFreshClaude { /// An authenticated session host that records every command it is sent. fn fake_host( runtime_dir: &std::path::Path, +) -> (Arc>>, tokio::task::JoinHandle<()>) { + fake_host_with_boot_change(runtime_dir, false) +} +fn fake_host_with_boot_change( + runtime_dir: &std::path::Path, + change_boot: bool, ) -> (Arc>>, tokio::task::JoinHandle<()>) { let secret = b"synthetic-host-secret"; std::fs::write(runtime_dir.join("secret"), secret).unwrap(); @@ -169,6 +188,7 @@ fn fake_host( let received = Arc::new(StdMutex::new(Vec::new())); let log = Arc::clone(&received); let task = tokio::spawn(async move { + let mut hello_count = 0; loop { let Ok((mut socket, _)) = listener.accept().await else { return; @@ -179,12 +199,26 @@ fn fake_host( HostCommand::Hello { incarnation_id, challenge, - } => HostResult::Hello { - host_boot_id: boot.clone(), - proof: host_proof(secret, &challenge, &boot, &incarnation_id), - capabilities: vec![], - effective_limits: crate::checkpoints::tests::limits(), - }, + } => { + hello_count += 1; + let current = if change_boot && hello_count > 1 { + HostBootId::new() + } else { + boot.clone() + }; + HostResult::Hello { + host_boot_id: current.clone(), + proof: host_proof(secret, &challenge, ¤t, &incarnation_id), + capabilities: vec![], + effective_limits: crate::checkpoints::tests::limits(), + } + } + HostCommand::FreshAgentModelCatalog { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some("/bound/project")); + HostResult::FreshAgentModelCatalog( + serde_json::json!([{"id":"provider-current","displayName":"Provider Current","supportedEffortLevels":["ultra"],"defaultEffort":"ultra"}]), + ) + } HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { state: CommandState::ProviderAcked, native_session_id: Some("native-claude".into()), @@ -437,3 +471,92 @@ fn host_configure_gets_the_fresh_agent_command_budget() { incarnation_id: IncarnationId::new(), })); } + +#[tokio::test] +async fn admin_model_catalog_uses_authenticated_current_host_and_preserves_recovery_settings() { + for scenario in ["live", "stale-epoch", "replaced-boot", "wrong-provider"] { + let fixture = running_fresh_provider(if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }) + .await; + let (received, host) = + fake_host_with_boot_change(&fixture.runtime_dir, scenario == "replaced-boot"); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let epoch = fixture.supervisor.registry.control_epoch(); + let mut request = Envelope::new( + RequestId::new(), + ControlRole::Web, + AdminCommand::FreshAgentModelCatalog(FreshAgentModelCatalogRequest { + soul_id: fixture.soul.clone(), + cwd: Some("/bound/project".into()), + expected_control_epoch: Some(if scenario == "stale-epoch" { + epoch + 1 + } else { + epoch + }), + }), + ); + request.auth = Some(SECRET.into()); + let reply = fixture.supervisor.dispatch(request).await; + host.abort(); + let _ = host.await; + match scenario { + "live" => assert!( + matches!(reply.result.unwrap(),AdminResult::FreshAgentModelCatalog(value) if value[0]["id"]=="provider-current" && value[0]["defaultEffort"]=="ultra") + ), + "stale-epoch" => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::StaleControlEpoch + ), + "wrong-provider" => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::UnsupportedOperation + ), + _ => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::OwnershipMismatch + ), + } + let commands = received.lock().unwrap(); + if scenario == "stale-epoch" || scenario == "wrong-provider" { + assert!(commands.is_empty()); + } else { + assert_eq!( + commands + .iter() + .filter(|c| matches!(c, HostCommand::FreshAgentModelCatalog { .. })) + .count(), + 1 + ); + assert!(commands.iter().all(|c| matches!( + c, + HostCommand::Hello { .. } | HostCommand::FreshAgentModelCatalog { .. } + ))); + for command in commands.iter() { + if let HostCommand::FreshAgentModelCatalog { incarnation_id, .. } = command { + assert_eq!(incarnation_id, &fixture.incarnation); + } + } + } + drop(commands); + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + assert_eq!( + before, after, + "catalog reads must never reconfigure recovery" + ); + } +} diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 3536b25b4..a538eb2b6 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -578,6 +578,15 @@ impl Supervisor { .await?, )) } + AdminCommand::FreshAgentModelCatalog(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentModelCatalog( + self.fresh_agent_model_catalog(request.soul_id, request.cwd) + .await?, + )) + } AdminCommand::FreshAgentReadSnapshot(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -1968,6 +1977,70 @@ impl Supervisor { } } + async fn fresh_agent_model_catalog( + &self, + soul_id: SoulId, + cwd: Option, + ) -> Result { + let lifecycle_lock = self.lifecycle_lock(&soul_id).await; + let _guard = lifecycle_lock.lock().await; + let handle = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::UnsupportedWorkload, + "soul is not a hosted fresh-agent", + ) + })?; + if agent.provider != freshell_runtime_protocol::FreshProvider::Codex { + return Err(RuntimeError::new( + RuntimeErrorCode::UnsupportedOperation, + "model catalog requires a bound Codex runtime", + )); + } + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + let reply = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentModelCatalog { + incarnation_id: handle.incarnation_id().clone(), + cwd, + }, + ) + .await?; + let HostResult::FreshAgentModelCatalog(value) = reply else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected model catalog reply", + )); + }; + let current = self + .registry + .active_handle_for_soul(soul_id) + .await + .map_err(map_registry)?; + let after = self + .authenticate_host(current.incarnation_id(), current.runtime_dir()) + .await?; + if current.incarnation_id() != handle.incarnation_id() + || current.fresh_agent() != handle.fresh_agent() + || host.host_boot_id != after.host_boot_id + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "model catalog source changed", + )); + } + Ok(value) + } + async fn fresh_agent_snapshot( &self, soul_id: SoulId, @@ -3344,6 +3417,7 @@ where reply.result, Ok(AdminResult::FreshAgentHistory(_) | AdminResult::FreshAgentSnapshot(_) + | AdminResult::FreshAgentModelCatalog(_) | AdminResult::FreshAgentChildSnapshot(_) | AdminResult::FreshAgentEvents(_)) ) { diff --git a/scripts/sandbox-test.sh b/scripts/sandbox-test.sh index 3394f7da1..4dbae86d4 100755 --- a/scripts/sandbox-test.sh +++ b/scripts/sandbox-test.sh @@ -140,6 +140,7 @@ else if [ "${RUNTIME_SUITE}" -eq 1 ]; then case "${CMD}" in 'node test/integration/providers/codex-cli-contract.mjs --scenario all' | \ + 'node test/integration/providers/codex-cli-contract.mjs --scenario model-effort-usage' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --missing-policy-client-probe' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario mcp-form-url' | \ 'node test/integration/providers/codex-cli-contract.mjs --scenario parent-child-read --child-approval' | \ diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index 75555902e..e91e55efc 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -55,7 +55,7 @@ export const FreshAgentTokenUsageSchema = z.object({ export const FreshAgentSettingsSchema = z.object({ model: z.string().min(1).optional(), permissionMode: z.string().min(1).optional(), - effort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional(), + effort: z.string().trim().min(1).optional(), plugins: z.array(z.string()).optional(), }).strict() diff --git a/shared/fresh-agent-model-capabilities.ts b/shared/fresh-agent-model-capabilities.ts index a03e0f44b..1d5dd8e20 100644 --- a/shared/fresh-agent-model-capabilities.ts +++ b/shared/fresh-agent-model-capabilities.ts @@ -1,4 +1,13 @@ import { z } from 'zod' +import type { SessionLocator } from './ws-protocol.js' + +export type FreshAgentCodexCatalogContext = { + sessionId: string + sessionRef?: SessionLocator + cwd?: string + observedEpoch?: number + observedGeneration?: number +} export const FRESH_AGENT_MODEL_CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000 @@ -45,6 +54,8 @@ export const FreshAgentModelCapabilitySchema = z.object({ supportsEffort: z.boolean(), supportedEffortLevels: z.array(FreshAgentModelCapabilitiesOpaqueStringSchema), supportsAdaptiveThinking: z.boolean(), + defaultEffort: FreshAgentModelCapabilitiesOpaqueStringSchema.optional(), + inputModalities: z.array(FreshAgentModelCapabilitiesOpaqueStringSchema).optional(), }).strict() export const FreshAgentModelCapabilitiesSchema = z.object({ diff --git a/shared/fresh-agent-models.ts b/shared/fresh-agent-models.ts index 7d24c6245..d1a13cd03 100644 --- a/shared/fresh-agent-models.ts +++ b/shared/fresh-agent-models.ts @@ -26,38 +26,8 @@ export const FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE = { defaultEffort: FRESHCLAUDE_DEFAULT_EFFORT, }, ], - freshcodex: [ - { - value: FRESHCODEX_DEFAULT_MODEL, - label: 'GPT-6 Astra', - thinkingEfforts: ['low', 'medium', 'high', 'xhigh', 'max'], - defaultEffort: FRESHCODEX_DEFAULT_EFFORT, - }, - { - value: 'gpt-5.6-sol', - label: 'GPT-5.6 Sol', - thinkingEfforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], - defaultEffort: FRESHCODEX_DEFAULT_EFFORT, - }, - { - value: 'gpt-5.6-terra', - label: 'GPT-5.6 Terra', - thinkingEfforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], - defaultEffort: FRESHCODEX_DEFAULT_EFFORT, - }, - { - value: 'gpt-5.6-luna', - label: 'GPT-5.6 Luna', - thinkingEfforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], - defaultEffort: FRESHCODEX_DEFAULT_EFFORT, - }, - { - value: 'gpt-5.3-codex-spark', - label: 'GPT-5.3 Codex Spark', - thinkingEfforts: ['none', 'minimal', 'low', 'medium', 'high', 'max'], - defaultEffort: FRESHCODEX_DEFAULT_EFFORT, - }, - ], + // A labeled input default, not an installed-model catalog. + freshcodex: [{ value:FRESHCODEX_DEFAULT_MODEL,label:'GPT-6 Astra',thinkingEfforts:[] as readonly string[],defaultEffort:FRESHCODEX_DEFAULT_EFFORT }], kilroy: [ { value: 'opus[1m]', @@ -85,8 +55,7 @@ export function resolveFreshAgentModelOption( } export function normalizeFreshcodexModel(model: string | undefined): string { - const option = FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex.find((candidate) => candidate.value === model) - return option?.value ?? FRESHCODEX_DEFAULT_MODEL + return model?.trim() || FRESHCODEX_DEFAULT_MODEL } export function normalizeFreshAgentModel( @@ -120,6 +89,7 @@ export function normalizeFreshAgentEffort( model: string | undefined, effort: string | undefined, ): string | undefined { + if (provider === 'codex') return effort?.trim() || undefined const options = getFreshAgentThinkingOptions(sessionType, provider, model) if (provider === 'opencode') { const normalizedModel = normalizeFreshAgentModel(sessionType, provider, model) @@ -139,7 +109,7 @@ export function normalizeFreshAgentEffort( return normalized.length > 0 ? normalized : undefined } } - const normalizedEffort = provider === 'codex' && effort === 'xhigh' ? 'max' : effort + const normalizedEffort = effort if (normalizedEffort && options.some((option) => option.value === normalizedEffort)) { return normalizedEffort } diff --git a/src/components/fresh-agent/FreshAgentModelDialog.tsx b/src/components/fresh-agent/FreshAgentModelDialog.tsx index f50c34cb1..27693db60 100644 --- a/src/components/fresh-agent/FreshAgentModelDialog.tsx +++ b/src/components/fresh-agent/FreshAgentModelDialog.tsx @@ -4,6 +4,7 @@ import { createPortal } from 'react-dom' import type { FreshAgentPaneContent } from '@/store/paneTypes' import { useAppDispatch, useAppSelector } from '@/store/hooks' import { mergePaneContent } from '@/store/panesSlice' +import { resolveCanonicalPaneSession, selectSessionRuntimeOwner } from '@/store/selectors/runtimeOwner' import { saveServerSettingsPatch } from '@/store/settingsThunks' import { sendFreshAgentConfigure } from '@/lib/fresh-agent-configure' import { getFreshAgentModelCapabilities } from '@/lib/api' @@ -36,7 +37,9 @@ import type { FreshAgentModelCapabilities, FreshAgentModelCapabilitiesResponse, FreshAgentModelCapability, + FreshAgentCodexCatalogContext, } from '@shared/fresh-agent-model-capabilities' +import { FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE } from '@shared/fresh-agent-models' import { highestThinkingLevelId, orderThinkingLevelIds } from '@shared/fresh-agent-thinking-levels' const MAX_RENDERED_MODEL_ROWS = 250 @@ -109,7 +112,17 @@ export function FreshAgentModelDialog({ const searchRef = useRef(null) const previousFocusRef = useRef(null) - const probeKey = `${sessionType}:${cwdKey}` + const runtimeOwner = useAppSelector((state) => { + const canonical = resolveCanonicalPaneSession(state,paneContent) + return canonical ? selectSessionRuntimeOwner(state,canonical.provider,canonical.sessionId) : undefined + }) + const codexContext = useMemo(() => sessionType === 'freshcodex' && paneContent.sessionId ? { + sessionId:paneContent.sessionId, + ...(paneContent.sessionRef ? {sessionRef:paneContent.sessionRef} : {}), + cwd:paneContent.initialCwd, + ...(runtimeOwner ? {observedEpoch:runtimeOwner.epoch,observedGeneration:runtimeOwner.generation} : {}), + } : undefined,[sessionType,paneContent.sessionId,paneContent.sessionRef,paneContent.initialCwd,runtimeOwner]) + const probeKey = JSON.stringify([sessionType,cwdKey,codexContext,paneContent.serverInstanceId]) const [probeResult, setProbeResult] = useState<{ key: string response: FreshAgentModelCapabilitiesResponse @@ -121,17 +134,21 @@ export function FreshAgentModelDialog({ const [highlightedRowKey, setHighlightedRowKey] = useState(undefined) const [highlightedLevelIndex, setHighlightedLevelIndex] = useState(0) const [recentModels, setRecentModels] = useState([]) + const [stagedModel,setStagedModel] = useState() + const [exactModelInput,setExactModelInput] = useState('') - // freshopencode + the claude providers (freshclaude/kilroy): probe the - // cwd-scoped live catalog each time the dialog opens (the settings popover - // makes the same call; the server caches it for 5 minutes). freshcodex - // never probes — its static table is synchronously known. + // A Codex pane probes only after ordinary creation supplies its bound context. useEffect(() => { - if (!open || sessionType === 'freshcodex') return + if (!open) return + if (sessionType === 'freshcodex' && !codexContext) { + setProbeResult(undefined) + setProbing(false) + return + } let cancelled = false setProbeResult(undefined) setProbing(true) - void getFreshAgentModelCapabilities(sessionType, { cwd: paneContent.initialCwd }) + void getFreshAgentModelCapabilities(sessionType, { cwd: paneContent.initialCwd, ...(codexContext ? {codexContext} : {}) }) .then((result) => { if (!cancelled) setProbeResult({ key: probeKey, response: result }) }) @@ -151,14 +168,36 @@ export function FreshAgentModelDialog({ if (!cancelled) setProbing(false) }) return () => { cancelled = true } - }, [open, sessionType, paneContent.provider, paneContent.initialCwd, probeKey]) + }, [open, sessionType, paneContent.provider, paneContent.initialCwd, probeKey, codexContext]) const staticCapabilities = useMemo(() => getFreshAgentStaticModelCapabilities(sessionType), [sessionType]) - const capabilities = useMemo(() => sessionType === 'freshopencode' - ? (probe?.ok ? probe : undefined) - : paneContent.provider === 'claude' && staticCapabilities - ? mergeClaudeModelCapabilities(staticCapabilities, probe?.ok === true ? probe : undefined) - : staticCapabilities, [sessionType, paneContent.provider, staticCapabilities, probe]) + const capabilities = useMemo(() => { + if (sessionType === 'freshcodex') { + const native = probe?.ok && probe.runtimeProvider === 'codex' && probe.sessionType === 'freshcodex' ? probe : undefined + const models = (native?.models ?? []).map((row) => row.id === effectiveModelId && effectiveEffort && !row.supportedEffortLevels.includes(effectiveEffort) + ? {...row,supportsEffort:true,supportedEffortLevels:[...row.supportedEffortLevels,effectiveEffort]} : row) + if (effectiveModelId && !models.some((row) => row.id === effectiveModelId)) { + models.push({id:effectiveModelId,displayName:paneContent.modelLabel?.modelId === effectiveModelId ? paneContent.modelLabel.label + : FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex.find((row) => row.value === effectiveModelId)?.label ?? effectiveModelId, + provider:'codex',source:{id:'current-selection',displayName:'Current selection'},supportsEffort:Boolean(effectiveEffort), + supportedEffortLevels:effectiveEffort ? [effectiveEffort] : [],supportsAdaptiveThinking:false}) + } + if (stagedModel) { + const index = models.findIndex((row) => row.id === stagedModel.id) + if (index < 0) models.push(stagedModel) + else models[index] = stagedModel + } + return {...(native ?? {sessionType:'freshcodex',runtimeProvider:'codex',status:'fresh',fetchedAt:0}),models} + } + return sessionType === 'freshopencode' ? (probe?.ok ? probe : undefined) + : paneContent.provider === 'claude' && staticCapabilities + ? mergeClaudeModelCapabilities(staticCapabilities,probe?.ok ? probe : undefined) : staticCapabilities + },[sessionType,paneContent.provider,paneContent.modelLabel,staticCapabilities,probe,effectiveModelId,effectiveEffort,stagedModel]) + const codexCatalogStatus = sessionType !== 'freshcodex' ? undefined + : !codexContext ? (paneContent.status === 'create-failed' ? 'Model catalog unavailable: the Codex runtime was not created.' : 'Waiting for the Codex runtime…') + : probing ? 'Loading model catalog…' + : probe?.ok === false ? 'Model catalog unavailable. Exact settings are preserved.' + : probe?.ok && !probe.models.some((row) => row.id === effectiveModelId) ? 'Current selection is unavailable in this catalog. Exact settings are preserved.' : undefined // Catalog-unavailable stays opencode-only: claude panes degrade to their // static rows (the same fallback the settings popover uses) instead of // closing with the unavailable notice. @@ -189,8 +228,10 @@ export function FreshAgentModelDialog({ const index = rows.findIndex((row) => row.kind === 'level' && row.id === lastUsed) if (index >= 0) return index } - return rows.length - 1 - }, [effectiveEffort, effectiveModelId, lastUsedLevelFor]) + const defaultEffort = capabilities?.models.find((model) => model.id === modelId)?.defaultEffort + const defaultIndex = rows.findIndex((row) => row.kind === 'level' && row.id === defaultEffort) + return defaultIndex >= 0 ? defaultIndex : rows.length - 1 + }, [effectiveEffort, effectiveModelId, lastUsedLevelFor, capabilities]) // Reset only when opening or changing panes, never when an asynchronous // catalog arrives while the user is typing or choosing a level. @@ -199,6 +240,8 @@ export function FreshAgentModelDialog({ setActiveColumn('models') setHighlightedRowKey(undefined) setHighlightedLevelIndex(0) + setStagedModel(undefined) + setExactModelInput('') }, [open, paneId, cwdKey, sessionType]) useEffect(() => { @@ -213,7 +256,7 @@ export function FreshAgentModelDialog({ if (current && (paneContent.provider !== 'claude' || existing.length > 0)) { recordFreshAgentModelUse(mruProvider, current, cwdKey) } - if (paneContent.provider !== 'claude' || probe?.ok) { + if (paneContent.provider === 'opencode' || (paneContent.provider === 'claude' && probe?.ok)) { pruneFreshAgentModelMru(mruProvider, capabilities, cwdKey) } recent = buildFreshAgentVisibleMru(mruProvider, { @@ -292,9 +335,10 @@ export function FreshAgentModelDialog({ // The highlighted model row: the user's staged selection while it stays // visible, else the pane's effective current model, else the first row. - const highlightedRow = flatModelRows.find((row) => row.key === highlightedRowKey && highlightedRowKey !== undefined) + const highlightedRow = useMemo(() => flatModelRows.find((row) => row.key === highlightedRowKey && highlightedRowKey !== undefined) ?? flatModelRows.find((row) => row.model.id === effectiveModelId) - ?? flatModelRows[0] + ?? (sessionType === 'freshcodex' && stagedModel ? {key:`catalog:${stagedModel.id}`,model:stagedModel} : undefined) + ?? flatModelRows[0], [flatModelRows, highlightedRowKey, effectiveModelId, sessionType, stagedModel]) const highlightedModel = highlightedRow?.model const levelRows: LevelRow[] = highlightedModel ? levelRowsForModel(highlightedModel) : [] // The level highlight is user-set only when the highlighted model row is @@ -317,10 +361,11 @@ export function FreshAgentModelDialog({ : undefined const selectModelRow = useCallback((row: ModelRow) => { + if (sessionType === 'freshcodex') setStagedModel(row.model) setHighlightedRowKey(row.key) setHighlightedLevelIndex(preselectLevelIndex(levelRowsForModel(row.model), row.model.id)) setActiveColumn('models') - }, [preselectLevelIndex]) + }, [preselectLevelIndex,sessionType]) const moveModelHighlight = useCallback((delta: 1 | -1) => { if (flatModelRows.length === 0 || !highlightedRow) return @@ -494,6 +539,22 @@ export function FreshAgentModelDialog({ onKeyDown={handleSearchKeyDown} />
+ {codexCatalogStatus ?

{codexCatalogStatus}

: null} + {sessionType === 'freshcodex' ?
+ + +
: null} {!capabilities ? (

Loading model catalog… @@ -541,6 +602,7 @@ export function FreshAgentModelDialog({ : undefined const isHighlighted = row === highlightedLevelRow const stageLevel = () => { + if (sessionType === 'freshcodex' && highlightedModel) setStagedModel(highlightedModel) if (highlightedRow && !highlightedIsUserStaged) setHighlightedRowKey(highlightedRow.key) setHighlightedLevelIndex(index) setActiveColumn('levels') diff --git a/src/lib/api.ts b/src/lib/api.ts index e253c73db..084d8bb83 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -20,6 +20,7 @@ import type { RecoveryInventory } from '@/lib/recovery/types' import type { Machine } from '@/lib/machine-identity' import { type FreshAgentModelCapabilitiesResponse, + type FreshAgentCodexCatalogContext, } from '@shared/fresh-agent-model-capabilities' import { ManagedRuntimeIncidentSummarySchema, @@ -612,10 +613,19 @@ export async function auditManagedRuntimeRepair( export async function getFreshAgentModelCapabilities( sessionType: string, - options: ApiRequestOptions & { cwd?: string } = {}, + options: ApiRequestOptions & { cwd?: string; codexContext?: FreshAgentCodexCatalogContext } = {}, ): Promise { - const { cwd, ...requestOptions } = options - const query = cwd ? `?${new URLSearchParams({ cwd }).toString()}` : '' + const { cwd, codexContext, ...requestOptions } = options + const params = new URLSearchParams() + if (codexContext) { + params.set('sessionId',codexContext.sessionId) + if (codexContext.sessionRef) params.set('sessionRef',JSON.stringify(codexContext.sessionRef)) + if (codexContext.observedEpoch !== undefined) params.set('observedEpoch',String(codexContext.observedEpoch)) + if (codexContext.observedGeneration !== undefined) params.set('observedGeneration',String(codexContext.observedGeneration)) + } + const catalogCwd = codexContext?.cwd ?? cwd + if (catalogCwd) params.set('cwd',catalogCwd) + const query = params.size ? `?${params.toString()}` : '' try { return parseFreshAgentModelCapabilitiesResponse( await api.get(`/api/fresh-agent/model-capabilities/${encodeURIComponent(sessionType)}${query}`, requestOptions), @@ -668,10 +678,19 @@ export async function getFreshAgentCheckpointOutcomePage( export async function refreshFreshAgentModelCapabilities( sessionType: string, - options: ApiRequestOptions & { cwd?: string } = {}, + options: ApiRequestOptions & { cwd?: string; codexContext?: FreshAgentCodexCatalogContext } = {}, ): Promise { - const { cwd, ...requestOptions } = options - const query = cwd ? `?${new URLSearchParams({ cwd }).toString()}` : '' + const { cwd, codexContext, ...requestOptions } = options + const params = new URLSearchParams() + if (codexContext) { + params.set('sessionId',codexContext.sessionId) + if (codexContext.sessionRef) params.set('sessionRef',JSON.stringify(codexContext.sessionRef)) + if (codexContext.observedEpoch !== undefined) params.set('observedEpoch',String(codexContext.observedEpoch)) + if (codexContext.observedGeneration !== undefined) params.set('observedGeneration',String(codexContext.observedGeneration)) + } + const catalogCwd = codexContext?.cwd ?? cwd + if (catalogCwd) params.set('cwd',catalogCwd) + const query = params.size ? `?${params.toString()}` : '' try { return parseFreshAgentModelCapabilitiesResponse( await api.post(`/api/fresh-agent/model-capabilities/${encodeURIComponent(sessionType)}/refresh${query}`, {}, requestOptions), diff --git a/src/lib/fresh-agent-model-capabilities.ts b/src/lib/fresh-agent-model-capabilities.ts index 752b45ff3..7f0a6e651 100644 --- a/src/lib/fresh-agent-model-capabilities.ts +++ b/src/lib/fresh-agent-model-capabilities.ts @@ -439,9 +439,9 @@ export function capFreshAgentModelSourceRows( /** * Map a provider's baked-in model menu into the live-catalog capability shape * so the shared model+thinking dialog treats baked-in and probed catalogs as - * data-only differences. freshcodex and the claude providers - * (freshclaude/kilroy) have baked-in static tables; freshopencode models come - * from the probed catalog alone. The claude arm carries no `source`, so + * data-only differences. Claude providers (freshclaude/kilroy) retain their + * static aliases. Codex requires a bound native catalog and OpenCode uses its + * probed catalog. The claude arm carries no `source`, so * statics and probed rows group together under the provider fallback exactly * as the probed claude catalog does. */ @@ -466,22 +466,5 @@ export function getFreshAgentStaticModelCapabilities( models, } } - if (sessionType !== 'freshcodex') return undefined - const models: FreshAgentModelCapability[] = FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex - .map((option) => ({ - id: option.value, - displayName: option.label, - provider: 'codex' as const, - source: { id: 'openai', displayName: 'openai' }, - supportsEffort: (option.thinkingEfforts?.length ?? 0) > 0, - supportedEffortLevels: [...(option.thinkingEfforts ?? [])], - supportsAdaptiveThinking: false, - })) - return { - sessionType: 'freshcodex', - runtimeProvider: 'codex', - status: 'fresh', - fetchedAt: 0, - models, - } + return undefined } diff --git a/src/lib/fresh-agent-registry.ts b/src/lib/fresh-agent-registry.ts index 7b120f910..1806842c0 100644 --- a/src/lib/fresh-agent-registry.ts +++ b/src/lib/fresh-agent-registry.ts @@ -45,7 +45,7 @@ export type FreshAgentRegistryEntry = { icon: React.ComponentType<{ className?: string }> defaultModel?: string defaultPermissionMode: string - defaultEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' + defaultEffort: string settingsVisibility: { model: boolean permissionMode: boolean @@ -319,6 +319,7 @@ export function resolveFreshAgentPaneCreateEffort(args: { modelEffortLevels?: readonly string[] }): string | undefined { const { sessionType, provider, model, providerEffort, fallbackEffort, modelEffortLevels } = args + if (provider === 'codex') return normalizeFreshAgentEffort(sessionType,provider,model,providerEffort) if (usesStampedEffortLevels(sessionType, provider, model, modelEffortLevels)) { return clampEffortToStampedLevels(modelEffortLevels, providerEffort ?? fallbackEffort) } diff --git a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts index 5287adbac..48a88dbb7 100644 --- a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts @@ -78,7 +78,7 @@ test('Codex picker waits for ordinary create and shows honest failure with exact await expect(dialog.getByRole('option', { name: 'Provider Current' })).toBeVisible() await expect(dialog.getByRole('searchbox')).toHaveValue('provider') expect(probes).toBe(1) - await setStatus('error') + await setStatus('create-failed') await expect(dialog.getByRole('status')).toContainText(/catalog unavailable/i) await dialog.getByRole('searchbox').fill('') await expect(dialog.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() @@ -147,12 +147,16 @@ test('Claude model search survives catalog loading and remembers a selected thin test('Codex cancels a staged model change and preserves its current thinking level', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**',route=>route.fulfill({json:{...codexCatalog,models:[ + {id:FRESHCODEX_DEFAULT_MODEL,displayName:'GPT-6 Astra',provider:'codex',supportsEffort:true,supportedEffortLevels:['low','high','xhigh'],defaultEffort:'high',supportsAdaptiveThinking:false}, + ...codexCatalog.models, + ]}})) await installPane(page, 'codex') const chip = page.getByRole('button', { name: /^Model:.*change model$/ }) await chip.click() const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) await expect(dialog.getByRole('button', { name: 'Use GPT-6 Astra · low' })).toBeVisible() - await dialog.getByRole('option', { name: /GPT-5.6 Sol/ }).click() + await dialog.getByRole('option', { name: 'Provider Current', exact:true }).click() await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() await chip.click() await dialog.getByRole('button', { name: 'Use GPT-6 Astra · low' }).click() 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 ed6d96cbf..e0436e2f0 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -313,7 +313,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { } }) - test('codex: create-shaped resume after restart carries the recorded model', async () => { + test('codex: configured xhigh and recorded model survive restart and the next send', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-codex-')) const opLogPath = path.join(sharedRoot, 'codex-ops.jsonl') @@ -325,7 +325,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { CODEX_CMD: `${process.execPath} ${CODEX_FIXTURE}`, // STATIC per server boot (V4/A6): resume succeeds by default. FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ - appendThreadOperationLogPath: opLogPath, + appendRequestLogPath: opLogPath, }), }, }) @@ -354,6 +354,9 @@ test.describe('fresh-agent settings survive restart (rust)', () => { const threadId = created.sessionId as string expect(threadId).toBeTruthy() + ws.send({type:'freshAgent.configure',requestId:'codex-settings-xhigh',provider:'codex',sessionType:'freshcodex',sessionId:threadId,settings:{effort:'xhigh'}}) + await ws.waitFor(f=>f.type==='freshAgent.event' && f.event?.type==='freshAgent.session.metadata' && f.event.effort==='xhigh',30_000,'exact xhigh metadata after configure') + // One turn. `freshAgent.send.accepted` is broadcast after `turn/start` // resolves (codex.rs handle_send), and the fake answers turn/start with a // completed turn -- so this IS the turn's completion edge on this fixture. @@ -398,6 +401,12 @@ test.describe('fresh-agent settings survive restart (rust)', () => { expect(resume, 'restart must resume the durable thread').toBeTruthy() expect(resume.params.model, 'resume must carry the recorded model, not null').toBe('gpt-5.3-codex-spark') expect(resume.params.sandbox, 'resume must carry the recorded sandbox').toBe('workspace-write') + ws.send({type:'freshAgent.send',requestId:'codex-settings-after-restart',provider:'codex',sessionType:'freshcodex',sessionId:threadId,text:'settings-resume second turn'}) + await ws.waitFor(f=>f.type==='freshAgent.send.accepted' && f.requestId==='codex-settings-after-restart',30_000,'post-restart accepted send') + const turns=readJsonl(opLogPath).filter(op=>op.method==='turn/start') + expect(turns).toHaveLength(2) + expect(turns.map(turn=>turn.params.model)).toEqual(['gpt-5.3-codex-spark','gpt-5.3-codex-spark']) + expect(turns.map(turn=>turn.params.effort)).toEqual(['xhigh','xhigh']) } finally { ws?.close() await server.stop().catch(() => {}) diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index b76e032ab..cfc57884f 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -332,7 +332,7 @@ function successResult(method, params) { } if (method === 'configRequirements/read') return { requirements: behavior.configRequirements ?? null } if (method === 'model/list') { - const pages = behavior.modelCatalogPages ?? [{ data: [ + const pages = behavior.modelCatalogPagesByCwd?.[process.cwd()] ?? behavior.modelCatalogPages ?? [{ data: [ { id: 'native-record', model: 'provider-current', displayName: 'Provider Current', supportedReasoningEfforts: [{ reasoningEffort: 'none' }, { reasoningEffort: 'ultra' }], defaultReasoningEffort: 'ultra', inputModalities: ['text', 'image'] }, @@ -815,7 +815,7 @@ wss.on('connection', (socket) => { } const method = message.method if (behavior.appendRequestLogPath) { - fs.appendFileSync(behavior.appendRequestLogPath, JSON.stringify({ method, params: message.params, codexHome: getCodexHome() }) + '\n') + fs.appendFileSync(behavior.appendRequestLogPath, JSON.stringify({ method, params: message.params, codexHome: getCodexHome(), cwd: process.cwd() }) + '\n') } if ( diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs index a880e0e2b..f6655fe7f 100644 --- a/test/integration/providers/codex-cli-contract.mjs +++ b/test/integration/providers/codex-cli-contract.mjs @@ -247,6 +247,75 @@ async function nativeRequirementsGuard(context, capture) { capture.observations.requirementsGuard.ownedListenerSettled = true } +async function nativeCatalogProbe(context,capture,fixture) { + const trafficStart=fixture.traffic.length + const reservation=http.createServer() + await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve)) + const port=reservation.address().port + await new Promise(resolve=>reservation.close(resolve)) + const native=new NativeClient(capture.binary.path,context,capture,['app-server','--listen',`ws://127.0.0.1:${port}`]) + const ready=()=>new Promise(resolve=>{ + const request=http.get(`http://127.0.0.1:${port}/readyz`,response=>{response.resume();resolve(response.statusCode===200)}) + request.on('error',()=>resolve(false));request.setTimeout(1000,()=>request.destroy()) + }) + try { + await deadline((async()=>{ + while (!await ready()) { + assert.equal(native.child.exitCode,null,capture.stderr) + await new Promise(resolve=>setTimeout(resolve,50)) + } + })(),15_000,'owned model-catalog native listener') + const receipt=path.join(context.stateRoot,'catalog-client-proof.json') + const probe=spawn(context.policyClientProbe.path,['--exact','catalog::native_tests::native_catalog_and_opaque_effort_use_initialized_client','--nocapture'],{ + cwd:context.workspace,env:{...context.environment,FRESHELL_CODEX_CATALOG_PROBE_URL:`ws://127.0.0.1:${port}`,FRESHELL_CODEX_CATALOG_PROBE_RECEIPT:receipt},stdio:['ignore','pipe','pipe'], + }) + let stdout='',stderr='' + probe.stdout.on('data',bytes=>{stdout+=bytes});probe.stderr.on('data',bytes=>{stderr+=bytes}) + const exited=new Promise((resolve,reject)=>{probe.once('exit',(code,signal)=>resolve({code,signal}));probe.once('error',reject)}) + let result + try { result=await deadline(exited,120_000,'real production catalog client') } + finally { if (probe.exitCode===null && probe.signalCode===null) {probe.kill('SIGKILL');await exited} } + assert.equal(result.code,0,`${stdout}\n${stderr}`) + assert.match(stdout,/1 passed; 0 failed/) + const proof=JSON.parse(await fsp.readFile(receipt,'utf8')) + assert(proof.models.length>0) + assert.equal(proof.nativeAcceptedFutureEffort,true) + assert(proof.futureEffortCompletedTurn) + assert.equal(proof.nativeExactLifecycleAccepted,true) + assert.equal(proof.completedTurns.length,4) + const traffic=fixture.traffic.slice(trafficStart) + assert.equal(traffic.length,7,'four lifecycle turns, one future-effort turn and two advertised-setting turns must each reach the controlled provider once') + assert(traffic.slice(0,5).every(request=>request.body.model==='provider-current'),'custom native model work must use the exact model omitted from the catalog') + // Record both boundaries: native receives exact public effort strings, + // and its downstream HTTP reasoning can depend on the selected model. + const nativeTurns=proof.frames.filter(entry=>entry.direction==='controller' && entry.frame.method==='turn/start') + assert.equal(nativeTurns.length,7) + assert(nativeTurns.slice(0,4).every(entry=>entry.frame.params.effort==='ultra')) + assert.equal(nativeTurns[4].frame.params.effort,'future-unadvertised-native-effort') + assert.equal(traffic[4].body.reasoning.effort,'future-unadvertised-native-effort') + const advertised=proof.advertisedSelection + assert(proof.models.some(model=>model.id===advertised.model.id)) + assert.equal(advertised.turns.length,2) + const matchedAdvertisedSettings=advertised.turns.map((turn,index)=>{ + const nativeRequest=nativeTurns[index+5].frame.params + const http=traffic[index+5].body + assert(advertised.model.supportedEffortLevels.includes(turn.effort)) + assert.equal(nativeRequest.model,advertised.model.id) + assert.equal(nativeRequest.effort,turn.effort) + assert.equal(nativeRequest.approvalPolicy,'never') + assert.equal(http.model,advertised.model.id) + assert(turn.turnId) + return {...turn,nativeRequest,http} + }) + const methods=proof.frames.filter(entry=>entry.direction==='controller' && entry.frame.id!==undefined).map(entry=>entry.frame.method) + assert.equal(methods.filter(method=>method==='initialize').length,1) + const firstWork=methods.indexOf('thread/start') + assert(firstWork>0 && methods.slice(0,firstWork).every(method=>['initialize','model/list'].includes(method))) + capture.observations.productionCatalogClient={...proof,result,stdout,stderr,fixtureRequestCount:traffic.length,nativeHttpEfforts:traffic.map(request=>request.body.reasoning.effort),matchedAdvertisedSettings} + } finally {await native.close()} + assert.equal(await ready(),false,'owned native catalog listener must close') +} + function assistantText(read, nativeTurnId) { return read.thread.turns.filter(turn => !nativeTurnId || turn.id === nativeTurnId).flatMap(turn => turn.items.filter(item => item.type === 'agentMessage').map(item => item.text)).join('\n') } @@ -892,6 +961,7 @@ export async function captureNativeScenario(name, options = {}) { capture.passed = true return capture } + if (name === 'model-effort-usage') await nativeCatalogProbe(context,capture,fixture) client = new NativeClient(capture.binary.path, context, capture) client.onServerRequest = frame => { if (frame.method === 'item/commandExecution/requestApproval' && !options.approvalControl) { @@ -1092,8 +1162,9 @@ export async function captureNativeScenario(name, options = {}) { assert.equal(usage?.last?.totalTokens, 15, 'usage must come from the actual model completion') capture.observations.latestUsage = usage.last capture.observations.usageLabel = 'Latest usage' - assert.equal(fixture.traffic[0].body.model, 'contract-model') - assert.equal(fixture.traffic[0].body.reasoning.effort, 'low') + const primaryFirst=fixture.traffic[capture.observations.productionCatalogClient.fixtureRequestCount] + assert.equal(primaryFirst.body.model, 'contract-model') + assert.equal(primaryFirst.body.reasoning.effort, 'low') } if (restricted) { const commandItems = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'commandExecution').map(frame => frame.params.item) diff --git a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx index 34197c6b6..86a686edd 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx @@ -582,6 +582,13 @@ describe('FreshAgentModelDialog (freshopencode)', () => { }) describe('FreshAgentModelDialog (freshcodex)', () => { + beforeEach(() => getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ + ok:true,sessionType:'freshcodex',runtimeProvider:'codex',status:'fresh',fetchedAt:1234, + models:[['gpt-6-astra','GPT-6 Astra'],['gpt-5.6-luna','GPT-5.6 Luna'],['gpt-5.6-sol','GPT-5.6 Sol']].map(([id,displayName]) => ({ + id,displayName,provider:'codex',supportsEffort:true,supportedEffortLevels:['low','medium','high','xhigh','max'],defaultEffort:'max',supportsAdaptiveThinking:false, + })), + })) + const nativeCatalog = { ok: true as const, sessionType: 'freshcodex' as const, runtimeProvider: 'codex' as const, status: 'fresh' as const, fetchedAt: 1234, @@ -655,7 +662,7 @@ describe('FreshAgentModelDialog (freshcodex)', () => { it('shows unavailable after failed ordinary creation without creating a catalog runtime', () => { const store = createStore() - seedFreshcodexPane(store, { sessionId: undefined, status: 'error', model: 'saved-custom', effort: 'ultra' }) + seedFreshcodexPane(store, { sessionId: undefined, status: 'create-failed', model: 'saved-custom', effort: 'ultra' }) renderDialog(store, { open: true }) expect(screen.getByRole('status')).toHaveTextContent(/catalog unavailable/i) expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() @@ -670,35 +677,16 @@ describe('FreshAgentModelDialog (freshcodex)', () => { expect(paneContent(store).effort).toBe('low') }) - it('discards a cancelled model selection when the dialog reopens', () => { + it('discards a cancelled model selection when the dialog reopens', async () => { const store = createStore() seedFreshcodexPane(store) const view = renderDialog(store, { open: true }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) view.rerender() view.rerender() fireEvent.click(screen.getByRole('button', { name: 'Use GPT-6 Astra · max' })) expect(paneContent(store).model).toBe('gpt-6-astra') }) - it('uses the static freshcodex table without probing the catalog endpoint', async () => { - const store = createStore() - seedFreshcodexPane(store) - - renderDialog(store, { open: true }) - - await screen.findByRole('dialog', { name: 'Model and thinking level' }) - expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() - // the current model is marked everywhere it appears (Recent + its group) - expect(screen.getAllByRole('option', { name: /GPT-6 Astra.*current/ }).length).toBeGreaterThanOrEqual(1) - expect(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })).toBeInTheDocument() - - // GPT-6 Astra levels canonically ordered - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' }) - const levelNames = Array.from(levelsList.querySelectorAll('[role="option"]')).map((el) => el.textContent) - expect(levelNames.map((name) => name?.replace(/last used|highest|current|●/g, '').trim())).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) - expect(screen.getByRole('button', { name: 'Use GPT-6 Astra · max' })).toBeInTheDocument() - }) - it('commits a freshcodex model + level under the freshcodex provider defaults and MRU scope', async () => { const onClose = vi.fn() const store = createStore() @@ -729,6 +717,22 @@ describe('FreshAgentModelDialog (freshcodex)', () => { const levelMru = JSON.parse(window.localStorage.getItem('freshcodex.modelLevelMru.v1') ?? '[]') expect(levelMru).toEqual([expect.objectContaining({ modelId: 'gpt-5.6-luna', level: 'low' })]) }) + + it('uses the bound native catalog while preserving the current row, advertised models and thinking levels', async () => { + const store = createStore() + seedFreshcodexPane(store) + renderDialog(store, {open:true}) + await screen.findByRole('option', {name:/GPT-5\.6 Luna/}) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledWith('freshcodex',expect.objectContaining({ + codexContext:expect.objectContaining({sessionId:'thread-dialog',cwd:'/repo/project-a'}), + })) + expect(screen.getAllByRole('option',{name:/GPT-6 Astra.*current/}).length).toBeGreaterThanOrEqual(1) + const levels = screen.getByRole('listbox',{name:'Thinking levels for GPT-6 Astra'}) + const names = Array.from(levels.querySelectorAll('[role="option"]')).map((el)=>el.textContent?.replace(/last used|highest|current|●/g,'').trim()) + expect(names).toEqual(['low','medium','high','xhigh','max']) + fireEvent.click(screen.getByRole('button',{name:'Use GPT-6 Astra · max'})) + expect(paneContent(store)).toMatchObject({model:'gpt-6-astra',effort:'max'}) + }) }) describe('FreshAgentModelDialog (freshclaude)', () => { diff --git a/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx index d1f68c253..99869b392 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx @@ -328,6 +328,11 @@ describe('FreshAgentSettingsButton', () => { }) it('opens the shared dialog from the freshcodex Change… button and persists the committed choice', async () => { + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() seedPane(store, { sessionType: 'freshcodex', @@ -341,7 +346,7 @@ describe('FreshAgentSettingsButton', () => { fireEvent.click(screen.getByRole('button', { name: /Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const lowOption = Array.from(levelsList.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes('low')) expect(lowOption).toBeDefined() diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index bd97eb4b7..8502937c7 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -2525,7 +2525,7 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', model: 'gpt-6-astra', - effort: 'max', + effort: undefined, })) const onMessage = wsMock.onMessage.mock.calls[0]?.[0] @@ -3501,7 +3501,6 @@ describe('FreshAgentView', () => { settings: { cwd: '/repo', model: 'gpt-5.3-codex-spark', - effort: 'max', }, })) }) @@ -6924,6 +6923,11 @@ describe('FreshAgentView', () => { }) it('lets Freshcodex settings choose model and thinking level from the gear popover’s Change… dialog', async () => { + apiMock.getFreshAgentModelCapabilities.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -6955,11 +6959,11 @@ describe('FreshAgentView', () => { fireEvent.click(screen.getByRole('button', { name: /Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) // GPT-5.6 Luna declares the current GPT-5.6 reasoning levels in // canonical order. - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) + const levelsList = await screen.findByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const levelTexts = Array.from(levelsList.querySelectorAll('[role="option"]')).map((el) => el.textContent) expect(levelTexts.map((text) => text?.replace(/last used|highest|current|●/g, '').trim())).toEqual( ['none', 'low', 'medium', 'high', 'xhigh', 'max'], @@ -6986,6 +6990,11 @@ describe('FreshAgentView', () => { }) it('persists Freshcodex thinking and permission settings as fresh-agent provider defaults', async () => { + apiMock.getFreshAgentModelCapabilities.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -7011,9 +7020,9 @@ describe('FreshAgentView', () => { fireEvent.click(screen.getByRole('button', { name: 'Agent settings' })) // Thinking now persists through the Change… dialog, not a retired dropdown. - fireEvent.click(screen.getByRole('button', { name: /GPT-5\.6 Luna · medium.*Change/ })) + fireEvent.click(screen.getByRole('button', { name: /gpt-5\.6-luna · medium.*Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) + const levelsList = await screen.findByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const highOption = Array.from(levelsList.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes('high')) expect(highOption).toBeDefined() fireEvent.click(highOption!) @@ -8203,7 +8212,7 @@ describe('FreshAgentView', () => { expect(screen.queryByRole('radio', { name: 'custom-codex-model' })).not.toBeInTheDocument() }) - it('normalizes stale Freshcodex thinking effort before create and send', async () => { + it('preserves explicit Freshcodex xhigh effort before create and send', async () => { const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -8227,7 +8236,7 @@ describe('FreshAgentView', () => { expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.create', requestId: 'req-stale-effort', - effort: 'max', + effort: 'xhigh', })) const onMessage = wsMock.onMessage.mock.calls[0]?.[0] @@ -8253,7 +8262,7 @@ describe('FreshAgentView', () => { expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.send', - settings: expect.objectContaining({ effort: 'max' }), + settings: expect.objectContaining({ effort: 'xhigh' }), })) }) @@ -11673,7 +11682,7 @@ describe('FreshAgentView /model slash command', () => { expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(0) }) - it('opens the shared model dialog for freshcodex without any catalog probe', async () => { + it('opens the shared model dialog for freshcodex using the bound native catalog context', async () => { const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -11699,7 +11708,9 @@ describe('FreshAgentView /model slash command', () => { fireEvent.click(screen.getByRole('button', { name: 'Send' })) expect(await screen.findByRole('dialog', { name: 'Model and thinking level' })).toBeInTheDocument() - expect(apiMock.getFreshAgentModelCapabilities).not.toHaveBeenCalled() + expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ sessionId: 'ses_model_cmd', sessionRef: { provider: 'codex', sessionId: 'ses_model_cmd' } }), + })) }) it('shows the shared catalog-unavailable notice instead of an empty dialog when the freshopencode probe fails', async () => { diff --git a/test/unit/client/lib/fresh-agent-create-content.test.ts b/test/unit/client/lib/fresh-agent-create-content.test.ts index 12f4c1a0c..dae978438 100644 --- a/test/unit/client/lib/fresh-agent-create-content.test.ts +++ b/test/unit/client/lib/fresh-agent-create-content.test.ts @@ -5,6 +5,18 @@ import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' describe('fresh-agent create defaults shared with REST', () => { + it('leaves an omitted Codex effort to the installed model instead of fabricating max for a custom model', () => { + const defaults = createDefaultServerSettings() + const content = buildFreshAgentPaneContent({ + entry: resolveFreshAgentType('freshcodex')!, createRequestId: 'native-default', + codingCliSettings: defaults.codingCli, + freshAgentSettings: {...defaults.freshAgent,providers:{...defaults.freshAgent.providers, + freshcodex:{modelSelection:{kind:'exact',modelId:'provider-current'}}, + }}, + }) + expect(content.model).toBe('provider-current') + expect(content.effort).toBeUndefined() + }) it('creates Codex with the exact saved custom model and native effort', () => { const defaults = createDefaultServerSettings() const content = buildFreshAgentPaneContent({ diff --git a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts index 948936206..519de1a1e 100644 --- a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts +++ b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts @@ -660,34 +660,6 @@ describe('mergeClaudeModelCapabilities', () => { }) describe('fresh-agent-model-capabilities static catalog mapping', () => { - it('maps the freshcodex static menu into the shared capability shape', () => { - const capabilities = getFreshAgentStaticModelCapabilities('freshcodex') - - expect(capabilities).toMatchObject({ - sessionType: 'freshcodex', - runtimeProvider: 'codex', - status: 'fresh', - }) - expect(capabilities?.models.map((model) => model.id)).toEqual([ - 'gpt-6-astra', - 'gpt-5.6-sol', - 'gpt-5.6-terra', - 'gpt-5.6-luna', - 'gpt-5.3-codex-spark', - ]) - expect(capabilities?.models[0]).toMatchObject({ - displayName: 'GPT-6 Astra', - provider: 'codex', - supportsEffort: true, - supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], - supportsAdaptiveThinking: false, - }) - expect(capabilities?.models[1]?.supportedEffortLevels).toEqual(['none', 'low', 'medium', 'high', 'xhigh', 'max']) - for (const model of capabilities?.models ?? []) { - expect(model.source).toEqual({ id: 'openai', displayName: 'openai' }) - } - }) - it('maps the claude static menu into the shared capability shape', () => { for (const sessionType of ['freshclaude', 'kilroy'] as const) { const capabilities = getFreshAgentStaticModelCapabilities(sessionType) diff --git a/test/unit/shared/fresh-agent-models.test.ts b/test/unit/shared/fresh-agent-models.test.ts index 4f128b7ff..a66a4aea2 100644 --- a/test/unit/shared/fresh-agent-models.test.ts +++ b/test/unit/shared/fresh-agent-models.test.ts @@ -42,21 +42,10 @@ describe('fresh-agent-models freshopencode static menu', () => { }) describe('fresh-agent-models freshcodex static menu', () => { - it('tracks the current Codex model availability list', () => { - expect(FRESHCODEX_DEFAULT_MODEL).toBe('gpt-6-astra') - expect(FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex.map((option) => option.value)).toEqual([ - 'gpt-6-astra', - 'gpt-5.6-sol', - 'gpt-5.6-terra', - 'gpt-5.6-luna', - 'gpt-5.3-codex-spark', - ]) - }) - - it('falls back retired or unknown freshcodex models to the current default', () => { - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4')).toBe('gpt-6-astra') - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4-mini')).toBe('gpt-6-astra') - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'unknown-model')).toBe('gpt-6-astra') + it('preserves retired or custom model IDs for native validation', () => { + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4')).toBe('gpt-5.4') + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini') + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'unknown-model')).toBe('unknown-model') }) }) diff --git a/test/unit/shared/fresh-agent-registry.test.ts b/test/unit/shared/fresh-agent-registry.test.ts index 59586659e..856bae720 100644 --- a/test/unit/shared/fresh-agent-registry.test.ts +++ b/test/unit/shared/fresh-agent-registry.test.ts @@ -31,7 +31,7 @@ describe('fresh-agent registry', () => { }) describe('resolveFreshAgentPaneCreateEffort', () => { - it('keeps claude/codex falling back to the registry default effort', () => { + it('keeps the Claude fallback and leaves omitted Codex effort to its native default', () => { expect(resolveFreshAgentPaneCreateEffort({ sessionType: 'freshclaude', provider: 'claude', @@ -45,7 +45,7 @@ describe('resolveFreshAgentPaneCreateEffort', () => { model: 'gpt-5.5', providerEffort: undefined, fallbackEffort: 'max', - })).toBe('max') + })).toBeUndefined() }) it('passes an explicit freshopencode provider default through for live-catalog models', () => { @@ -165,7 +165,7 @@ describe('resolveFreshAgentPaneModelDefaults', () => { ['kilroy: registry default when nothing is saved', 'kilroy', undefined, undefined, 'opus[1m]', true], ['codex: saved default wins', 'freshcodex', saved('gpt-5.6-luna'), 'gpt-5.6-terra', 'gpt-5.6-luna', true], ['codex: runtime CLI model when nothing is saved', 'freshcodex', undefined, 'gpt-5.6-terra', 'gpt-5.6-terra', true], - ['codex: unknown saved model normalizes to the codex default', 'freshcodex', saved('gpt-unknown'), undefined, 'gpt-6-astra', true], + ['codex: unknown saved model preserves exact intent for native validation', 'freshcodex', saved('gpt-unknown'), undefined, 'gpt-unknown', true], ['codex: registry default when nothing is configured', 'freshcodex', undefined, undefined, 'gpt-6-astra', true], ['opencode: saved default persists', 'freshopencode', saved('umans/kimi'), undefined, 'umans/kimi', true], ['opencode: runtime CLI model persists', 'freshopencode', undefined, 'umans/kimi', 'umans/kimi', true], From c16b744100e0c6a23aebeadc258d5d2e69e4bab2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:31:57 -0700 Subject: [PATCH 38/92] test(codex): capture pending catalog fixture source transitions --- .../fresh-agent-model-dialog-parity.spec.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts index 48a88dbb7..d187e7f75 100644 --- a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts @@ -53,11 +53,36 @@ test('Codex discovers only the bound runtime and preserves search and exact sett test('Codex picker waits for ordinary create and shows honest failure with exact saved input', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() let probes = 0 + const contexts: Record[] = [] + const snapshotRequests: string[] = [] + const paneFacts = () => page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + const layout = state.panes.layouts[tabId] + const pane = layout?.type === 'leaf' ? layout.content : undefined + if (pane?.kind !== 'fresh-agent') return { paneId, kind: pane?.kind } + const keys = [pane.sessionId, pane.sessionRef?.sessionId].filter(Boolean).map(id => `codex:${id}`) + return { paneId, sessionId: pane.sessionId, sessionRef: pane.sessionRef, + resumeSessionId: pane.resumeSessionId, status: pane.status, cwd: pane.initialCwd, + model: pane.model, effort: pane.effort, serverInstanceId: pane.serverInstanceId, + owners: Object.fromEntries(Object.entries(state.freshAgent.runtimeOwners).filter(([key]) => keys.includes(key))), + lifecycleFrames: (harness.getSentWsMessages?.() ?? []).filter((message: any) => + ['freshAgent.create', 'freshAgent.attach'].includes(message?.type)).map((message: any) => + ({type:message.type,requestId:message.requestId,sessionId:message.sessionId})), + } + }) await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async (route) => { probes++ + contexts.push(Object.fromEntries(new URL(route.request().url()).searchParams)) await route.fulfill({ json: codexCatalog }) }) await installPane(page, 'codex') + await page.route('**/api/fresh-agent/threads/**', async route => { + snapshotRequests.push(new URL(route.request().url()).pathname) + await route.fallback() + }) const setStatus = async (status: string, sessionId?: string) => page.evaluate(({ status, sessionId }) => { const harness = window.__FRESHELL_TEST_HARNESS__! const state = harness.getState() @@ -69,6 +94,7 @@ test('Codex picker waits for ordinary create and shows honest failure with exact } } }) }, { status, sessionId }) await setStatus('creating') + console.log('task007-pending-fixture-before-open ' + JSON.stringify(await paneFacts())) await page.getByRole('button', { name: /^Model:.*change model$/ }).click() const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) @@ -77,12 +103,12 @@ test('Codex picker waits for ordinary create and shows honest failure with exact await setStatus('idle', 'acknowledged-native') await expect(dialog.getByRole('option', { name: 'Provider Current' })).toBeVisible() await expect(dialog.getByRole('searchbox')).toHaveValue('provider') - expect(probes).toBe(1) + expect(probes, JSON.stringify({contexts,snapshotRequests,pane:await paneFacts()})).toBe(1) await setStatus('create-failed') await expect(dialog.getByRole('status')).toContainText(/catalog unavailable/i) await dialog.getByRole('searchbox').fill('') await expect(dialog.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() - expect(probes).toBe(1) + expect(probes, JSON.stringify({contexts,snapshotRequests,pane:await paneFacts()})).toBe(1) }) async function installPane(page: Page, provider: 'claude' | 'codex') { From 73b0a3b9d8054a2877bb08702aa08e50a3335231 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:38 -0700 Subject: [PATCH 39/92] fix(codex): keep catalog binding stable through creation --- .../fresh-agent/FreshAgentModelDialog.tsx | 30 +++- .../fresh-agent-model-dialog-parity.spec.ts | 136 ++++++++++-------- .../FreshAgentModelDialog.test.tsx | 50 ++++++- 3 files changed, 143 insertions(+), 73 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentModelDialog.tsx b/src/components/fresh-agent/FreshAgentModelDialog.tsx index 27693db60..e33c776a0 100644 --- a/src/components/fresh-agent/FreshAgentModelDialog.tsx +++ b/src/components/fresh-agent/FreshAgentModelDialog.tsx @@ -116,13 +116,29 @@ export function FreshAgentModelDialog({ const canonical = resolveCanonicalPaneSession(state,paneContent) return canonical ? selectSessionRuntimeOwner(state,canonical.provider,canonical.sessionId) : undefined }) - const codexContext = useMemo(() => sessionType === 'freshcodex' && paneContent.sessionId ? { - sessionId:paneContent.sessionId, - ...(paneContent.sessionRef ? {sessionRef:paneContent.sessionRef} : {}), - cwd:paneContent.initialCwd, - ...(runtimeOwner ? {observedEpoch:runtimeOwner.epoch,observedGeneration:runtimeOwner.generation} : {}), - } : undefined,[sessionType,paneContent.sessionId,paneContent.sessionRef,paneContent.initialCwd,runtimeOwner]) - const probeKey = JSON.stringify([sessionType,cwdKey,codexContext,paneContent.serverInstanceId]) + const referenceProvider = paneContent.sessionRef?.provider + const referenceSessionId = paneContent.sessionRef?.sessionId + const observedEpoch = runtimeOwner?.epoch + const observedGeneration = runtimeOwner?.generation + const codexContext = useMemo(() => { + if (sessionType !== 'freshcodex' || !paneContent.sessionId) return undefined + return { + sessionId: paneContent.sessionId, + ...(referenceProvider !== undefined && referenceSessionId !== undefined + ? { sessionRef: { provider: referenceProvider, sessionId: referenceSessionId } } + : {}), + cwd: paneContent.initialCwd, + ...(observedEpoch !== undefined && observedGeneration !== undefined + ? { observedEpoch, observedGeneration } + : {}), + } + }, [sessionType, paneContent.sessionId, paneContent.initialCwd, referenceProvider, referenceSessionId, observedEpoch, observedGeneration]) + // Source values invalidate a request; repeated observations of those values + // do not. The observation timestamp is not a change of owner or runtime. + const ownerSourceKey = sessionType === 'freshcodex' && runtimeOwner + ? JSON.stringify({ ...runtimeOwner, updatedAt: undefined }) + : undefined + const probeKey = JSON.stringify([sessionType, cwdKey, codexContext, paneContent.serverInstanceId, ownerSourceKey]) const [probeResult, setProbeResult] = useState<{ key: string response: FreshAgentModelCapabilitiesResponse diff --git a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts index d187e7f75..feb11d00e 100644 --- a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts @@ -52,81 +52,93 @@ test('Codex discovers only the bound runtime and preserves search and exact sett test('Codex picker waits for ordinary create and shows honest failure with exact saved input', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() - let probes = 0 const contexts: Record[] = [] - const snapshotRequests: string[] = [] - const paneFacts = () => page.evaluate(() => { - const harness = window.__FRESHELL_TEST_HARNESS__! - const state = harness.getState() - const tabId = state.tabs.activeTabId! - const paneId = state.panes.activePane[tabId] - const layout = state.panes.layouts[tabId] - const pane = layout?.type === 'leaf' ? layout.content : undefined - if (pane?.kind !== 'fresh-agent') return { paneId, kind: pane?.kind } - const keys = [pane.sessionId, pane.sessionRef?.sessionId].filter(Boolean).map(id => `codex:${id}`) - return { paneId, sessionId: pane.sessionId, sessionRef: pane.sessionRef, - resumeSessionId: pane.resumeSessionId, status: pane.status, cwd: pane.initialCwd, - model: pane.model, effort: pane.effort, serverInstanceId: pane.serverInstanceId, - owners: Object.fromEntries(Object.entries(state.freshAgent.runtimeOwners).filter(([key]) => keys.includes(key))), - lifecycleFrames: (harness.getSentWsMessages?.() ?? []).filter((message: any) => - ['freshAgent.create', 'freshAgent.attach'].includes(message?.type)).map((message: any) => - ({type:message.type,requestId:message.requestId,sessionId:message.sessionId})), - } - }) - await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async (route) => { - probes++ + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async route => { contexts.push(Object.fromEntries(new URL(route.request().url()).searchParams)) await route.fulfill({ json: codexCatalog }) }) - await installPane(page, 'codex') - await page.route('**/api/fresh-agent/threads/**', async route => { - snapshotRequests.push(new URL(route.request().url()).pathname) - await route.fallback() - }) - const setStatus = async (status: string, sessionId?: string) => page.evaluate(({ status, sessionId }) => { - const harness = window.__FRESHELL_TEST_HARNESS__! - const state = harness.getState() - const tabId = state.tabs.activeTabId! - const paneId = state.panes.activePane[tabId] - harness.dispatch({ type: 'panes/mergePaneContent', payload: { tabId, paneId, updates: { - status, sessionId, sessionRef: undefined, resumeSessionId: undefined, - model: 'saved-custom', modelSelection: { kind: 'exact', modelId: 'saved-custom' }, effort: 'xhigh', - } } }) - }, { status, sessionId }) - await setStatus('creating') - console.log('task007-pending-fixture-before-open ' + JSON.stringify(await paneFacts())) - await page.getByRole('button', { name: /^Model:.*change model$/ }).click() + const creates = () => page.evaluate(() => + (window.__FRESHELL_TEST_HARNESS__!.getSentWsMessages?.() ?? []) + .filter((frame: any) => frame?.type === 'freshAgent.create') + .map((frame: any) => ({ requestId: frame.requestId, model: frame.model, effort: frame.effort }))) + const begin = (requestId: string) => installPane(page, 'codex', { requestId, model: 'saved-custom', effort: 'xhigh' }) + const open = async () => { + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('button', { name: /saved-custom.*Change/ }).click() + } + const firstRequest = 'model-dialog-codex-ack-create' + await begin(firstRequest) + await expect.poll(creates).toEqual([{ requestId: firstRequest, model: 'saved-custom', effort: 'xhigh' }]) + await open() const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('textbox', { name: 'Exact model ID' }).fill('staged-custom') + await dialog.getByRole('textbox', { name: 'Reasoning effort' }).fill('ultra') await dialog.getByRole('searchbox').fill('provider') - expect(probes).toBe(0) - await setStatus('idle', 'acknowledged-native') - await expect(dialog.getByRole('option', { name: 'Provider Current' })).toBeVisible() + expect(contexts).toHaveLength(0) + expect(await creates()).toHaveLength(1) + await page.evaluate(requestId => { + const harness = window.__FRESHELL_TEST_HARNESS__! + if (!harness.receiveWsMessage) throw new Error('Public receive-message fixture is required') + harness.receiveWsMessage({ type: 'freshAgent.created', requestId, + sessionId: 'acknowledged-native', sessionRef: { provider: 'codex', sessionId: 'acknowledged-native' }, + sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' }) + }, firstRequest) + await expect(dialog.getByRole('option', { name: 'Provider Current', exact: true })).toBeVisible() await expect(dialog.getByRole('searchbox')).toHaveValue('provider') - expect(probes, JSON.stringify({contexts,snapshotRequests,pane:await paneFacts()})).toBe(1) - await setStatus('create-failed') + await expect(dialog.getByRole('button', { name: 'Use staged-custom · ultra' })).toBeEnabled() + expect(contexts).toEqual([{ sessionId: 'acknowledged-native', + sessionRef: JSON.stringify({ provider: 'codex', sessionId: 'acknowledged-native' }), cwd: '/tmp' }]) + expect(await creates()).toHaveLength(1) + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.keyboard.press('Escape') + + const failedRequest = 'model-dialog-codex-failed-create' + await begin(failedRequest) + await expect.poll(creates).toEqual([ + { requestId: firstRequest, model: 'saved-custom', effort: 'xhigh' }, + { requestId: failedRequest, model: 'saved-custom', effort: 'xhigh' }, + ]) + await open() + await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('searchbox').fill('saved') + await page.evaluate(requestId => { + const harness = window.__FRESHELL_TEST_HARNESS__! + if (!harness.receiveWsMessage) throw new Error('Public receive-message fixture is required') + harness.receiveWsMessage({ type: 'freshAgent.create.failed', requestId, + code: 'FRESH_AGENT_CREATE_FAILED', message: 'Controlled create failure', retryable: false }) + }, failedRequest) await expect(dialog.getByRole('status')).toContainText(/catalog unavailable/i) + await expect(dialog.getByRole('searchbox')).toHaveValue('saved') await dialog.getByRole('searchbox').fill('') await expect(dialog.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() - expect(probes, JSON.stringify({contexts,snapshotRequests,pane:await paneFacts()})).toBe(1) + expect(contexts).toHaveLength(1) + expect(await creates()).toHaveLength(2) }) -async function installPane(page: Page, provider: 'claude' | 'codex') { +async function installPane( + page: Page, + provider: 'claude' | 'codex', + creation?: { requestId: string; model: string; effort: string }, +) { const sessionType = provider === 'claude' ? 'freshclaude' : 'freshcodex' - const sessionId = 'd4430000-0000-4444-8444-000000000001' - const model = provider === 'claude' ? 'opus[1m]' : FRESHCODEX_DEFAULT_MODEL - await page.route('**/api/fresh-agent/threads/**', (route) => route.fulfill({ - json: { - sessionType, provider, sessionId, threadId: sessionId, + const sessionId = creation ? undefined : 'd4430000-0000-4444-8444-000000000001' + const model = creation?.model ?? (provider === 'claude' ? 'opus[1m]' : FRESHCODEX_DEFAULT_MODEL) + const effort = creation?.effort ?? 'low' + const createRequestId = creation?.requestId ?? `model-dialog-${provider}` + await page.route('**/api/fresh-agent/threads/**', route => { + const requestedSessionId = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1)!) + return route.fulfill({ json: { + sessionType, provider, sessionId: requestedSessionId, threadId: requestedSessionId, revision: 1, latestTurnId: null, status: 'idle', capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: false }, - settings: { model, effort: 'low' }, + settings: { model, effort }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, pendingApprovals: [], pendingQuestions: [], turns: [], - extensions: provider === 'claude' ? { claude: { liveSessionId: sessionId, cliSessionId: sessionId } } : {}, - }, - })) - await page.evaluate(({ sessionType, provider, sessionId, model }) => { + extensions: provider === 'claude' ? { claude: { liveSessionId: requestedSessionId, cliSessionId: requestedSessionId } } : {}, + } }) + }) + await page.evaluate(({ sessionType, provider, sessionId, model, effort, createRequestId }) => { const harness = window.__FRESHELL_TEST_HARNESS__! const state = harness.getState() const tabId = state.tabs.activeTabId! @@ -134,13 +146,13 @@ async function installPane(page: Page, provider: 'claude' | 'codex') { harness.setFreshAgentNetworkEffectsSuppressed(paneId, true) harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content: { - kind: 'fresh-agent', sessionType, provider, sessionId, - sessionRef: { provider, sessionId }, resumeSessionId: sessionId, - createRequestId: `model-dialog-${provider}`, status: 'idle', - model, effort: 'low', initialCwd: '/tmp', settingsDismissed: true, + kind: 'fresh-agent', sessionType, provider, + ...(sessionId ? { sessionId, sessionRef: { provider, sessionId }, resumeSessionId: sessionId } : {}), + createRequestId, status: sessionId ? 'idle' : 'creating', + model, effort, initialCwd: '/tmp', settingsDismissed: true, }, } }) - }, { sessionType, provider, sessionId, model }) + }, { sessionType, provider, sessionId, model, effort, createRequestId }) } test('Claude model search survives catalog loading and remembers a selected thinking level', async ({ freshellPage, page, terminal }) => { diff --git a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx index 86a686edd..3b2925504 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx @@ -1,5 +1,5 @@ import { configureStore } from '@reduxjs/toolkit' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,6 +7,7 @@ import { FreshAgentModelDialog } from '@/components/fresh-agent/FreshAgentModelD import { useAppSelector } from '@/store/hooks' import panesReducer, { initLayout, mergePaneContent } from '@/store/panesSlice' import settingsReducer from '@/store/settingsSlice' +import freshAgentReducer, { applyRuntimeOwner } from '@/store/freshAgentSlice' import type { FreshAgentPaneContent } from '@/store/paneTypes' import type { FreshAgentSettingScopes } from '@shared/fresh-agent-contract' @@ -115,6 +116,7 @@ function createStore() { reducer: { panes: panesReducer, settings: settingsReducer, + freshAgent: freshAgentReducer, }, }) } @@ -631,6 +633,43 @@ describe('FreshAgentModelDialog (freshcodex)', () => { expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { model: 'provider-current', effort: 'ultra' } })) }) + it('keeps the bound catalog stable when the same session reference is rematerialized', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + try { + const store = createStore() + const owner = { type: 'session.runtimeOwner' as const, provider: 'codex', sessionId: 'thread-dialog', + epoch: 7, generation: 3, ownerKind: 'fresh-agent' as const, operationId: 'catalog-owner', + transition: 'handoff-committed' as const } + store.dispatch(applyRuntimeOwner(owner)) + seedFreshcodexPane(store, { sessionRef: { provider: 'codex', sessionId: 'thread-dialog' } }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'provider' } }) + fireEvent.click(screen.getByRole('option', { name: 'none', exact: true })) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1) + await act(async () => { + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { + sessionRef: { provider: 'codex', sessionId: 'thread-dialog' }, + } })) + }) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1) + now.mockReturnValue(1_001) + await act(async () => { store.dispatch(applyRuntimeOwner(owner)) }) + expect(getFreshAgentModelCapabilitiesSpy, JSON.stringify(getFreshAgentModelCapabilitiesSpy.mock.calls)).toHaveBeenCalledTimes(1) + // A real owner transition at the same fence remains a new source. + await act(async () => { store.dispatch(applyRuntimeOwner({ ...owner, transition: 'handoff-started' })) }) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(2) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenLastCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ observedEpoch: 7, observedGeneration: 3 }), + })) + expect(screen.getByRole('searchbox')).toHaveValue('provider') + expect(screen.getByRole('button', { name: 'Use Provider Current · none' })).toBeEnabled() + } finally { + now.mockRestore() + } + }) + it('keeps the exact current model and xhigh visible when catalog fails or omits it', async () => { const store = createStore() seedFreshcodexPane(store, { model: 'saved-custom', effort: 'xhigh' }) @@ -727,9 +766,12 @@ describe('FreshAgentModelDialog (freshcodex)', () => { codexContext:expect.objectContaining({sessionId:'thread-dialog',cwd:'/repo/project-a'}), })) expect(screen.getAllByRole('option',{name:/GPT-6 Astra.*current/}).length).toBeGreaterThanOrEqual(1) - const levels = screen.getByRole('listbox',{name:'Thinking levels for GPT-6 Astra'}) - const names = Array.from(levels.querySelectorAll('[role="option"]')).map((el)=>el.textContent?.replace(/last used|highest|current|●/g,'').trim()) - expect(names).toEqual(['low','medium','high','xhigh','max']) + await waitFor(() => { + const levels = screen.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' }) + const names = Array.from(levels.querySelectorAll('[role="option"]')) + .map(el => el.textContent?.replace(/last used|highest|current|●/g, '').trim()) + expect(names).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) + }) fireEvent.click(screen.getByRole('button',{name:'Use GPT-6 Astra · max'})) expect(paneContent(store)).toMatchObject({model:'gpt-6-astra',effort:'max'}) }) From 9a15d860f964d2c4383bf2e7a6f7f2a478d69aab Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:09:21 -0700 Subject: [PATCH 40/92] test(freshcodex): preserve picker effort intent --- .../components/panes/PaneContainer.test.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 99acf2f04..bba318cb9 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -2543,7 +2543,7 @@ describe('PaneContainer', () => { expect(paneContent.provider).toBe('codex') expect(paneContent.initialCwd).toBe('/home/user/freshcodex-project') expect(paneContent.model).toBe('gpt-6-astra') - expect(paneContent.effort).toBe('max') + expect(paneContent.effort).toBeUndefined() } }) @@ -2555,7 +2555,7 @@ describe('PaneContainer', () => { })) }) - it('normalizes saved Freshcodex effort from provider settings against the active model', async () => { + it.each(['xhigh', 'max'])('preserves saved Freshcodex effort %s from provider settings for native validation', async (effort) => { const node = createPickerNode('pane-1') const store = createStoreWithClaude( node, @@ -2563,7 +2563,7 @@ describe('PaneContainer', () => { { codex: { model: 'gpt-5.6-luna' } }, ['claude', 'codex'], { claude: true, codex: true }, - { freshcodex: { effort: 'xhigh' } }, + { freshcodex: { effort } }, ) mockApiPost.mockResolvedValueOnce({ valid: true, resolvedPath: '/home/user/freshcodex-project' }) @@ -2584,10 +2584,19 @@ describe('PaneContainer', () => { const paneContent = (state.layouts['tab-1'] as Extract).content expect(paneContent.kind).toBe('fresh-agent') if (paneContent.kind === 'fresh-agent') { + expect(paneContent.sessionType).toBe('freshcodex') + expect(paneContent.provider).toBe('codex') + expect(paneContent.initialCwd).toBe('/home/user/freshcodex-project') + expect(paneContent.status).toBe('creating') + expect(paneContent.createRequestId).toBeDefined() expect(paneContent.model).toBe('gpt-5.6-luna') - expect(paneContent.effort).toBe('max') + expect(paneContent.effort).toBe(effort) } }) + + expect(saveServerSettingsPatchSpy).toHaveBeenCalledWith({ + codingCli: { providers: { codex: { model: 'gpt-5.6-luna', cwd: '/home/user/freshcodex-project' } } }, + }) }) it('enables Freshopencode from the picker without persisting the built-in model fallback', async () => { From 2257bb81dfd2e0477b42f671e30e5df304a74817 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:02:05 -0700 Subject: [PATCH 41/92] Fix Claude candidate preparation CPU budget --- .../src/backend/claude_source_observation.rs | 5 +++- scripts/testing/runtime-test-broker.ts | 3 +- .../server/managed-provider-parity.test.ts | 30 +++++++++++++++++++ .../testing/runtime-test-broker.test.ts | 4 ++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/freshell-supervisor/src/backend/claude_source_observation.rs b/crates/freshell-supervisor/src/backend/claude_source_observation.rs index e9b502db3..47bb06852 100644 --- a/crates/freshell-supervisor/src/backend/claude_source_observation.rs +++ b/crates/freshell-supervisor/src/backend/claude_source_observation.rs @@ -470,11 +470,14 @@ impl DockerEngineBackend { .push(json!({"Type":"bind","Source":common,"Target":common,"ReadOnly":true})); } descriptors.extend(prepared_inputs::mount_descriptors(&mounts, &backing.name)?); + // A candidate performs the workload's full durable projection, + // including installed dependencies, rather than a metadata-only read. + // Give that preparation the ordinary managed workload's CPU budget. let body = json!({"Image":image,"User":"0:0","Tty":false,"WorkingDir":cwd, "Entrypoint":["/runtime/freshell-session-host"],"Cmd":["project-claude-candidate-private"],"Labels":labels, "Env":self.private_helper_environment(image,&["HOME=/home/freshell/provider"]).await?, "HostConfig":{"NetworkMode":"none","ReadonlyRootfs":true,"CapDrop":["ALL"],"CapAdd":["CHOWN","SETGID","SETUID"], - "SecurityOpt":["no-new-privileges"],"Memory":256*1024*1024,"MemorySwap":256*1024*1024,"NanoCpus":500_000_000,"PidsLimit":32, + "SecurityOpt":["no-new-privileges"],"Memory":256*1024*1024,"MemorySwap":256*1024*1024,"NanoCpus":2_000_000_000,"PidsLimit":32, "LogConfig":{"Type":"none","Config":{}},"Tmpfs":{},"Mounts":descriptors}}); step.enter("create"); let created = reader diff --git a/scripts/testing/runtime-test-broker.ts b/scripts/testing/runtime-test-broker.ts index 7e9f30352..d17ea47d2 100644 --- a/scripts/testing/runtime-test-broker.ts +++ b/scripts/testing/runtime-test-broker.ts @@ -1104,7 +1104,8 @@ export class RestrictedDockerBroker { || host.NetworkMode !== 'none' || host.ReadonlyRootfs !== true || JSON.stringify(host.CapDrop) !== JSON.stringify(['ALL']) || JSON.stringify(host.CapAdd) !== JSON.stringify(['CHOWN','SETGID','SETUID']) || JSON.stringify(host.SecurityOpt) !== JSON.stringify(['no-new-privileges']) - || host.Memory !== (reader ? 256 : 128) * 1024 * 1024 || host.MemorySwap !== host.Memory || host.NanoCpus !== 500_000_000 || host.PidsLimit !== 32 + || host.Memory !== (reader ? 256 : 128) * 1024 * 1024 || host.MemorySwap !== host.Memory + || host.NanoCpus !== (reader ? 2_000_000_000 : 500_000_000) || host.PidsLimit !== 32 || !equalRecord(host.LogConfig, { Type: 'none', Config: host.LogConfig?.Config }) || Object.keys(host.LogConfig?.Config ?? {}).length || (reader ? !equalRecord(host.Tmpfs, {}) : host.Tmpfs !== undefined)) return false const mounts = host.Mounts diff --git a/test/integration/server/managed-provider-parity.test.ts b/test/integration/server/managed-provider-parity.test.ts index 0311d1ac9..08549be6f 100644 --- a/test/integration/server/managed-provider-parity.test.ts +++ b/test/integration/server/managed-provider-parity.test.ts @@ -542,6 +542,36 @@ process.stdout.write(JSON.stringify({rootExists:fs.existsSync(root),fileExists:f } }, 180_000) + it('managed Claude prepares its complete installed environment before dispatch', async () => { + const wire = await TerminalWire.connect(rig.info, true) + sockets.push(wire) + await wire.create('claude', workspace, authorizeManagedRequest) + const launch = await waitFor('managed Claude installed environment consumer', () => ( + wire.recordRows().find(row => row.provider === 'claude' && row.kind === 'launch') + )) + // The real child executes the selected command, imports the last installed + // dependency and reads its resource after candidate preparation completes. + expect(launch.selectedEnvironment).toEqual({ + dependency: 'installed-dependency-nonce', resource: 'selected-resource-nonce', entries: 4201, + }) + expect(launch.providerConfig['settings.json']).toMatchObject({ paritySetting: true }) + expect(launch.providerPlugin).toBe('claude parity plugin\n') + expect(launch.childIdentity).toEqual({ uid: 65534, gid: 0 }) + expect(launch.privateTopology).toEqual({ uid: 0, gid: 0, mode: 0o710 }) + expect(launch.onecliChildDigest).toBe(onecli.childDigests.claude) + expect(launch.onecliControlPresent).toBe(false) + const mcp = await waitFor('managed Claude prepared MCP probe', () => ( + wire.recordRows().find(row => row.provider === 'claude' && row.kind === 'mcp') + )) + expect(mcp.error).toBeUndefined() + expect(mcp.tools).toContain('freshell') + expect(mcp.call?.ok).toBe(true) + expect(mcp.authenticatedCall).toMatchObject({ isError: false, count: 0, truncated: false }) + for (const marker of [nestedSecretMarker, 'fixture-secret-byte']) { + expect(JSON.stringify({ launch, mcp })).not.toContain(marker) + } + }, 180_000) + it.each(providers)('%s preserves provider-visible launch and MCP behavior', async provider => { const observed: RecordRow[] = [] const mcpResults: RecordRow[] = [] diff --git a/test/unit/tooling/testing/runtime-test-broker.test.ts b/test/unit/tooling/testing/runtime-test-broker.test.ts index dcc0169df..6daa679ff 100644 --- a/test/unit/tooling/testing/runtime-test-broker.test.ts +++ b/test/unit/tooling/testing/runtime-test-broker.test.ts @@ -694,6 +694,7 @@ function candidateHelperBody(input: ClaudeCandidateAuthorization, role: 'mountpo body.Cmd = [role === 'reader' ? 'project-claude-candidate-private' : 'prepare-provider-mountpoints'] body.HostConfig.CapAdd = ['CHOWN', 'SETGID', 'SETUID'] body.HostConfig.Memory = body.HostConfig.MemorySwap = (role === 'reader' ? 256 : 128) * 1024 * 1024 + body.HostConfig.NanoCpus = role === 'reader' ? 2_000_000_000 : 500_000_000 body.HostConfig.Mounts = [ { Type: 'bind', Source: input.hostBinaryPath, Target: '/runtime/freshell-session-host', ReadOnly: true }, { Type: 'volume', Source: volume, Target: '/home/freshell/provider', ReadOnly: false, VolumeOptions: { NoCopy: true } }, @@ -851,7 +852,7 @@ describe('fixed initial Claude candidate broker', () => { instance.authorizeInitialProviderVolume(sourceAuthorization()); instance.authorizeClaudeCandidate(input) expect((await request('POST', '/v1.47/volumes/create', { Name: volume, Driver: 'local', DriverOpts: {}, Labels: candidateLabels(input) })).status).toBe(201) const body = candidateHelperBody(input, role), before = forwarded.length - for (const mutation of ['uid','command','credential','caps','network','memory','logs','reservation','generation','request','mount','readonly','volume']) { + for (const mutation of ['uid','command','credential','caps','network','memory','cpu','logs','reservation','generation','request','mount','readonly','volume']) { const changed = structuredClone(body) if (mutation === 'uid') changed.User = '65534:0' if (mutation === 'command') changed.Cmd.push('PRIVATE_CANDIDATE_SENTINEL') @@ -859,6 +860,7 @@ describe('fixed initial Claude candidate broker', () => { if (mutation === 'caps') changed.HostConfig.CapAdd.push('DAC_OVERRIDE') if (mutation === 'network') changed.HostConfig.NetworkMode = 'bridge' if (mutation === 'memory') changed.HostConfig.Memory *= 2 + if (mutation === 'cpu') changed.HostConfig.NanoCpus *= 2 if (mutation === 'logs') changed.HostConfig.LogConfig.Type = 'json-file' if (mutation === 'reservation') changed.Labels['com.freshell.source-reservation-id'] = '33333333-3333-4333-8333-333333333333' if (mutation === 'generation') changed.Labels['com.freshell.input-generation'] = 'grant-33333333-3333-4333-8333-333333333333' From f68271b0c4c91e264eef077e763a0498ab402c60 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:36:53 -0700 Subject: [PATCH 42/92] Pin test build compilers and report prepared build provenance --- docker/cloud-run/Dockerfile | 5 +- scripts/testing/runtime-build-preflight.ts | 19 ++- scripts/testing/runtime-sandbox.ts | 15 ++- test/e2e-browser/helpers/managed-runtime.ts | 7 +- .../testing/runtime-build-preflight.test.ts | 127 +++++++++++++++++- 5 files changed, 156 insertions(+), 17 deletions(-) diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index de5becb29..c758a612e 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -15,7 +15,7 @@ ARG FRESHELL_BUILD_COMMIT # --------------------------------------------------------------------------- # Stage 1: build the Rust server. # --------------------------------------------------------------------------- -FROM rust:1-bookworm AS rust-builder +FROM rust:1.96.0-bookworm AS rust-builder ARG FRESHELL_BUILD_COMMIT @@ -39,7 +39,8 @@ COPY crates/ ./crates/ COPY extensions/ ./extensions/ COPY docker/runtime/provider-versions.json ./docker/runtime/provider-versions.json COPY docker/runtime/codex-native-sandbox.json ./docker/runtime/codex-native-sandbox.json -RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked +RUN rustc --version && cargo --version \ + && FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked # Compile the real-client requirement guard before entering native test lanes. RUN set -eu; cargo test --release -p freshell-codex --features native-policy-qualification --lib --no-run --locked \ ; set -- target/release/deps/freshell_codex-* \ diff --git a/scripts/testing/runtime-build-preflight.ts b/scripts/testing/runtime-build-preflight.ts index 1fccc66c5..11a8d5167 100644 --- a/scripts/testing/runtime-build-preflight.ts +++ b/scripts/testing/runtime-build-preflight.ts @@ -6,8 +6,8 @@ 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' + | 'supervisor-qualification' | 'server-managed-provider-qualification' | 'server-managed-fresh-agent-fixtures' | 'server-managed-runtime-v1' +export type RuntimeBuildRecipe = 'test-hosts' | 'release-hosts' | 'qualification-supervisor' | 'qualification-server' | 'fresh-fixture-server' | 'managed-server' export type RuntimeBuildCommand = { program: string; args: string[] } type BuildManifest = { @@ -15,9 +15,14 @@ type BuildManifest = { repoRoot: string revision: string binaries: Partial> + command?: RuntimeBuildCommand } const recipes: Record> }> = { + 'managed-server': { + args: ['--release', '-p', 'freshell-server', '--features', 'managed-runtime-v1'], + profile: 'release', outputs: { 'server-managed-runtime-v1': 'freshell-server' }, + }, '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' }, @@ -80,6 +85,12 @@ export function preparedRuntimeBinary(repoRoot: string, binary: RuntimeBinary, e return file } +/** Older manifests have no command provenance; never infer it from the host. */ +export function preparedRuntimeBuildProvenance(repoRoot: string, env: NodeJS.ProcessEnv = process.env) { + const manifest = readManifest(repoRoot, env) + return manifest ? { source: 'prepared' as const, manifestPath: env[RUNTIME_BUILD_MANIFEST_ENV]!, command: manifest.command ?? null } : undefined +} + 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[]) { @@ -92,7 +103,7 @@ export function hasPreparedRuntimeBuilds(repoRoot: string, selected: RuntimeBuil 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 = process.env, command = { program: path.join(os.homedir(), '.local/bin/mise'), args: ['exec', 'rust@1.96.0', '--', 'cargo'] } }: { env?: NodeJS.ProcessEnv command?: RuntimeBuildCommand } = {}, @@ -100,7 +111,7 @@ export function prepareRuntimeBuilds( 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 manifest: BuildManifest = { version: 1, repoRoot: root, revision: revision(root), binaries: {}, command } 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 }) diff --git a/scripts/testing/runtime-sandbox.ts b/scripts/testing/runtime-sandbox.ts index cd8b6098e..730381cb9 100644 --- a/scripts/testing/runtime-sandbox.ts +++ b/scripts/testing/runtime-sandbox.ts @@ -7,7 +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 { preparedRuntimeBinary, preparedRuntimeBuildProvenance } 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' @@ -228,6 +228,7 @@ export class RuntimeHarness { private readonly assertions: AssertionRecord[] = [] private ownedRuntimeImage: OwnedRuntimeImage | undefined private brokerStarted = false + private buildProvenance: ReturnType | { source: 'mise'; command: { program: string; args: string[] }; hostMiseRustc: string } private readonly fixtureInputs: RuntimeFixtureInputs | undefined constructor(repoRoot: string, runId = randomUUID(), phase: 1 | 2 | 3 | 4 | 5 = 1, @@ -1292,6 +1293,7 @@ export class RuntimeHarness { this.releaseHostBinary = path.join(this.buildDir, 'freshell-session-host-release') const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-test') if (prepared) { + this.buildProvenance = preparedRuntimeBuildProvenance(this.repoRoot) for (const [source, destination] of [ [prepared, this.testSupervisorBinary], [preparedRuntimeBinary(this.repoRoot, 'host-test')!, this.testHostBinary], @@ -1301,17 +1303,20 @@ export class RuntimeHarness { 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' }) + const command = { program: mise, args: ['exec', 'rust@1.96.0', '--', 'cargo'] } + const hostMiseRustc = execFileSync(mise, ['exec', 'rust@1.96.0', '--', 'rustc', '--version'], { encoding: 'utf8' }).trim() + execFileSync(mise, [...command.args, 'build', '--locked', '-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' }) + execFileSync(mise, [...command.args, 'build', '--locked', '--release', '-p', 'freshell-supervisor', '-p', 'freshell-session-host'], { cwd: this.repoRoot, stdio: 'inherit' }) 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) fs.chmodSync(this.releaseHostBinary, 0o755) + this.buildProvenance = { source: 'mise', command, hostMiseRustc } } private collectCapabilities(): unknown { @@ -1334,12 +1339,10 @@ export class RuntimeHarness { } private collectBuildInfo(): unknown { - const mise = path.join(os.homedir(), '.local', 'bin', 'mise') - const rustc = execFileSync(mise, ['exec', 'rust@1.96', '--', 'rustc', '--version'], { encoding: 'utf8' }).trim() return { candidateSha: this.candidateSha, runtimeImage: this.imageRef, - rustc, + build: this.buildProvenance, node: process.version, docker: docker(['version', '--format', 'client={{.Client.Version}} server={{.Server.Version}}']).trim(), binaries: { diff --git a/test/e2e-browser/helpers/managed-runtime.ts b/test/e2e-browser/helpers/managed-runtime.ts index 144585397..5bd92f9af 100644 --- a/test/e2e-browser/helpers/managed-runtime.ts +++ b/test/e2e-browser/helpers/managed-runtime.ts @@ -522,10 +522,9 @@ export class ManagedRuntimeBrowserRig { 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}`) + const prepared = preparedRuntimeBinary(this.repoRoot, `server-${features}`) if (!prepared) execFileSync(mise, [ - 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-server', + 'exec', 'rust@1.96.0', '--', 'cargo', 'build', '--locked', '--release', '-p', 'freshell-server', '--features', features, ], { cwd: this.repoRoot, stdio: 'inherit' }) const source = prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-server') @@ -540,7 +539,7 @@ export class ManagedRuntimeBrowserRig { const mise = path.join(os.homedir(), '.local', 'bin', 'mise') const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-qualification') if (!prepared) execFileSync(mise, [ - 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', + 'exec', 'rust@1.96.0', '--', 'cargo', 'build', '--locked', '--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification', ], { cwd: this.repoRoot, stdio: 'inherit' }) const target = path.join(this.runtime.buildDir, 'freshell-supervisor-qualification') diff --git a/test/unit/tooling/testing/runtime-build-preflight.test.ts b/test/unit/tooling/testing/runtime-build-preflight.test.ts index ebca142d6..0a10ef1ee 100644 --- a/test/unit/tooling/testing/runtime-build-preflight.test.ts +++ b/test/unit/tooling/testing/runtime-build-preflight.test.ts @@ -5,13 +5,23 @@ 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 { RUNTIME_BUILD_MANIFEST_ENV, prepareRuntimeBuilds, 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 childFixture = vi.hoisted(() => ({ execute: undefined as undefined | ((program: string, args: string[], options?: any) => any) })) +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal() + const execute = ((program: string, args: string[], options?: any) => childFixture.execute + ? childFixture.execute(program, args, options) : actual.execFileSync(program, args, options)) as typeof actual.execFileSync + return { ...actual, execFileSync: execute, default: { ...actual, execFileSync: execute } } +}) + const owned: string[] = [] afterEach(() => { vi.unstubAllEnvs() + vi.restoreAllMocks() + childFixture.execute = undefined for (const directory of owned.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) }) @@ -132,6 +142,121 @@ describe('managed runtime build preparation before suite startup', () => { expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') }) + it('freezes and reuses the ordinary managed browser server without a second compiler selection', () => { + const f = fixture() + const prepared = prepareRuntimeBuilds(f.root, ['managed-server'], { env: f.project.config.env, command: f.command }) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, prepared.manifestPath) + try { + const rig = new ManagedRuntimeBrowserRig(f.root) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + const server = (rig as unknown as { buildManagedServer(): string }).buildManagedServer() + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-runtime-v1') + expect(f.rows()).toHaveLength(1) + fs.rmSync(rig.runtime.buildDir, { recursive: true, force: true }) + } finally { prepared.cleanup() } + }) + + it('attributes runtime build receipts to the prepared command instead of the host mise compiler', 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]) + try { + const runtime = new RuntimeHarness(f.root, undefined, 2) + const { execFileSync: execute } = await vi.importActual('node:child_process') + childFixture.execute = (program, args, options) => { + if (program === 'docker') return 'fixture client/server' + if (program === 'git') return execute(program, args, options) + throw new Error(`prepared receipt unexpectedly executes ${program}`) + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): Record } + methods.buildBinaries() + const receipt = methods.collectBuildInfo() + expect(receipt).not.toHaveProperty('rustc') + expect(receipt.build).toEqual({ source: 'prepared', manifestPath: f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV], command: f.command }) + expect(receipt).not.toHaveProperty('hostMiseRustc') + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + } finally { await teardown?.() } + }) + + it('builds default runtime artifacts with the exact reported mise command and labels the host version', async () => { + const f = fixture() + const runtime = new RuntimeHarness(f.root, undefined, 2) + const builds: string[][] = [] + childFixture.execute = (program, args) => { + if (program === 'docker') return 'fixture client/server' + if (!program.endsWith('/mise')) throw new Error('unexpected builder') + if (args.includes('rustc')) return 'rustc fixture-host-version\n' + // This builder refuses a different compiler selection, before producing artifacts. + if (args[1] !== 'rust@1.96.0' || !args.includes('--locked')) throw new Error('wrong compiler or unlocked build') + builds.push(args) + const target = path.join(f.root, 'target', args.includes('--release') ? 'release' : 'debug') + fs.mkdirSync(target, { recursive: true }) + for (const binary of ['freshell-supervisor', 'freshell-session-host']) { + fs.writeFileSync(path.join(target, binary), `#!/bin/sh\n# ${args.includes('--release') ? 'release' : 'test'}\n`, { mode: 0o755 }) + } + return '' + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): any } + methods.buildBinaries() + const receipt = methods.collectBuildInfo() + expect(receipt.build.source).toBe('mise') + expect(receipt.build.hostMiseRustc).toBe('rustc fixture-host-version') + expect(receipt).not.toHaveProperty('rustc') + expect(builds).toHaveLength(2) + for (const args of builds) expect(args.slice(0, 4)).toEqual(receipt.build.command.args) + expect(fs.readFileSync(runtime.testHostBinary, 'utf8')).toContain('# test') + expect(fs.readFileSync(runtime.releaseHostBinary, 'utf8')).toContain('# release') + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + }) + + it.each([ + { enabledProviders: ['opencode'], feature: 'managed-runtime-v1' }, + { enabledProviders: ['codex'], feature: 'managed-provider-qualification' }, + { enabledProviders: ['codex'], fixtureFreshAgentModes: ['freshcodex'], feature: 'managed-fresh-agent-fixtures' }, + ])('builds the selected browser server with the exact locked compiler: $feature', selection => { + const f = fixture() + const rig = new ManagedRuntimeBrowserRig(f.root, 2, {}, {}, 'test', selection) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + const builds: string[][] = [] + childFixture.execute = (program, args) => { + if (!program.endsWith('/mise') || args[1] !== 'rust@1.96.0' || !args.includes('--locked')) { + throw new Error('browser selected a different compiler or unlocked build') + } + builds.push(args) + const target = path.join(f.root, 'target/release') + fs.mkdirSync(target, { recursive: true }) + fs.writeFileSync(path.join(target, 'freshell-server'), `#!/bin/sh\n# ${args[args.indexOf('--features') + 1]}\n`, { mode: 0o755 }) + return '' + } + const server = (rig as unknown as { buildManagedServer(): string }).buildManagedServer() + expect(fs.readFileSync(server, 'utf8')).toContain(`# ${selection.feature}`) + expect(builds).toHaveLength(1) + fs.rmSync(rig.runtime.buildDir, { recursive: true, force: true }) + }) + + it('keeps compiler provenance unknown for an older prepared manifest', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + const manifestPath = f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV]! + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + delete manifest.command + fs.writeFileSync(manifestPath, JSON.stringify(manifest)) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, manifestPath) + try { + const runtime = new RuntimeHarness(f.root, undefined, 2) + const { execFileSync: execute } = await vi.importActual('node:child_process') + childFixture.execute = (program, args, options) => { + if (program === 'docker') return 'fixture client/server' + if (program === 'git') return execute(program, args, options) + throw new Error('older provenance must not be inferred from an installed compiler') + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): any } + methods.buildBinaries() + expect(methods.collectBuildInfo().build).toEqual({ source: 'prepared', manifestPath, command: null }) + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + } finally { await teardown?.() } + }) + 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, '../../../..') From a2c28eeac42e3cfafb0f73ee339c6519143dadb7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:42:48 -0700 Subject: [PATCH 43/92] test: join Claude fixture processes before removing HOME --- .../providers/claude-freshell-mcp.mjs | 6 +- .../providers/claude-local-provider.mjs | 71 ++++++++++++ .../claude-fixture-cleanup.test.ts | 105 ++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 test/unit/provider-fixtures/claude-fixture-cleanup.test.ts diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs index dad524c41..dead41909 100644 --- a/test/integration/providers/claude-freshell-mcp.mjs +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -67,7 +67,7 @@ try { }) failureStage = 'sidecar start' - sidecar = spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { + sidecar = fixture.ownProcess(spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { cwd: sidecarDir, env: fixture.env(provider.url, { CLAUDE_CMD: pinned.nativeCli, @@ -78,7 +78,7 @@ try { FRESHELL_TOKEN: token, }), stdio: ['pipe', 'pipe', 'pipe'], - }) + })) let stderr = '' sidecar.stderr.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000) }) sidecar.stdin.on('error', () => {}) @@ -111,6 +111,7 @@ try { 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') + await fixture.ownProcessId(created.cliPid) failureStage = 'cli argv' const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') @@ -155,6 +156,7 @@ try { for (let waited = 0; waited < 10_000 && receipt.probe === null; waited += 50) { try { receipt.probe = JSON.parse(await fs.readFile(reportFile, 'utf8')) } catch { await sleep(50) } } + if (receipt.probe?.pid) await fixture.ownProcessId(receipt.probe.pid) const tokenDigest = createHash('sha256').update(token).digest('hex') const probeOk = receipt.probe !== null diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index dd0d425c2..33a463152 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -128,6 +128,7 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres const tmp = path.join(root, 'tmp') for (const directory of [home, cwd, tmp]) await fs.mkdir(directory, { recursive: true, mode: 0o700 }) const claudeConfigDir = path.join(home, '.claude') + const ownedProcessLifetimes = new Map() return { root, home, @@ -186,12 +187,82 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres const complete = text.slice(0, text.lastIndexOf('\n') + 1) return complete.split('\n').filter(Boolean).map((line) => JSON.parse(line)) }, + /** Register immediately after spawn so even an early close is retained. */ + ownProcess(child) { + const closed = new Promise(resolve => child.once('close', resolve)) + ownedProcessLifetimes.set(child.pid, () => waitForFixtureProcess(closed, child.pid)) + return child + }, + /** The sidecar's created frame identifies its separately owned native CLI. */ + async ownProcessId(pid) { + if (!Number.isInteger(pid) || pid <= 0) throw new Error('fixture process PID must be a positive integer') + if (ownedProcessLifetimes.has(pid)) return + const identity = await liveFixtureProcessIdentity(pid) + if (identity) ownedProcessLifetimes.set(pid, () => waitForFixturePid(pid, identity)) + }, async cleanup() { + // A sidecar exit/query.close() initiates CLI shutdown; it does not join + // the native writer. Never remove HOME until all registered owners have + // settled. A missing exit retains the fixture and fails diagnostically. + const outcomes = await Promise.allSettled([...ownedProcessLifetimes.values()].map(wait => wait())) + const failures = outcomes.filter(result => result.status === 'rejected').map(result => result.reason) + if (failures.length) throw new AggregateError(failures, `Claude fixture process cleanup incomplete; retained ${root}`) await fs.rm(root, { recursive: true, force: true }) }, } } +const FIXTURE_PROCESS_EXIT_BUDGET_MS = 5_000 + +async function liveFixtureProcessIdentity(pid) { + let stat + try { stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8') } + catch (error) { if (error.code === 'ENOENT' || error.code === 'ESRCH') return null; throw error } + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ') + if (!/^\d+$/.test(fields[19] ?? '')) throw new Error(`cannot establish fixture process identity for PID ${pid}`) + // Zombies cannot write; start time distinguishes the captured process from + // a later process reusing its PID. No signal is sent by fixture cleanup. + return fields[0] === 'Z' || fields[0] === 'X' ? null : fields[19] +} + +function processExitError(pid) { + return new Error(`fixture-owned process ${pid} did not settle within ${FIXTURE_PROCESS_EXIT_BUDGET_MS}ms`) +} + +async function waitForFixtureProcess(closed, pid) { + let timer + try { + await Promise.race([closed, new Promise((_, reject) => { + timer = setTimeout(() => reject(processExitError(pid)), FIXTURE_PROCESS_EXIT_BUDGET_MS) + })]) + } finally { clearTimeout(timer) } +} + +function waitForFixturePid(pid, identity) { + return new Promise((resolve, reject) => { + let settled = false + let reading = false + const finish = error => { + if (settled) return + settled = true + clearInterval(poll) + clearTimeout(deadline) + if (error) reject(error) + else resolve() + } + const check = async () => { + if (reading || settled) return + reading = true + try { if (await liveFixtureProcessIdentity(pid) !== identity) finish() } + catch (error) { finish(error) } + finally { reading = false } + } + const poll = setInterval(check, 10) + const deadline = setTimeout(() => finish(processExitError(pid)), FIXTURE_PROCESS_EXIT_BUDGET_MS) + void check() + }) +} + function textOfContent(content) { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' diff --git a/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts b/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts new file mode 100644 index 000000000..e687f3776 --- /dev/null +++ b/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment node +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter, once } from 'node:events' +import fs from 'node:fs/promises' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createClaudeFixture } from '../../integration/providers/claude-local-provider.mjs' + +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + +// An ordinary owned writer, stopped by IPC rather than signals. This exercises +// the same HOME ownership as a native CLI running under a separate sidecar. +const writer = ` + const fs = require('node:fs/promises') + const path = require('node:path') + process.on('message', async message => { + if (message === 'finish') return process.disconnect() + if (message !== 'write') return + try { + await fs.writeFile(path.join(process.env.HOME, 'last-native-write'), 'saved') + process.send({ written: true }) + } catch (error) { process.send({ written: false, code: error.code }) } + }) + process.send({ ready: true }) +` + +async function finish(child: ChildProcess, closed: Promise) { + if (child.connected) child.send('finish') + await closed +} + +it.each(['child', 'native-pid'] as const)('retains HOME until an explicitly owned %s finishes its final write and exits', async ownership => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-cleanup-' }) + const child = spawn(process.execPath, ['-e', writer], { + env: fixture.env('http://127.0.0.1:9'), + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }) + const closed = once(child, 'close') + await once(child, 'message') + if (ownership === 'child') fixture.ownProcess(child) + else await fixture.ownProcessId(child.pid!) + let removedWhileWriterActive = false + const remove = fs.rm + const removal = vi.spyOn(fs, 'rm').mockImplementation(async (target, options) => { + if (target === fixture.root && child.exitCode === null && child.signalCode === null) removedWhileWriterActive = true + return remove(target, options) + }) + const cleaning = fixture.cleanup() + try { + // A final write after teardown begins must still have its owned HOME. + // The IPC acknowledgement establishes the schedule without a sleep. + const written = once(child, 'message') + child.send('write') + const report = (await written)[0] + const saved = report.written ? await fs.readFile(path.join(fixture.home, 'last-native-write'), 'utf8') : null + await finish(child, closed) + await cleaning + expect(removedWhileWriterActive, 'cleanup must not remove HOME while its owned writer is running').toBe(false) + expect(report).toEqual({ written: true }) + expect(saved).toBe('saved') + await expect(fs.stat(fixture.root)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await finish(child, closed) + try { + await cleaning + await fixture.cleanup() + } finally { removal.mockRestore() } + } +}) + +it('joins inherited stdio close after sidecar exit before removing its fixture', async () => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-pipe-close-' }) + const sidecar = Object.assign(new EventEmitter(), { pid: 12345 }) + fixture.ownProcess(sidecar) + const cleaning = fixture.cleanup() + try { + sidecar.emit('exit', 0, null) + // A descendant still holding the pipes may write after the sidecar exits. + await fs.writeFile(path.join(fixture.home, 'inherited-writer'), 'final') + expect(await fs.readFile(path.join(fixture.home, 'inherited-writer'), 'utf8')).toBe('final') + } finally { + sidecar.emit('close', 0, null) + await cleaning + } + await expect(fs.stat(fixture.root)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('bounds a missing owner close and retains HOME for diagnosis instead of deleting active state', async () => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-missing-close-' }) + const sidecar = Object.assign(new EventEmitter(), { pid: 12345 }) + fixture.ownProcess(sidecar) + vi.useFakeTimers() + const cleaning = fixture.cleanup() + const failure = cleaning.catch(error => error) + await vi.advanceTimersByTimeAsync(5_000) + const error = await failure + expect(error).toBeInstanceOf(AggregateError) + expect(error.message).toContain('Claude fixture process cleanup incomplete; retained') + expect(error.errors[0].message).toContain('fixture-owned process 12345 did not settle within 5000ms') + expect((await fs.stat(fixture.home)).isDirectory()).toBe(true) + expect(vi.getTimerCount()).toBe(0) + sidecar.emit('close', 0, null) + await fixture.cleanup() + expect(vi.getTimerCount()).toBe(0) +}) From c9f5834530adaf2272b6127130a97f8717c2057f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:04:06 -0700 Subject: [PATCH 44/92] test(codex): expose missing file access and plan browser behavior --- .../specs/fresh-agent-control-rust.spec.ts | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) 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 184e06967..e585073f0 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1289,6 +1289,7 @@ async function bootCodexLane( projectDir: string opLogPath: string responseLogPath: string + requestLogPath: string tabId: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-agentctl-codex-')) @@ -1297,6 +1298,7 @@ async function bootCodexLane( await fs.mkdir(projectDir, { recursive: true }) const opLogPath = path.join(sharedRoot, 'codex-ops.jsonl') const responseLogPath = path.join(sharedRoot, 'codex-responses.jsonl') + const requestLogPath = path.join(sharedRoot, 'codex-requests.jsonl') const { server, info, harness } = await bootWall(page, { env: { // Whitespace-split by spawn_sidecar (codex.rs): interpreter + script. @@ -1307,6 +1309,7 @@ async function bootCodexLane( // answers the recorded history, so forks provably diverge at the pin. recordTurns: true, appendClientResponseLogPath: responseLogPath, + appendRequestLogPath: requestLogPath, ...behavior, }), ...extraEnv, @@ -1319,7 +1322,7 @@ async function bootCodexLane( const tabId = (await harness.getActiveTabId())! expect(tabId).toBeTruthy() await createFreshAgentPane(page, /^Freshcodex$/, 'Freshcodex', projectDir) - return { server, info, harness, sharedRoot, projectDir, opLogPath, responseLogPath, tabId } + return { server, info, harness, sharedRoot, projectDir, opLogPath, responseLogPath, requestLogPath, tabId } } catch (error) { await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) throw error @@ -1521,6 +1524,60 @@ async function readRollout(homeDir: string, threadId: string): Promise { test.setTimeout(240_000) + test('Freshcodex file access and Plan settings reach native turns and survive reload', async ({ page }) => { + const lane = await bootCodexLane(page, { serverRequestsByPrompt: { + 'Plan the change': { id: 'plan-question', method: 'item/tool/requestUserInput', params: { + isBlocking: true, questions: [{ id: 'approach', header: 'Approach', question: 'Choose an approach', options: [{ label: 'Small change', description: 'Keep the change focused' }] }], + } }, + } }) + try { + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'Settings readiness control') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(1) + console.log('TASK008_SETTINGS_NATIVE_READY', JSON.stringify({ threadId: 'thread-new-1', status: 'idle', completedControl: true })) + + await page.getByRole('button', { name: 'Agent settings' }).click() + const settings = page.getByRole('dialog', { name: 'Agent settings' }) + await settings.getByRole('combobox', { name: 'File access', exact: true }).selectOption('read-only') + await settings.getByRole('combobox', { name: 'Work mode', exact: true }).selectOption('plan') + await page.keyboard.press('Escape') + await sendComposerText(page, 'Plan the change') + const question = page.getByRole('region', { name: 'Question from Codex' }) + await expect(question).toBeVisible() + const starts = () => readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start') + await expect.poll(() => starts().length).toBe(2) + const plan = starts()[1].params + expect(plan).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { + mode: 'plan', settings: { model: plan.model, reasoning_effort: plan.effort, developer_instructions: null }, + } }) + await question.getByRole('button', { name: 'Small change', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 'plan-question', result: { answers: { approach: { answers: ['Small change'] } } } }) + await expect.poll(async () => (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1'))?.status).toBe('idle') + await expect.poll(() => readFreshAgentSessionStatus(lane.harness, 'thread-new-1')).toBe('idle') + + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('combobox', { name: 'Work mode', exact: true }).selectOption('default') + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 6, 'Build the change') + expect(starts()[2].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { + mode: 'default', settings: { model: plan.model, reasoning_effort: plan.effort, developer_instructions: null }, + } }) + + await page.reload() + await lane.harness.waitForHarness() + await lane.harness.waitForConnection() + await page.getByRole('button', { name: 'Agent settings' }).click() + await expect(settings.getByRole('combobox', { name: 'File access', exact: true })).toHaveValue('read-only') + await expect(settings.getByRole('combobox', { name: 'Work mode', exact: true })).toHaveValue('default') + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 8, 'After settings reload') + expect(starts()[3].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, collaborationMode: { mode: 'default' } }) + } finally { + await lane.server.stop() + await fs.rm(lane.sharedRoot, { recursive: true, force: true }) + } + }) + test('Codex approvals and questions survive reload and send user decisions to the provider', async ({ page }) => { const lane = await bootCodexLane(page, { serverRequestsByPrompt: { 'Approve tests': { id: 501, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests' } }, From 0b4a8631a4c406601e159a92d871b3b27608ba94 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:30:18 -0700 Subject: [PATCH 45/92] feat(codex): expose faithful settings and native plan mode --- .../freshell-agent-runtime/src/host_actor.rs | 32 +- .../src/host_actor_tests.rs | 7 +- .../src/snapshot_projection.rs | 2 +- crates/freshell-codex/src/app_server.rs | 27 + .../freshell-codex/tests/app_server_drive.rs | 3 + crates/freshell-codex/tests/interrupt_rpc.rs | 1 + crates/freshell-freshagent/src/claude.rs | 31 +- .../src/claude/end_of_output_tests.rs | 1 + .../src/claude/live_frames_tests.rs | 3 + .../src/claude/recovery.rs | 2 +- .../src/claude/tests/recovery_tests.rs | 1 + .../tests/settings_persistence_tests.rs | 2 + .../src/claude_rest_lane.rs | 3 +- crates/freshell-freshagent/src/codex.rs | 1398 ++++++++++++++--- .../freshell-freshagent/src/codex/settings.rs | 183 +++ .../src/codex_sidecar_tracking_tests.rs | 3 + .../freshell-freshagent/src/identity_sink.rs | 2 + crates/freshell-freshagent/src/lib.rs | 2 + crates/freshell-freshagent/src/opencode_ws.rs | 49 +- .../src/pane_ops_tab_tests.rs | 2 + .../src/session_handoff/tests.rs | 3 + .../freshell-protocol/src/client_messages.rs | 24 +- .../src/fresh_agent_ops_tests.rs | 5 +- crates/freshell-runtime-protocol/src/lib.rs | 21 +- .../freshell-server/src/fresh_agent_proxy.rs | 4 +- .../src/fresh_agent_proxy_rest.rs | 3 +- .../src/fresh_agent_proxy_tests.rs | 5 +- crates/freshell-server/src/identity_sink.rs | 92 ++ .../src/recovery_inventory_tests.rs | 4 + crates/freshell-session-host/src/main.rs | 7 +- .../providers/deterministic_fresh_agent.rs | 2 + .../src/providers/fresh_agent.rs | 295 +++- crates/freshell-supervisor/src/backend.rs | 3 + .../src/fresh_agent_configure_tests.rs | 68 +- crates/freshell-supervisor/src/recovery.rs | 10 +- crates/freshell-supervisor/src/registry.rs | 9 +- .../freshell-supervisor/src/resume_catalog.rs | 2 + crates/freshell-ws/src/codex_association.rs | 1 + crates/freshell-ws/src/invariants.rs | 1 + .../freshell-ws/src/opencode_association.rs | 1 + crates/freshell-ws/src/pane_ledger.rs | 6 + crates/freshell-ws/src/pane_ledger_tests.rs | 12 + crates/freshell-ws/tests/common/claude_rig.rs | 1 + .../freshell-ws/tests/common/ledger_sink.rs | 2 + .../freshell-ws/tests/cross_kind_liveness.rs | 1 + .../tests/pane_reconcile_freshagent.rs | 3 + docs/index.html | 2 +- port/contract/ws-protocol.schema.json | 162 +- port/contract/ws-server-messages.schema.json | 18 + shared/fresh-agent-contract.ts | 7 +- shared/settings.ts | 14 +- shared/ws-protocol.ts | 9 +- .../fresh-agent/FreshAgentModelDialog.tsx | 24 +- .../fresh-agent/FreshAgentSettingsButton.tsx | 49 +- src/components/fresh-agent/FreshAgentView.tsx | 54 +- src/lib/fresh-agent-configure.ts | 8 +- src/lib/fresh-agent-create-content.ts | 5 +- src/lib/fresh-agent-registry.ts | 17 +- src/lib/fresh-agent-setting-scopes.ts | 3 +- src/lib/fresh-agent-settings-convergence.ts | 47 + src/lib/fresh-agent-ws.ts | 2 + src/store/freshAgentSlice.ts | 4 + src/store/freshAgentTypes.ts | 2 + src/store/paneTypes.ts | 3 + src/store/panesSlice.ts | 4 + src/store/persistedState.ts | 9 + .../specs/fresh-agent-control-rust.spec.ts | 62 +- .../freshagent-settings-resume-rust.spec.ts | 61 +- .../codex-app-server/fake-app-server.mjs | 13 +- .../FreshAgentModelDialog.test.tsx | 18 +- .../FreshAgentSettingsButton.test.tsx | 35 + .../fresh-agent/FreshAgentView.test.tsx | 32 + .../lib/fresh-agent-create-content.test.ts | 19 +- .../fresh-agent-settings-convergence.test.ts | 14 + .../client/store/panesPersistence.test.ts | 22 + 75 files changed, 2686 insertions(+), 372 deletions(-) create mode 100644 crates/freshell-freshagent/src/codex/settings.rs create mode 100644 src/lib/fresh-agent-settings-convergence.ts create mode 100644 test/unit/client/lib/fresh-agent-settings-convergence.test.ts diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 9647ebb53..731dbd944 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -50,6 +50,8 @@ mod double_optional { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, pub provider: FreshProvider, pub runtime_variant: String, pub cwd: String, @@ -95,6 +97,9 @@ impl FreshAgentProfile { /// Folds per-turn settings into the durable launch profile: every /// provided field replaces the stored one, absent fields keep it. pub fn apply_turn_settings(&mut self, settings: &FreshAgentTurnSettings) { + if let Some(value) = &settings.collaboration_mode { + self.collaboration_mode = Some(value.clone()); + } if let Some(value) = &settings.cwd { self.cwd = value.clone(); } @@ -102,7 +107,7 @@ impl FreshAgentProfile { self.model = Some(value.clone()); } if let Some(value) = &settings.effort { - self.effort = Some(value.clone()); + self.effort = value.clone(); } if let Some(value) = &settings.permission_mode { self.permission_mode = Some(value.clone()); @@ -128,6 +133,10 @@ impl FreshAgentProfile { pub fn validate(&self) -> Result<(), ActorError> { if self.runtime_variant.is_empty() + || self + .collaboration_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "default" | "plan")) || self.cwd.is_empty() || self.provider_store_id.is_empty() || self.native_session_id.as_deref().is_some_and(str::is_empty) @@ -265,6 +274,20 @@ pub trait FreshAgentTransport: Send + Sync { ) -> Result { self.dispatch(request_id, text, profile).await } + /// Carries the caller's partial settings separately from durable launch + /// intent. Providers with native effective defaults must not restate an + /// unchanged launch profile as new overrides on every send. + async fn dispatch_with_turn_settings( + &self, + request_id: &RequestId, + text: &str, + profile: &FreshAgentProfile, + _settings: Option<&FreshAgentTurnSettings>, + logical_attempt_id: Option<&str>, + ) -> Result { + self.dispatch_with_checkpoint_identity(request_id, text, profile, logical_attempt_id) + .await + } async fn resolve_permission( &self, decision_id: &str, @@ -688,8 +711,8 @@ impl FreshAgentHostActor { other => Ok(other), }; } - if let Some(settings) = settings { - state.profile.apply_turn_settings(&settings); + if let Some(settings) = settings.as_ref() { + state.profile.apply_turn_settings(settings); state.profile.validate()?; } dispatch_profile = state.profile.clone(); @@ -714,10 +737,11 @@ impl FreshAgentHostActor { let result = self .transport - .dispatch_with_checkpoint_identity( + .dispatch_with_turn_settings( &request_id, &text, &dispatch_profile, + settings.as_ref(), logical_attempt_id.as_deref(), ) .await; diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index ffd127bca..57db0d4b2 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -632,6 +632,7 @@ impl FreshAgentTransport for FakeTransport { fn profile(provider: FreshProvider, store: &str, native: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + collaboration_mode: None, runtime_variant: provider.as_str().into(), provider, cwd: "/workspace".into(), @@ -1113,9 +1114,10 @@ async fn exact_native_identity_and_profile_are_reused_on_host_actor_recovery() { RequestId::parse("settings-update").unwrap(), "continue".into(), Some(FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("updated-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, }), @@ -1776,9 +1778,10 @@ impl FreshAgentTransport for ConfigureTransport { fn opus_high() -> FreshAgentTurnSettings { FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, } diff --git a/crates/freshell-agent-runtime/src/snapshot_projection.rs b/crates/freshell-agent-runtime/src/snapshot_projection.rs index a0e556c62..5e3dbd75f 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection.rs @@ -147,7 +147,7 @@ fn valid_scopes(value: &Value) -> bool { scopes.iter().all(|(name, value)| { matches!( name.as_str(), - "model" | "effort" | "sandbox" | "permissionMode" + "model" | "effort" | "sandbox" | "permissionMode" | "cwd" | "collaborationMode" ) && matches!( value.as_str(), Some("per-send" | "create-only" | "unsupported") diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index 54080da85..f2e82a312 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -178,6 +178,9 @@ pub struct StartThreadParams { pub struct StartedThread { pub thread_id: String, pub reasoning_effort: Option, + /// Effective settings stated by native start/resume. Absent fields are + /// deliberately distinguishable from an explicit null native default. + pub effective_settings: Value, } /// `thread/fork` params (the 0.147.0 `ThreadForkParams` generated type): `{threadId, @@ -211,6 +214,7 @@ pub struct StartTurnParams { pub effort: Option, pub sandbox_policy: Option, pub approval_policy: Option, + pub collaboration_mode: Option, } /// The `turn/start` result (`CodexTurnStartResult`, `protocol.ts:318-320`): the provider turn id. @@ -463,6 +467,7 @@ impl CodexAppServerClient { Ok(StartedThread { thread_id, reasoning_effort, + effective_settings: effective_thread_settings(&result), }) } @@ -518,6 +523,7 @@ impl CodexAppServerClient { Ok(StartedThread { thread_id: resumed_thread_id, reasoning_effort, + effective_settings: effective_thread_settings(&result), }) } @@ -606,6 +612,9 @@ impl CodexAppServerClient { insert_opt_str(&mut wire, "model", params.model); // DEV-0003: the reasoning effort crosses the wire EXACTLY as given (no clamp/remap). insert_opt_str(&mut wire, "effort", params.effort); + if let Some(mode) = params.collaboration_mode { + wire.insert("collaborationMode".to_string(), mode); + } let result = self.request("turn/start", Value::Object(wire)).await?; let turn_id = result @@ -933,6 +942,23 @@ fn fail_all_pending(pending: &PendingMap, _reason: &str) { } } +fn effective_thread_settings(response: &Value) -> Value { + let mut settings = Map::new(); + for key in [ + "model", + "reasoningEffort", + "cwd", + "sandbox", + "approvalPolicy", + "collaborationMode", + ] { + if let Some(value) = response.get(key) { + settings.insert(key.into(), value.clone()); + } + } + Value::Object(settings) +} + fn insert_opt_str(map: &mut Map, key: &str, value: Option) { if let Some(value) = value { map.insert(key.to_string(), json!(value)); @@ -1397,6 +1423,7 @@ mod tests { let turn_effort = effort.to_string(); let task = tokio::spawn(async move { c.start_turn(StartTurnParams { + collaboration_mode: None, thread_id: "thread-1".to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-codex/tests/app_server_drive.rs b/crates/freshell-codex/tests/app_server_drive.rs index dab60bcef..7f24b7160 100644 --- a/crates/freshell-codex/tests/app_server_drive.rs +++ b/crates/freshell-codex/tests/app_server_drive.rs @@ -93,6 +93,7 @@ async fn full_drive_completed_turn_emits_the_positive_edge_with_verbatim_effort( .unwrap(); let turn = client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "freshell-t2-ok" })], cwd: Some("/work".to_string()), @@ -173,6 +174,7 @@ async fn full_drive_user_armed_interrupted_turn_does_not_chime() { .unwrap(); client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, @@ -245,6 +247,7 @@ async fn rpc_error_on_turn_start_surfaces_to_the_caller() { .expect("thread/start"); client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-codex/tests/interrupt_rpc.rs b/crates/freshell-codex/tests/interrupt_rpc.rs index c2096ab7d..9b969e5fe 100644 --- a/crates/freshell-codex/tests/interrupt_rpc.rs +++ b/crates/freshell-codex/tests/interrupt_rpc.rs @@ -65,6 +65,7 @@ async fn interrupt_turn_rpc_then_interrupted_completion_snapshots_without_chime( .expect("thread/start"); let turn = client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index f4d4c450d..68ec6435d 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2016,10 +2016,11 @@ impl FreshClaudeState { // `sdk.session.init` (claude has no sandbox concept: always `None`), and // the launch is built from the same values (G15: the plugins too). let settings = crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: msg.model.clone(), sandbox: None, permission_mode: msg.permission_mode.clone(), - effort: msg.effort.clone(), + effort: msg.effort.clone().flatten(), cwd: msg.cwd.clone(), plugins: None, } @@ -8092,6 +8093,7 @@ pub(crate) mod tests { let mut send = send_msg(&session_id, "carries an unsupported knob"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, effort: None, @@ -8264,15 +8266,16 @@ pub(crate) mod tests { state.set_identity_sink(fake.clone()); let mut create = dedup_create_msg("settings-send"); create.model = Some("opus".into()); - create.effort = Some("high".into()); + create.effort = Some(Some("high".into())); state.handle_create(create, None).await; let created = await_claude_created(&mut rx, "settings-send").await; let session_id = created["sessionId"].as_str().unwrap(); let mut send = send_msg(session_id, "Review this image"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("sonnet".into()), - effort: Some("max".into()), + effort: Some(Some("max".into())), permission_mode: Some("plan".into()), sandbox: None, }); @@ -8335,14 +8338,15 @@ pub(crate) mod tests { state.set_identity_sink(fake.clone()); let mut create = dedup_create_msg("partial-settings"); create.model = Some("opus".into()); - create.effort = Some("high".into()); + create.effort = Some(Some("high".into())); state.handle_create(create, None).await; let created = await_claude_created(&mut rx, "partial-settings").await; let session_id = created["sessionId"].as_str().unwrap(); let mut send = send_msg(session_id, "Keep this unsent"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("sonnet".into()), - effort: Some("invalid".into()), + effort: Some(Some("invalid".into())), cwd: None, permission_mode: None, sandbox: None, @@ -8390,6 +8394,7 @@ pub(crate) mod tests { let mut send = send_msg(session_id, "Do not lose this prompt"); send.request_id = Some("retained-prompt".into()); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("unavailable".into()), effort: None, @@ -8438,6 +8443,7 @@ pub(crate) mod tests { fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -14981,7 +14987,7 @@ pub(crate) mod tests { msg.session_type = SessionType::Kilroy; msg.model = Some("opus-x".to_string()); msg.permission_mode = Some("plan".to_string()); - msg.effort = Some("high".to_string()); + msg.effort = Some(Some("high".to_string())); msg.cwd = Some(env.dir.to_string_lossy().to_string()); state.handle_create(msg, None).await; // Create publishes only after the native binding is durable. @@ -15164,6 +15170,7 @@ pub(crate) mod tests { "claude", DURABLE, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("opus-x".into()), sandbox: None, permission_mode: Some("plan".into()), @@ -17135,11 +17142,12 @@ rl.on('line', (line) => { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -17307,9 +17315,10 @@ rl.on('line', (line) => { let mut send = send_msg(&session_id, "carries a new model"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); @@ -17351,9 +17360,10 @@ rl.on('line', (line) => { // exactly one metadata frame converges. let mut send = send_msg(&session_id, "carries a new model"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); @@ -17377,9 +17387,10 @@ rl.on('line', (line) => { // no second configure), and the unchanged pair converges nothing. let mut resend = send_msg(&session_id, "same pair again"); resend.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); 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 bfe9da175..0fedef8f9 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -252,6 +252,7 @@ impl Drop for EndingSession { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 8d759281d..d2801fb89 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -126,6 +126,7 @@ impl Drop for ClaudeEnvReset { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -171,6 +172,7 @@ fn configure_msg(session_id: &str, permission_mode: &str) -> FreshAgentConfigure cwd: None, request_id: Some("live-frames-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, permission_mode: Some(permission_mode.to_string()), @@ -441,6 +443,7 @@ async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it( let mut session = LiveSession::start(Some("plan")).await; let mut send = send_msg(&session.pane, "[]"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, permission_mode: Some("acceptEdits".to_string()), diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index b3d0e578a..ea2bca855 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -259,7 +259,7 @@ pub(super) fn updated_settings( next.model = Some(model.clone()); } if let Some(effort) = &requested.effort { - next.effort = Some(effort.clone()); + next.effort = effort.clone(); } if let Some(permission_mode) = &requested.permission_mode { next.permission_mode = Some(permission_mode.clone()); diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs index dcec8c677..87e5034cc 100644 --- a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -66,6 +66,7 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme .configure_for_send( &id, &freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("old-runtime-model".into()), cwd: None, effort: None, diff --git a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs index a34f34563..b99ec947b 100644 --- a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs @@ -19,6 +19,7 @@ async fn a_configure_receipt_after_reset_saves_the_same_runtime_under_its_curren .configure_for_send( &old, &freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("sonnet".into()), cwd: None, effort: None, @@ -91,6 +92,7 @@ async fn settings_persistence_failure_preserves_the_runtime_and_unsent_prompt_fo 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 { + collaboration_mode: None, model: Some("sonnet".into()), cwd: None, effort: None, diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index bec8840d3..dd0ca08bf 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -38,6 +38,7 @@ impl ClaudeRestLane { .state .handle_create( FreshAgentCreate { + collaboration_mode: None, request_id: request.request_id, provider: Some(AgentProvider::Claude), session_type, @@ -45,7 +46,7 @@ impl ClaudeRestLane { model: request.model, model_selection: request.model_selection, permission_mode: request.permission_mode, - effort: request.effort, + effort: request.effort.map(Some), plugins: request.plugins, session_ref, sandbox: request.sandbox, diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 9e3ba52d3..447307d31 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -62,11 +62,10 @@ use freshell_codex::launch_lifecycle::{ use freshell_codex::launch_plan::{codex_sidecar_spawn_spec, CodexSidecarLaunchContext}; use freshell_codex::transport::{reap_owned_codex_sidecars, TungsteniteTransport}; use freshell_codex::{ - mint_ownership_id, normalize_codex_thread_status, normalize_freshcodex_effort, - normalize_freshcodex_model, read_rollout_history_mode, to_codex_reasoning_effort, - CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, CodexNotification, CodexStatus, - CodexSubscription, HistoryMode, StartThreadParams, StartTurnParams, ThreadForkParams, - CODEX_SIDECAR_OWNERSHIP_ENV, + mint_ownership_id, normalize_codex_thread_status, read_rollout_history_mode, + to_codex_reasoning_effort, CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, + CodexNotification, CodexStatus, CodexSubscription, HistoryMode, StartThreadParams, + StartTurnParams, ThreadForkParams, CODEX_SIDECAR_OWNERSHIP_ENV, }; use freshell_protocol::{ ErrorCode, ErrorMsg, FreshAgentAttach, FreshAgentCompact, FreshAgentConfigure, @@ -83,6 +82,8 @@ use crate::summary::{ use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; pub mod children; +pub mod settings; +use settings::{CodexEffectiveSettings, CodexWorkMode}; mod controls; mod elicitation; mod metadata; @@ -526,6 +527,9 @@ struct CodexCreateRecord { /// One live freshcodex session: the app-server client, its owned sidecar, and the /// normalized create-time settings a later `send` re-uses. struct CodexSession { + collaboration_mode: Option, + native_turn_settings: Option, + native_thread_settings: Option, turn_state: SharedCodexTurnState, projection: Arc>, client: Arc, @@ -685,6 +689,37 @@ struct CodexSession { provenance: Option, } +impl CodexSession { + fn effective_settings(&self) -> CodexEffectiveSettings { + let mut settings = CodexEffectiveSettings { + model: self.model.clone(), + effort: self.effort.clone(), + cwd: self.cwd.clone(), + sandbox: self.sandbox.clone(), + permission_mode: self.permission_mode.clone(), + collaboration_mode: self.collaboration_mode, + }; + // Historical lineage-only resumes have no recorded next-turn intent. + // Their native observations still supply working/display defaults; + // provenance-only refreshes keep the raw blank sentinel untouched. + if self.model.is_empty() { + if let Some(native) = &self.native_thread_settings { + settings.adopt_native(native); + } + } + settings + } + + fn apply_settings(&mut self, settings: &CodexEffectiveSettings) { + self.model = settings.model.clone(); + self.effort = settings.effort.clone(); + self.cwd = settings.cwd.clone(); + self.sandbox = settings.sandbox.clone(); + self.permission_mode = settings.permission_mode.clone(); + self.collaboration_mode = settings.collaboration_mode; + } +} + struct CodexCheckpointIdentityProbe { client: Arc, session_id: String, @@ -1698,6 +1733,7 @@ impl FreshCodexState { permission_mode: Option<&str>, effort: Option<&str>, cwd: Option<&str>, + collaboration_mode: Option, supersedes: Option<&str>, // D8 provenance stamps — `Some` from connection-scoped creates and // from the fork lane (the RESOLVED fork provenance: forking @@ -1726,6 +1762,7 @@ impl FreshCodexState { return Ok(()); }; let settings = crate::identity_sink::FreshAgentSettings { + collaboration_mode: collaboration_mode.map(|mode| mode.as_str().into()), model: if model.is_empty() { None } else { @@ -1950,6 +1987,8 @@ impl FreshCodexState { snapshot["status"] = json!("exited"); } self.overlay_controls(session_id, &mut snapshot).await; + self.overlay_settings_snapshot(session_id, &mut snapshot) + .await; let pending = self.retained_control_events(session_id).await; let marks = self .attention_marks() @@ -2218,33 +2257,43 @@ impl FreshCodexState { .unwrap_or_default(), None => crate::identity_sink::FreshAgentSettings::default(), }; - let cwd = msg.cwd.clone().or(rec.cwd); let raw_model = msg .model .clone() .or_else(|| { msg.model_selection .as_ref() - .and_then(|s| s.as_ref()) - .map(|s| s.model_id.clone()) + .and_then(|selection| selection.as_ref()) + .map(|selection| selection.model_id.clone()) }) .or(rec.model); - let model = normalize_freshcodex_model(raw_model.as_deref()); - let raw_effort = msg.effort.clone().or(rec.effort); - let effort = normalize_freshcodex_effort(Some(&model), raw_effort.as_deref()); - let mut sandbox = msg.sandbox.map(sandbox_wire_value).or(rec.sandbox); - let mut permission_mode = msg.permission_mode.clone().or(rec.permission_mode); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", error); - return; - } - - // Keep nonempty native values for native validation; discovery is not - // exhaustive for custom providers. - if let Err(err) = to_codex_reasoning_effort(effort.as_deref()) { - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); - return; - } + let base = CodexEffectiveSettings { + model: raw_model.unwrap_or_default(), + effort: msg.effort.clone().unwrap_or(rec.effort), + cwd: msg.cwd.clone().or(rec.cwd), + sandbox: msg.sandbox.map(sandbox_wire_value).or(rec.sandbox), + permission_mode: msg.permission_mode.clone().or(rec.permission_mode), + collaboration_mode: None, + }; + let patch = freshell_protocol::FreshAgentSendSettings { + collaboration_mode: msg.collaboration_mode.clone().or(rec.collaboration_mode), + ..Default::default() + }; + let effective = match CodexEffectiveSettings::resolve(&base, &patch) { + Ok(settings) => settings, + Err(error) => { + self.fail_create(&request_id, error.code, &error.message); + return; + } + }; + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; // FIX (CODEX-FIRST triage Finding 1): a `freshAgent.create` carrying // `resumeSessionId` must RESUME the existing thread -- mirroring the reference's @@ -2478,6 +2527,7 @@ impl FreshCodexState { effort, sandbox, permission_mode, + collaboration_mode, lease_guard, own_ticket, provenance, @@ -2583,8 +2633,8 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), }) .await; - let thread_id = match started { - Ok(started) => started.thread_id, + let started = match started { + Ok(started) => started, Err(err) => { client.close().await; let mut child = child; @@ -2596,6 +2646,25 @@ impl FreshCodexState { } }; + let thread_id = started.thread_id.clone(); + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + // b8ke ext r22 F1: the MINT-TIME REKEY — the SAME ticket's // in-flight record moves from the pane-scoped provisional key to // the canonical thread id in ONE atomic step (`rekey_starting`: @@ -2664,6 +2733,8 @@ impl FreshCodexState { cwd, sandbox, permission_mode, + collaboration_mode, + started.effective_settings.clone(), None, // Threads freshell starts are paginated (we SET the mode at start). Some(HistoryMode::Paginated), @@ -2708,6 +2779,7 @@ impl FreshCodexState { effort: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, mut lease_guard: Option, // kata b8ke Task 3: the coordinator claim ticket from // `handle_create`'s resume lane — every failure return below drops @@ -2869,6 +2941,23 @@ impl FreshCodexState { return; } let thread_id = resume_session_id; + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; self.clear_dead_thread(&thread_id).await; // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Finding @@ -2939,6 +3028,8 @@ impl FreshCodexState { cwd, sandbox, permission_mode, + collaboration_mode, + started.effective_settings.clone(), lease_guard, history_mode, own_ticket, @@ -2973,6 +3064,8 @@ impl FreshCodexState { cwd: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, + native_thread_settings: Value, mut lease_guard: Option, // Kata 1wxv Task 2: the thread's durable history mode — `Some(Paginated)` // for threads freshell started, the rollout-meta parse for resumes. @@ -3138,6 +3231,9 @@ impl FreshCodexState { self.sessions.lock().await.insert( thread_id.clone(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(native_thread_settings), turn_state: turn_state.clone(), client, model: model.clone(), @@ -3279,6 +3375,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, provenance.as_ref(), // Unified agent names (Task 4, T2-M5): the naming classification @@ -3424,7 +3521,7 @@ impl FreshCodexState { session_type: SESSION_TYPE.to_string(), session_ref: Some(SessionLocator { provider: PROVIDER.to_string(), - session_id: thread_id, + session_id: thread_id.clone(), }), name_ref, session_name, @@ -3435,6 +3532,18 @@ impl FreshCodexState { // notification already buffered on `notifs` is only delivered to the consumer // (and thus only broadcast) from this point on. let _ = created_tx.send(()); + self.publish_codex_settings( + &thread_id, + &CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }, + None, + ); } /// REVIEW FIX (item 3): legacy's `freshAgent.create.failed` sends `retryable: true` on @@ -3512,10 +3621,12 @@ impl FreshCodexState { s.cwd.clone(), s.sandbox.clone(), s.permission_mode.clone(), + s.collaboration_mode, ) }) }; - if let Some((model, effort, cwd, sandbox, permission_mode)) = parked { + if let Some((model, effort, cwd, sandbox, permission_mode, collaboration_mode)) = parked + { let _ = self .record_codex_binding( thread_id, @@ -3525,6 +3636,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, Some(&p), // Unified agent names: a refresh carries no naming fact. @@ -3726,67 +3838,59 @@ impl FreshCodexState { // Read defaults after acquiring the turn lock: a queued send inherits the // last accepted settings, while this message's explicit choices win. - let stored = { - let sessions = self.sessions.lock().await; - sessions.get(&session_id).map(|s| { - ( - s.model.clone(), - s.effort.clone(), - s.cwd.clone(), - s.sandbox.clone(), - s.permission_mode.clone(), - ) - }) - }; - let Some((stored_model, stored_effort, stored_cwd, stored_sandbox, stored_permission)) = - stored - else { + let stored = self + .sessions + .lock() + .await + .get(&session_id) + .map(CodexSession::effective_settings); + let Some(stored) = stored else { self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); return; }; - let settings = msg.settings.as_ref(); - let model = settings - .and_then(|s| s.model.clone()) - .unwrap_or(stored_model); - let effort = settings.and_then(|s| s.effort.clone()).or(stored_effort); - let turn_cwd = settings.and_then(|s| s.cwd.clone()).or(stored_cwd).or(cwd); - let mut sandbox = settings - .and_then(|s| s.sandbox) - .map(sandbox_wire_value) - .or(stored_sandbox); - let mut permission_mode = settings - .and_then(|s| s.permission_mode.clone()) - .or(stored_permission); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.send_error(&request_id, "INVALID_PERMISSION_MODE", error); - return; + let mut patch = msg.settings.clone().unwrap_or_default(); + if patch.cwd.is_none() && stored.cwd.is_none() { + patch.cwd = cwd; } - - // Re-normalize model/effort on send (adapter.ts:961-963) — idempotent for stored values. - let model = normalize_freshcodex_model(Some(&model)); - let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); - let effort = if effort.as_deref() == Some("max") { + let effective = match CodexEffectiveSettings::resolve(&stored, &patch) { + Ok(settings) => settings, + Err(error) => { + self.send_error(&request_id, error.code, &error.message); + return; + } + }; + let model = effective.model.clone(); + let effort = effective.effort.clone(); + let turn_cwd = effective.cwd.clone(); + let sandbox = effective.sandbox.clone(); + let permission_mode = effective.permission_mode.clone(); + let wire_choice = if effort.as_deref() == Some("max") { match freshell_codex::catalog::read_models(&client).await { Ok(catalog) => Some(freshell_codex::catalog::resolve_legacy_effort( &model, "max", &catalog, )), Err(error) => { tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?request_id, cause=%error, "freshagent.codex.effort_catalog_unavailable"); - effort + effort.clone() } } } else { - effort + effort.clone() }; - let wire_effort = match to_codex_reasoning_effort(effort.as_deref()) { + let wire_effort = match to_codex_reasoning_effort(wire_choice.as_deref()) { Ok(value) => value, - Err(err) => { - self.send_error(&request_id, "INVALID_EFFORT", &err.to_string()); + Err(error) => { + self.send_error(&request_id, "INVALID_EFFORT", &error.to_string()); return; } }; + let native_settings = CodexEffectiveSettings { + effort: wire_effort.clone(), + ..effective.clone() + }; let params = StartTurnParams { + collaboration_mode: native_settings.native_collaboration_mode(), thread_id: session_id.clone(), input: codex_user_input(&msg.text, msg.images.as_deref()), cwd: turn_cwd.clone(), @@ -3943,12 +4047,11 @@ impl FreshCodexState { // keep the previous working settings for resume and retry. let mut settings_changed = false; if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { - settings_changed = session.model != model || session.effort != effort; - session.model = model.clone(); - session.effort = effort.clone(); - session.cwd = turn_cwd.clone(); - session.sandbox = sandbox.clone(); - session.permission_mode = permission_mode.clone(); + settings_changed = session.effective_settings() != effective; + session.apply_settings(&effective); + if expected_turn_id.is_none() { + session.native_turn_settings = Some(native_settings); + } } // b8ke ext r29 F3: the post-send binding refresh carries the // operation's CLAIM pair (captured above, right after ensure-alive) @@ -3974,6 +4077,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), turn_cwd.as_deref(), + effective.collaboration_mode, None, // Send/settings mutation is not a new browser assertion — conn-less // (merge keeps prior stamps, ep4 writer rules). @@ -4005,14 +4109,7 @@ impl FreshCodexState { // a device that missed the configure still lands here). Keyed at the // client-addressed id, the same stamp the accepted frame below uses. if settings_changed { - self.broadcast(&crate::session_metadata::session_metadata_frame( - PROVIDER, - SESSION_TYPE, - &session_id, - Some(model.as_str()), - effort.as_deref(), - None, - )); + self.publish_codex_settings(&session_id, &effective, request_id.as_deref()); } // Unified agent names (Task 4): the shared accepted-input callback — @@ -4274,100 +4371,152 @@ impl FreshCodexState { /// unknown session surfaces on the pane's error banner. pub async fn handle_configure(&self, msg: FreshAgentConfigure) { let session_id = msg.session_id.clone(); - let stored = { - let sessions = self.sessions.lock().await; - sessions.get(&session_id).map(|s| { - ( - s.model.clone(), - s.effort.clone(), - s.sandbox.clone(), - s.permission_mode.clone(), - s.cwd.clone(), - ) - }) - }; - let Some((stored_model, stored_effort, stored_sandbox, stored_permission, stored_cwd)) = - stored - else { - self.emit_fresh_agent_error( + let target = self + .sessions + .lock() + .await + .get(&session_id) + .map(|session| (session.turn_lock.clone(), session.client.clone())); + let Some((turn_lock, client)) = target else { + self.configure_error( &session_id, + msg.request_id.as_deref(), "INVALID_SESSION_ID", "codex session not found", ); return; }; - - let settings = &msg.settings; - let mut model = settings - .model - .clone() - .unwrap_or_else(|| stored_model.clone()); - let effort = settings.effort.clone().or_else(|| stored_effort.clone()); - let mut sandbox = settings - .sandbox - .map(sandbox_wire_value) - .or_else(|| stored_sandbox.clone()); - let mut permission_mode = settings - .permission_mode - .clone() - .or_else(|| stored_permission.clone()); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.emit_fresh_agent_error(&session_id, "INVALID_PERMISSION_MODE", error); - return; - } - // Re-normalize on configure (the same idempotent pass send applies). - model = normalize_freshcodex_model(Some(&model)); - let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); - if let Err(err) = to_codex_reasoning_effort(effort.as_deref()) { - self.emit_fresh_agent_error(&session_id, "INVALID_EFFORT", &err.to_string()); + // Keep the operation's retained owner fence across both the turn wait + // and durable write. Never sample a replacement owner's generation. + let (epoch, generation) = self + .ownership_stamps + .lock() + .expect("ownership stamps lock") + .get(&session_id) + .map(|stamp| (Some(stamp.epoch), Some(stamp.generation))) + .unwrap_or((None, None)); + let _turn = turn_lock.lock().await; + let stored = self + .sessions + .lock() + .await + .get(&session_id) + .filter(|session| { + Arc::ptr_eq(&client, &session.client) && !session.exited.load(Ordering::SeqCst) + }) + .map(CodexSession::effective_settings); + let Some(stored) = stored else { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "SESSION_RESERVED", + "The Codex runtime changed while settings waited; refresh before changing them.", + ); return; - } - - let settings_changed = stored_model != model - || stored_effort != effort - || stored_sandbox != sandbox - || stored_permission != permission_mode; - if !settings_changed { - // Idempotent configure: nothing to converge. + }; + let mut patch = msg.settings; + patch.cwd = patch.cwd.or(msg.cwd); + let effective = match CodexEffectiveSettings::resolve(&stored, &patch) { + Ok(settings) => settings, + Err(error) => { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + error.code, + &error.message, + ); + return; + } + }; + if effective == stored { return; } - if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { - session.model = model.clone(); - session.effort = effort.clone(); - session.sandbox = sandbox.clone(); - session.permission_mode = permission_mode.clone(); - } - let _ = self + if let Err(error) = self .record_codex_binding( &session_id, None, - &model, - sandbox.as_deref(), - permission_mode.as_deref(), - effort.as_deref(), - stored_cwd.as_deref(), - None, - // A settings record is not a new browser assertion — conn-less - // (the ledger merge keeps the row's prior stamps). - None, - // Unified agent names: a settings refresh carries no naming fact. + &effective.model, + effective.sandbox.as_deref(), + effective.permission_mode.as_deref(), + effective.effort.as_deref(), + effective.cwd.as_deref(), + effective.collaboration_mode, None, None, None, + epoch, + generation, false, ) - .await; + .await + { + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?msg.request_id, error=%error, "freshagent.codex.configure_persist_failed"); + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "CODEX_SETTINGS_PERSIST_FAILED", + "Could not save Codex settings. The previous settings remain active; try again.", + ); + return; + } + let applied = { + let mut sessions = self.sessions.lock().await; + match sessions + .get_mut(&session_id) + .filter(|session| Arc::ptr_eq(&client, &session.client)) + { + Some(session) => { + session.apply_settings(&effective); + true + } + None => false, + } + }; + if !applied { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "SESSION_RESERVED", + "The Codex runtime changed while saving settings; refresh before changing them.", + ); + return; + } + self.publish_codex_settings(&session_id, &effective, msg.request_id.as_deref()); + } - // Broadcast keyed at the CLIENT-ADDRESSED id: the client's fold - // resolves the sessions-map entry by it. - self.broadcast(&crate::session_metadata::session_metadata_frame( - PROVIDER, - SESSION_TYPE, - &session_id, - Some(model.as_str()), - effort.as_deref(), - None, - )); + fn configure_error( + &self, + session_id: &str, + request_id: Option<&str>, + code: &str, + message: &str, + ) { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({"type":"freshAgent.error","sessionId":session_id,"requestId":request_id,"code":code,"message":message}), + provider: PROVIDER.into(), session_type: SESSION_TYPE.into(), session_id: session_id.into(), + })); + } + + fn publish_codex_settings( + &self, + session_id: &str, + settings: &CodexEffectiveSettings, + request_id: Option<&str>, + ) { + let mut event = settings.as_json(); + event["type"] = json!("freshAgent.session.metadata"); + event["sessionId"] = json!(session_id); + event["effort"] = json!(settings.effort); + event["settingsScope"] = json!("next-turn"); + if let Some(request_id) = request_id { + event["requestId"] = json!(request_id); + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event, + provider: PROVIDER.into(), + session_type: SESSION_TYPE.into(), + session_id: session_id.into(), + })); } // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── @@ -5240,6 +5389,7 @@ impl FreshCodexState { s.cwd.clone(), s.sandbox.clone(), s.permission_mode.clone(), + s.collaboration_mode, // Fork precedence source (2) (focused-ep1-r3/r5): the // parent's PARKED provenance (the child pane continues // the parent's lineage in the same client context). @@ -5254,6 +5404,7 @@ impl FreshCodexState { parent_cwd, parent_sandbox, parent_permission_mode, + parent_collaboration_mode, parent_provenance, )) = looked_up else { @@ -5544,6 +5695,24 @@ impl FreshCodexState { return; } + let mut effective = CodexEffectiveSettings { + model: eff_model, + effort: parent_effort, + cwd: eff_cwd, + sandbox: parent_sandbox, + permission_mode: parent_permission_mode, + collaboration_mode: parent_collaboration_mode, + }; + effective.adopt_native(&resumed.effective_settings); + let CodexEffectiveSettings { + model: eff_model, + effort: parent_effort, + cwd: eff_cwd, + sandbox: parent_sandbox, + permission_mode: parent_permission_mode, + collaboration_mode: parent_collaboration_mode, + } = effective; + // Register the child on its OWN sidecar (the shared registration tail, the // ensure_session_resumable shape), inheriting the parent's settings. The // child rollout's durable session_meta is the history-mode SoT (kata 1wxv @@ -5561,6 +5730,8 @@ impl FreshCodexState { eff_cwd.clone(), parent_sandbox.clone(), parent_permission_mode.clone(), + parent_collaboration_mode, + resumed.effective_settings.clone(), read_rollout_history_mode(&child_id), fork_provenance.clone(), ) @@ -5582,6 +5753,7 @@ impl FreshCodexState { parent_permission_mode.as_deref(), parent_effort.as_deref(), eff_cwd.as_deref(), + parent_collaboration_mode, None, fork_provenance.as_ref(), // Unified agent names (Task 4): the fork's naming classification @@ -6756,10 +6928,9 @@ impl FreshCodexState { /// the OLD mint-new path silently discarded the model's memory of the conversation). /// /// If the app-server rejects the resume WITH those settings for any reason other than a - /// genuine "thread not found" (a stale/invalid model or sandbox value, say), one retry is - /// made with NO settings at all -- `handle_send`'s own "re-normalize on send" already - /// re-applies the real settings on the next turn, so a resume succeeding with defaults is - /// strictly better than failing over a value we can't currently validate. + /// genuine "thread not found", one retry may omit a stale model. File access + /// and approval remain exact on that retry. Untrusted policy refusal returns + /// directly, preserving its prepared-source diagnostic before any work. /// /// Only a genuine `threadNotFound` on resume ([`is_codex_thread_not_found`]) falls back to /// the ORIGINAL mint-new-thread behavior ([`Self::respawn_as_new_thread_after_crash`]): @@ -6813,6 +6984,7 @@ impl FreshCodexState { session_effort, session_sandbox, session_permission_mode, + session_collaboration_mode, session_provenance, ) = { let guard = self.sessions.lock().await; @@ -6826,6 +6998,7 @@ impl FreshCodexState { session.effort.clone(), session.sandbox.clone(), session.permission_mode.clone(), + session.collaboration_mode, // D8 (focused-ep1-r3): the parked provenance rides ACROSS the // recovery rebuild below (and into the mint-new fallback) — the // same logical session continues; nothing new is invented (the @@ -6838,25 +7011,30 @@ impl FreshCodexState { // P1.13 (Task 5, R2): a blank stored model means this registration came from a // settings-less resume (a record miss at R3 time) -- fall back to the ledger // record so crash recovery doesn't silently respawn onto defaults. - let (model, sandbox, permission_mode, effort) = if session_model.is_empty() { - let rec = self - .identity_sink() - .and_then(|s| s.load_settings("codex", session_id)) - .unwrap_or_default(); - ( - rec.model.unwrap_or_default(), - rec.sandbox, - rec.permission_mode, - rec.effort, - ) - } else { - ( - session_model, - session_sandbox, - session_permission_mode, - session_effort, - ) - }; + let (model, sandbox, permission_mode, effort, collaboration_mode) = + if session_model.is_empty() { + let rec = self + .identity_sink() + .and_then(|s| s.load_settings("codex", session_id)) + .unwrap_or_default(); + ( + rec.model.unwrap_or_default(), + rec.sandbox, + rec.permission_mode, + rec.effort, + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + ) + } else { + ( + session_model, + session_sandbox, + session_permission_mode, + session_effort, + session_collaboration_mode, + ) + }; // Single-flight: acquire (and possibly create) this session id's per-thread lock, // shared with `ensure_session_resumable`'s map so a concurrent historical-thread @@ -7025,6 +7203,7 @@ impl FreshCodexState { effort, sandbox, permission_mode, + collaboration_mode, lease_guard, session_provenance, ) @@ -7098,18 +7277,22 @@ impl FreshCodexState { let resume_result = match first_attempt { Ok(started) => Ok(started), Err(err) if is_codex_thread_not_found(&err) => Err(err), - // Not a "thread not found" -- retry once with no settings at all in case the - // app-server rejected one of them; `handle_send` re-applies the real settings - // on the next turn regardless. - Err(_) => { + // A retry without settings would drop prepared/explicit untrusted + // intent. Preserve the original typed refusal before any work. + Err(error) if permission_mode.as_deref() == Some("untrusted") => Err(error), + // A stale model may be unavailable after a provider update. Keep + // file access and approval intent on this retry: native defaults + // must never turn a settings refusal into broader access. + Err(error) => { + tracing::warn!(provider=PROVIDER, session_id=%session_id, cause=%error, "freshagent.codex.resume_retry_without_model"); client .resume_thread( session_id, StartThreadParams { cwd: cwd.clone(), model: None, - sandbox: None, - approval_policy: None, + sandbox: sandbox.clone(), + approval_policy: permission_mode.clone(), history_mode: None, // resume carries no mode param }, ) @@ -7144,6 +7327,7 @@ impl FreshCodexState { effort, sandbox, permission_mode, + collaboration_mode, lease_guard, session_provenance, ) @@ -7187,6 +7371,24 @@ impl FreshCodexState { ))); } + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + let active_turn: Arc>> = Arc::new(StdMutex::new(None)); let user_interrupt_pending: Arc = Arc::new(AtomicBool::new(false)); let quiet_deadman = QuietDeadman::new_shared(); @@ -7273,6 +7475,9 @@ impl FreshCodexState { guard.insert( session_id.to_string(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(started.effective_settings.clone()), turn_state: turn_state.clone(), client, model: model.clone(), @@ -7326,6 +7531,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, None, // Unified agent names: a refresh carries no naming fact. @@ -7400,6 +7606,7 @@ impl FreshCodexState { effort: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, mut lease_guard: Option, // D8 (focused-ep1-r3): the crashed session's parked provenance, CARRIED // onto the mint-new record below (the identity moved; the same logical @@ -7435,8 +7642,8 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), }) .await; - let new_thread_id = match started { - Ok(started) => started.thread_id, + let started = match started { + Ok(started) => started, Err(err) => { client.close().await; let mut child = child; @@ -7451,6 +7658,25 @@ impl FreshCodexState { } }; + let new_thread_id = started.thread_id.clone(); + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + // kata b8ke Task 3: the mint-new fallback MINTS the canonical thread // id — claim it under the NEW key (no fence: a minted id was never // observed) before the registration below (the caller already @@ -7589,6 +7815,9 @@ impl FreshCodexState { guard.insert( new_thread_id.clone(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(started.effective_settings.clone()), turn_state: turn_state.clone(), client, model: model.clone(), @@ -7669,6 +7898,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, Some(old_session_id), // D8: conn-less crash-respawn — provenance `None`; the ledger // inherits the superseded parent's stamps (fork-chain rule). @@ -8629,20 +8859,19 @@ impl FreshCodexState { snapshot["capabilities"]["interrupt"] = json!(true); } self.overlay_controls(thread_id, &mut snapshot).await; + self.overlay_settings_snapshot(thread_id, &mut snapshot) + .await; + Ok(snapshot) + } + + async fn overlay_settings_snapshot(&self, thread_id: &str, snapshot: &mut Value) { if let Some(session) = self.sessions.lock().await.get(thread_id) { - let mut settings = Map::new(); - if !session.model.is_empty() { - settings.insert("model".into(), json!(session.model)); - } - if let Some(effort) = &session.effort { - settings.insert("effort".into(), json!(effort)); - } - if let Some(permission) = &session.permission_mode { - settings.insert("permissionMode".into(), json!(permission)); - } - snapshot["settings"] = Value::Object(settings); + snapshot["settings"] = session.effective_settings().as_json(); + snapshot["extensions"]["codex"]["settings"] = json!({ + "scope":"next-turn", "currentTurn":session.native_turn_settings.as_ref().map(CodexEffectiveSettings::as_json), + "lastThreadResponse":session.native_thread_settings.as_ref(), + }); } - Ok(snapshot) } /// Resolve the live client + active-turn bit for `thread_id` — FULLY @@ -9150,7 +9379,7 @@ impl FreshCodexState { running with default model and permissions. Reconfirm your settings.", ); } - let rec = recovered.clone().unwrap_or_default(); + let mut rec = recovered.clone().unwrap_or_default(); // D8 (focused-ep1-r4 Finding 2 — the parking invariant's durable // half): this construction lane is CONN-LESS (attach / snapshot reads @@ -9226,6 +9455,26 @@ impl FreshCodexState { ))); } + if recovered.is_some() { + let mut effective = CodexEffectiveSettings { + model: rec.model.clone().unwrap_or_default(), + effort: rec.effort.clone(), + cwd: cwd.map(str::to_owned).or_else(|| rec.cwd.clone()), + sandbox: rec.sandbox.clone(), + permission_mode: rec.permission_mode.clone(), + collaboration_mode: rec + .collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + }; + effective.adopt_native(&started.effective_settings); + rec.model = (!effective.model.is_empty()).then_some(effective.model); + rec.effort = effective.effort; + rec.cwd = effective.cwd; + rec.sandbox = effective.sandbox; + rec.permission_mode = effective.permission_mode; + } + // FIX (CODEX-FIRST triage Finding 2): the app-server just proved this id alive -- // clear any stale "recently gone" marking so it doesn't linger. self.clear_dead_thread(thread_id).await; @@ -9273,9 +9522,13 @@ impl FreshCodexState { child, rec.model.clone().unwrap_or_default(), rec.effort.clone(), - cwd.map(str::to_string).or_else(|| rec.cwd.clone()), + rec.cwd.clone().or_else(|| cwd.map(str::to_string)), rec.sandbox.clone(), rec.permission_mode.clone(), + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + started.effective_settings.clone(), // Kata 1wxv Task 2 (r3): the durable rollout meta carries the // mode across resume (only a missing/unparseable meta ⇒ legacy). read_rollout_history_mode(thread_id), @@ -9382,7 +9635,10 @@ impl FreshCodexState { rec.sandbox.as_deref(), rec.permission_mode.as_deref(), rec.effort.as_deref(), - cwd.or(rec.cwd.as_deref()), + rec.cwd.as_deref().or(cwd), + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), None, // D8: conn-less attach-resume refresh — provenance `None` // keeps the row's existing stamps. @@ -9502,6 +9758,8 @@ impl FreshCodexState { cwd: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, + native_thread_settings: Value, // Kata 1wxv Task 2: the thread's durable history mode (the rollout-meta // parse the resume/fork callers perform post-RPC). history_mode: Option, @@ -9569,6 +9827,9 @@ impl FreshCodexState { self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(native_thread_settings), turn_state: turn_state.clone(), client, model, @@ -9667,6 +9928,9 @@ impl FreshCodexState { self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), @@ -10351,6 +10615,8 @@ fn build_codex_snapshot_json( "effort": "per-send", "sandbox": "per-send", "permissionMode": "per-send", + "cwd": "per-send", + "collaborationMode": "per-send", }, }, "tokenUsage": { @@ -12969,6 +13235,9 @@ pub(crate) mod tests { state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), @@ -13092,6 +13361,9 @@ pub(crate) mod tests { state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), @@ -13477,6 +13749,7 @@ pub(crate) mod tests { let sink = crate::naming::test_support::RecordingSink::new(); st.set_session_naming(sink.clone()); let create = FreshAgentCreate { + collaboration_mode: None, pane_id: None, observed_epoch: None, observed_generation: None, @@ -14263,24 +14536,618 @@ pub(crate) mod tests { ); } - /// b8ke ext r29 F3 + focused ep5 r1 F2: the post-send binding refresh - /// carries the operation's OWN claim pair — the lane's retained - /// OwnershipStamp for the session (written only by this lane's claim - /// commits), never a fresh registry observation — so the ledger's - /// delayed-write fence can refuse a refresh that lands after a - /// handoff reaped the runtime and bound a terminal under a NEWER - /// generation. Pre-r29 the refresh passed NO pair (the pre-r22 - /// unfenced shape), so the late write was accepted and rewrote the - /// terminal's authoritative recovery row; the ep5-r1 review found - /// the ext-r29 fresh-observe capture handed a mid-flight handoff the - /// POST-handoff generation (the same-generation trap). - #[tokio::test] - async fn the_post_send_binding_refresh_carries_the_observed_ownership_pair() { + async fn task008_settings_fixture() -> ( + FreshCodexState, + freshell_codex::ChannelPeer, + tokio::sync::broadcast::Receiver, + Arc, + ) { let (transport, peer) = freshell_codex::new_channel_transport(); - let (client, notifs) = CodexAppServerClient::connect(transport); - let (mut st, _) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(Arc::clone(®istry)); + let (client, notifications) = CodexAppServerClient::connect(transport); + let (st, _) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let wire = insert_fake_session_with_real_consumer( + &st, + "thread-settings", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifications, + spawn_sleeper(), + &format!("task008-settings-{}", uuid::Uuid::new_v4()), + ) + .await; + (st, peer, wire, sink) + } + + async fn task008_settings_send( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + settings: Value, + ) -> Value { + task008_settings_send_named(st, peer, settings, "turn-settings").await + } + + async fn task008_settings_send_named( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + settings: Value, + turn_id: &str, + ) -> Value { + let task = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(serde_json::from_value(json!({ + "provider":"codex", "sessionType":"freshcodex", "sessionId":"thread-settings", + "requestId":"settings-send", "text":"settings probe", "settings":settings, + })).unwrap()).await; + } + }); + let (mut id, mut method, mut params) = peer.expect_request().await; + if method == "initialize" { + peer.respond(&id, json!({})); + peer.expect_notification().await; + (id, method, params) = peer.expect_request().await; + } + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":turn_id}})); + task.await.unwrap(); + params + } + + async fn task008_settings_snapshot( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + ) -> Value { + let task = tokio::spawn({ + let st = st.clone(); + async move { st.get_snapshot("thread-settings", None).await.unwrap() } + }); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-settings","status":{"type":"idle"},"turns":[]}}), + ); + task.await.unwrap() + } + + #[tokio::test] + async fn task008_settings_configure_cwd_reaches_the_next_native_start() { + let (st, peer, _, sink) = task008_settings_fixture().await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra"}})).unwrap()).await; + let params = task008_settings_send(&st, &peer, Value::Null).await; + let saved = sink.load_settings("codex", "thread-settings"); + peer.disconnect(); + st.shutdown().await; + assert_eq!(params["cwd"], "/next"); + assert_eq!(params["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(saved.unwrap().cwd.as_deref(), Some("/next")); + } + + #[tokio::test] + async fn task008_settings_snapshot_exposes_complete_next_turn_defaults() { + let (st, peer, _, _) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra"})).await; + let snapshot = task008_settings_snapshot(&st, &peer).await; + peer.disconnect(); + st.shutdown().await; + assert_eq!( + snapshot["settings"], + json!({"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra","collaborationMode":"default"}) + ); + assert_eq!(snapshot["capabilities"]["settingScopes"]["cwd"], "per-send"); + } + + #[tokio::test] + async fn task008_settings_plan_and_explicit_build_use_native_collaboration_mode() { + let mut observed = Vec::new(); + for mode in ["plan", "default"] { + let (st, peer, _, _) = task008_settings_fixture().await; + let params = task008_settings_send(&st, &peer, json!({"model":"provider-current","effort":"ultra","cwd":"/next","sandbox":"read-only","permissionMode":"never","collaborationMode":mode})).await; + peer.disconnect(); + st.shutdown().await; + assert_eq!(params["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(params["approvalPolicy"], "never"); + observed.push(params["collaborationMode"].clone()); + } + assert_eq!(observed, ["plan", "default"].map(|mode| json!({"mode":mode,"settings":{"model":"provider-current","reasoning_effort":"ultra","developer_instructions":null}}))); + } + + #[tokio::test] + async fn task008_settings_newer_configure_survives_a_held_older_send_response() { + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + let send = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","text":"old settings","settings":{"model":"old-model","effort":"low","cwd":"/old","sandbox":"read-only","permissionMode":"on-request"}})).unwrap()).await; + } + }); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&id, json!({})); + peer.expect_notification().await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + let configure = st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","requestId":"new-settings","settings":{"model":"new-model","effort":"ultra","cwd":"/new","sandbox":"workspace-write","permissionMode":"never","collaborationMode":"plan"}})).unwrap()); + tokio::pin!(configure); + // Give the public configure an actual admission opportunity. It may + // finish now or serialize behind the held send; either ordering is valid. + let admitted = futures_util::poll!(&mut configure).is_ready(); + peer.respond(&id, json!({"turn":{"id":"turn-old"}})); + send.await.unwrap(); + if !admitted { + configure.await; + } + drain_wire(&mut wire); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-settings","turn":{"id":"turn-old","status":"completed"}}), + ); + await_next_idle_snapshot(&mut wire, "thread-settings").await; + let params = task008_settings_send(&st, &peer, Value::Null).await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + assert_eq!(params["model"], "new-model"); + assert_eq!(params["effort"], "ultra"); + assert_eq!(params["cwd"], "/new"); + assert_eq!(params["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(saved.model.as_deref(), Some("new-model")); + assert_eq!(saved.cwd.as_deref(), Some("/new")); + } + + #[tokio::test] + async fn task008_settings_durable_configure_failure_refuses_without_false_success() { + let (events, _capture) = tracing_capture::capture(); + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"model":"saved-model","effort":"low"})).await; + drain_wire(&mut wire); + sink.set_fail_writes(true); + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","requestId":"refused-settings","settings":{"model":"unsaved-model","effort":"ultra"}})).unwrap()).await; + let frames = drain(&mut wire).await; + let snapshot = task008_settings_snapshot(&st, &peer).await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + assert!( + frames + .iter() + .any(|frame| frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "CODEX_SETTINGS_PERSIST_FAILED" + && frame["event"]["requestId"] == "refused-settings"), + "typed correlated refusal missing: {frames:?}" + ); + assert!(!frames + .iter() + .any(|frame| frame["event"]["type"] == "freshAgent.session.metadata")); + assert_eq!(snapshot["settings"]["model"], "saved-model"); + assert_eq!(saved.model.as_deref(), Some("saved-model")); + assert!(events + .lock() + .unwrap() + .iter() + .any(|event| event.message.contains("configure_persist_failed"))); + } + + #[tokio::test] + async fn task008_settings_configure_carries_its_retained_owner_fence() { + let (mut st, peer, _, sink) = task008_settings_fixture().await; + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + let mut ticket = + match st.begin_lane_claim_at("thread-settings", "settings-owner", None, "test") { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("settings ownership claim must grant"), + }; + let expected = ( + Some(registry.boot_epoch()), + Some(ticket.as_ref().unwrap().generation()), + ); + assert!(st + .commit_lane_claim_at(&mut ticket, "thread-settings", "thread-settings", None) + .is_ok()); + st.handle_configure(configure_msg( + "thread-settings", + Some("provider-current"), + Some("ultra"), + )) + .await; + let observed = sink + .binding_pairs + .lock() + .unwrap() + .get(&("codex".into(), "thread-settings".into())) + .cloned(); + peer.disconnect(); + st.shutdown().await; + assert_eq!(observed, Some(expected)); + } + + #[tokio::test] + async fn task008_settings_refinement_retained_snapshot_keeps_complete_staged_defaults() { + let (st, peer, _, _) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"model":"provider-current","effort":"ultra","cwd":"/first","sandbox":"read-only","permissionMode":"never"})).await; + task008_settings_snapshot(&st, &peer).await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"cwd":"/next","collaborationMode":"plan"}})).unwrap()).await; + let retained = st.retained_state("thread-settings").await.unwrap(); + peer.disconnect(); + st.shutdown().await; + assert_eq!( + retained["snapshot"]["settings"], + json!({"model":"provider-current","effort":"ultra","cwd":"/next","sandbox":"read-only","permissionMode":"never","collaborationMode":"plan"}) + ); + assert_eq!( + retained["snapshot"]["extensions"]["codex"]["settings"]["currentTurn"]["cwd"], + "/first" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_recovery_refusal_keeps_file_access_and_approval_on_retry() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("restricted-recovery.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log}).to_string()); + let (st, mut wire) = state_with_bus(); + let mut create = create_msg("restricted-settings-recovery"); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.sandbox = Some(freshell_protocol::Sandbox::ReadOnly); + create.permission_mode = Some("on-request".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + assert!(created + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.created")); + simulate_unrequested_crash(&st, "thread-new-1").await; + configure_fake_codex_cmd( + &json!({"appendRequestLogPath":log,"rejectResumeOverrides":true}).to_string(), + ); + drain_wire(&mut wire); + st.handle_send(send_msg( + "thread-new-1", + "restricted recovery must not run with native full access", + )) + .await; + let frames = drain(&mut wire).await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let observed = json!({ + "resumes":requests.iter().filter(|row| row["method"] == "thread/resume").map(|row| json!({"sandbox":row["params"]["sandbox"],"approval":row["params"]["approvalPolicy"]})).collect::>(), + "work":requests.iter().filter(|row| row["method"] == "turn/start").map(|row| json!({"sandbox":row["params"]["sandboxPolicy"]["type"],"approval":row["params"]["approvalPolicy"]})).collect::>(), + "refused":frames.iter().any(|frame| frame["type"] == "error" && frame["message"].as_str().is_some_and(|message| message.starts_with("CODEX_RESPAWN_FAILED:") && message.contains("resume sandbox or approval"))) + }); + assert_eq!( + observed, + json!({"resumes":[{"sandbox":"read-only","approval":"on-request"},{"sandbox":"read-only","approval":"on-request"}],"work":[],"refused":true}), + "native refusal cannot become a no-policy full-access recovery" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_refinement_untrusted_recovery_refuses_without_dropping_policy() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("untrusted-recovery.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log}).to_string()); + let (st, mut wire) = state_with_bus(); + let mut create = create_msg("untrusted-settings-recovery"); + create.model = Some("provider-current".into()); + create.effort = Some(Some("ultra".into())); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.permission_mode = Some("untrusted".into()); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + assert!( + created + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.created"), + "native untrusted creation is ready" + ); + simulate_unrequested_crash(&st, "thread-new-1").await; + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"configRequirements":{"allowedApprovalPolicies":["never"]}}).to_string()); + drain_wire(&mut wire); + st.handle_send(send_msg("thread-new-1", "must be refused before work")) + .await; + let frames = drain(&mut wire).await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!( + !requests + .iter() + .any(|row| row["method"] == "thread/resume" || row["method"] == "turn/start"), + "requirements refusal must not retry without untrusted approval: {requests:?}" + ); + assert!(frames.iter().any(|frame| frame["type"] == "error" + && frame["message"] + .as_str() + .is_some_and(|message| message.contains("approval_policy untrusted")))); + assert!(!frames + .iter() + .any(|frame| frame["type"] == "freshAgent.send.accepted")); + } + + #[tokio::test] + async fn task008_settings_active_steer_keeps_the_native_turn_and_stages_the_next_start() { + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + let first = task008_settings_send(&st, &peer, json!({"model":"first-model","effort":"low","cwd":"/first","sandbox":"read-only","permissionMode":"on-request"})).await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"model":"next-model","effort":"ultra","cwd":"/next","sandbox":"workspace-write","permissionMode":"never","collaborationMode":"plan"}})).unwrap()).await; + let steer = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(send_msg("thread-settings", "add context")) + .await; + } + }); + let (id, method, params) = peer.expect_request().await; + peer.respond(&id, json!({"turnId":"turn-settings"})); + steer.await.unwrap(); + let snapshot = task008_settings_snapshot(&st, &peer).await; + drain_wire(&mut wire); + peer.emit_notification("turn/completed", json!({"threadId":"thread-settings","turn":{"id":"turn-settings","status":"completed"}})); + await_next_idle_snapshot(&mut wire, "thread-settings").await; + let next = task008_settings_send_named(&st, &peer, Value::Null, "turn-next").await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + // Existing steering is an honest baseline: these assertions precede + // the newly missing staged/current-settings assertions. + assert_eq!(method, "turn/steer"); + assert_eq!( + params, + json!({"threadId":"thread-settings","expectedTurnId":"turn-settings","input":[{"type":"text","text":"add context","text_elements":[]}]}) + ); + assert_eq!(first["model"], "first-model"); + assert_eq!(first["effort"], "low"); + assert_eq!(next["cwd"], "/next"); + assert_eq!(next["model"], "next-model"); + assert_eq!(next["effort"], "ultra"); + assert_eq!(next["approvalPolicy"], "never"); + assert_eq!(next["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(next["collaborationMode"]["mode"], "plan"); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["scope"], + "next-turn" + ); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["currentTurn"]["model"], + "first-model" + ); + assert_eq!(snapshot["settings"]["model"], "next-model"); + assert_eq!(saved.model.as_deref(), Some("next-model")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_refinement_thread_effort_echo_preserves_next_turn_selection_and_native_default( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("effort-scopes.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"effectiveThreadSettings":{"reasoningEffort":"low","collaborationMode":{"mode":"default","settings":{"model":"provider-current","reasoning_effort":"low","developer_instructions":null}}}}).to_string()); + for (index, effort) in [None, Some("ultra"), Some("max"), Some("future-effort")] + .into_iter() + .enumerate() + { + let (st, _) = state_with_bus(); + let mut create = create_msg(&format!("effort-scope-{index}")); + create.model = Some("provider-current".into()); + create.effort = effort.map(str::to_owned).map(Some); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + st.handle_send(send_msg("thread-new-1", "selected next-turn effort")) + .await; + st.shutdown().await; + } + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + let actual: Vec<_> = turns.iter().map(|row| json!({"effort":row["params"]["effort"],"mode":row["params"]["collaborationMode"]["mode"],"modeEffort":row["params"]["collaborationMode"]["settings"]["reasoning_effort"]})).collect(); + let expected: Vec<_> = [ + Value::Null, + json!("ultra"), + json!("max"), + json!("future-effort"), + ] + .into_iter() + .map(|effort| json!({"effort":effort,"mode":"plan","modeEffort":effort})) + .collect(); + assert_eq!(actual, expected, "thread/start has no effort override input; its current/default echo cannot replace next-turn intent"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_clear_effort_configure_preserves_cwd_only_changes_and_survives_resume( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let next_cwd = temp.path().join("next"); + std::fs::create_dir(&next_cwd).unwrap(); + let log = temp.path().join("clear-effort.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"recordTurns":true,"effectiveThreadSettings":{"reasoningEffort":"low"}}).to_string()); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let (st, mut wire) = state_with_bus(); + st.set_identity_sink(sink.clone()); + let mut create = create_msg("clear-effort-create"); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.model = Some("provider-current".into()); + create.effort = Some(Some("ultra".into())); + create.sandbox = Some(freshell_protocol::Sandbox::ReadOnly); + create.permission_mode = Some("never".into()); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + for (index, patch) in [ + None, + Some(json!({"cwd":next_cwd})), + Some(json!({"effort":null})), + ] + .into_iter() + .enumerate() + { + if let Some(settings) = patch { + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-new-1","requestId":format!("clear-settings-{index}"),"settings":settings})).unwrap()).await; + } + drain_wire(&mut wire); + st.handle_send(send_msg("thread-new-1", &format!("effort stage {index}"))) + .await; + collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + } + let saved = sink.load_settings("codex", "thread-new-1").unwrap(); + st.shutdown().await; + let (resumed, _) = state_with_bus(); + resumed.set_identity_sink(sink); + resumed.handle_create(serde_json::from_value(json!({"requestId":"clear-effort-resume","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":"thread-new-1"}})).unwrap(), None).await; + resumed + .handle_send(send_msg("thread-new-1", "after clearing and resume")) + .await; + resumed.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + assert_eq!( + turns + .iter() + .map(|row| row["params"]["effort"].clone()) + .collect::>(), + vec![json!("ultra"), json!("ultra"), Value::Null, Value::Null] + ); + assert!(saved.effort.is_none()); + for row in &turns[1..] { + assert_eq!(row["params"]["cwd"], json!(next_cwd)); + assert_eq!(row["params"]["collaborationMode"]["mode"], "plan"); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(row["params"]["approvalPolicy"], "never"); + } + assert_eq!( + turns[2]["params"]["collaborationMode"]["settings"]["reasoning_effort"], + Value::Null + ); + assert_eq!( + turns[3]["params"]["collaborationMode"]["settings"]["reasoning_effort"], + Value::Null + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_native_effective_values_win_on_create_resume_and_fork() { + let _environment_lock = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("effective"); + std::fs::create_dir(&cwd).unwrap(); + let log = temp.path().join("settings-native-echo.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"recordTurns":true,"effectiveThreadSettings":{"model":"native-effective","reasoningEffort":"ultra","cwd":cwd,"sandbox":"read-only","approvalPolicy":"on-request"}}).to_string()); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let (st, mut rx) = state_with_bus(); + st.set_identity_sink(sink.clone()); + st.handle_create(serde_json::from_value(json!({"requestId":"settings-echo-create","provider":"codex","sessionType":"freshcodex","model":"stale-create","effort":"low","cwd":temp.path(),"sandbox":"danger-full-access","permissionMode":"never","collaborationMode":"plan"})).unwrap(), None).await; + st.handle_send(send_msg("thread-new-1", "after effective create")) + .await; + collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + let (reply, frames) = capturing_sink(); + st.handle_fork( + fork_msg( + "thread-new-1", + "settings-echo-fork", + Some(json!({"model":"stale-fork","cwd":temp.path()})), + ), + None, + reply, + ) + .await; + let child = captured_frames(&frames) + .iter() + .find(|frame| frame["type"] == "freshAgent.forked") + .expect("native fork completes")["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&child, "after effective fork")) + .await; + collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + st.shutdown().await; + let (resumed, _) = state_with_bus(); + resumed.set_identity_sink(sink.clone()); + resumed.handle_create(serde_json::from_value(json!({"requestId":"settings-echo-resume","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":"thread-new-1"},"model":"stale-resume","effort":"low","cwd":temp.path(),"sandbox":"danger-full-access","permissionMode":"never"})).unwrap(), None).await; + resumed + .handle_send(send_msg("thread-new-1", "after effective resume")) + .await; + let snapshot = resumed.get_snapshot("thread-new-1", None).await.unwrap(); + resumed.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests.iter().filter(|row| row["method"] == "turn/start").map(|row| json!({"model":row["params"]["model"],"effort":row["params"]["effort"],"cwd":row["params"]["cwd"],"sandbox":row["params"]["sandboxPolicy"],"approval":row["params"]["approvalPolicy"],"mode":row["params"]["collaborationMode"]})).collect(); + let expected = json!({"model":"native-effective","effort":"low","cwd":cwd,"sandbox":{"type":"readOnly"},"approval":"on-request","mode":{"mode":"plan","settings":{"model":"native-effective","reasoning_effort":"low","developer_instructions":null}}}); + assert_eq!(starts, vec![expected; 3]); + assert_eq!(snapshot["settings"]["effort"], "low"); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["lastThreadResponse"]["reasoningEffort"], + "ultra" + ); + let saved = sink.load_settings("codex", "thread-new-1").unwrap(); + assert_eq!(saved.model.as_deref(), Some("native-effective")); + } + + /// b8ke ext r29 F3 + focused ep5 r1 F2: the post-send binding refresh + /// carries the operation's OWN claim pair — the lane's retained + /// OwnershipStamp for the session (written only by this lane's claim + /// commits), never a fresh registry observation — so the ledger's + /// delayed-write fence can refuse a refresh that lands after a + /// handoff reaped the runtime and bound a terminal under a NEWER + /// generation. Pre-r29 the refresh passed NO pair (the pre-r22 + /// unfenced shape), so the late write was accepted and rewrote the + /// terminal's authoritative recovery row; the ep5-r1 review found + /// the ext-r29 fresh-observe capture handed a mid-flight handoff the + /// POST-handoff generation (the same-generation trap). + #[tokio::test] + async fn the_post_send_binding_refresh_carries_the_observed_ownership_pair() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (mut st, _) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(Arc::clone(®istry)); let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); @@ -15111,6 +15978,9 @@ pub(crate) mod tests { st.sessions.lock().await.insert( "thread-stall".to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, turn_state: turn_state.clone(), client, sidecar_pid: None, @@ -15467,6 +16337,7 @@ pub(crate) mod tests { let create = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -15643,6 +16514,7 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -15804,6 +16676,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -19053,6 +19926,7 @@ pub(crate) mod tests { None, None, None, + None, // no work-mode override in this lease fixture // kata b8ke Task 3: no coordinator claim in this direct-drive // test path (the lease-claim skip mirrors handle_create's // pre-claim fast paths). @@ -19532,6 +20406,12 @@ pub(crate) mod tests { // Conn-less attach reconstructs the session (all-blank settings), // seeding the OLD row's stamps (focused-ep1-r4 Finding 2). st.handle_attach(attach_msg("thread-lineage-adopt")).await; + let observed = st.get_snapshot("thread-lineage-adopt", None).await.unwrap(); + assert_eq!(observed["settings"]["model"], "fixture-model"); + assert_eq!( + observed["extensions"]["codex"]["settings"]["lastThreadResponse"]["model"], + "fixture-model" + ); // A NEW connection's recovery-create adopts the live incumbent. let mut resume = create_msg("req-adopt-lineage"); @@ -19573,6 +20453,28 @@ pub(crate) mod tests { crate::identity_sink::FreshAgentSettings::default(), "the settings payload is untouched by the provenance refresh (stays lineage-only)" ); + drop(bindings); + // Accepted work materializes the observed defaults as durable intent; + // it must use that exact native model, not a fabricated fallback. + st.handle_send(send_msg( + "thread-lineage-adopt", + "first accepted lineage work", + )) + .await; + let accepted = collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.send.accepted" + && frame["sessionId"] == "thread-lineage-adopt" + }) + .await; + assert!(accepted.matched, "{:?}", accepted.frames); + assert_eq!( + fake.load_settings("codex", "thread-lineage-adopt") + .unwrap() + .model + .as_deref(), + Some("fixture-model") + ); + st.shutdown().await; } /// Focused-ep1-r4 Finding 2 (codex): the CONN-LESS cold attach @@ -19617,6 +20519,7 @@ pub(crate) mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: None, permission_mode: None, @@ -19722,6 +20625,7 @@ pub(crate) mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: None, permission_mode: None, @@ -23685,6 +24589,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24237,6 +25142,7 @@ pub(crate) mod tests { "codex", "thread-9", crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: Some("workspace-write".into()), permission_mode: Some("on-request".into()), @@ -24381,6 +25287,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24435,6 +25342,7 @@ pub(crate) mod tests { /// `request_id`. fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24781,6 +25689,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24852,6 +25761,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24934,6 +25844,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -25004,6 +25915,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -25669,7 +26581,16 @@ pub(crate) mod tests { /// to depending on a host-installed `codex` binary. pub(crate) fn configure_fake_codex_cmd(behavior_json: &str) { std::env::set_var("CODEX_CMD", format!("node {}", fake_codex_app_server_cmd())); - std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior_json); + let behavior = match serde_json::from_str::(behavior_json) { + Ok(Value::Object(mut behavior)) => { + behavior.entry("echoRequestedModel").or_insert(json!(true)); + Value::Object(behavior).to_string() + } + // Existing fault cases deliberately pass malformed/non-object + // input. Let the native fake consume and refuse it as before. + _ => behavior_json.to_owned(), + }; + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior); } /// Create a session whose sidecar is a REAL fake-app-server-driven codex process (not @@ -25692,6 +26613,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -25744,6 +26666,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, observed_epoch: None, observed_generation: None, @@ -25941,6 +26864,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -25956,7 +26880,7 @@ pub(crate) mod tests { model_selection: None, permission_mode: Some("on-request".to_string()), sandbox: Some(freshell_protocol::Sandbox::WorkspaceWrite), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), plugins: None, tab_id: None, }, @@ -26011,6 +26935,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -26094,6 +27019,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -26447,7 +27373,7 @@ pub(crate) mod tests { simulate_unrequested_crash(&st, &thread_id).await; // A transient failure (NOT a "not found"-shaped message) on EVERY `thread/resume` - // attempt -- the first (with settings) AND the retry (with settings dropped). + // attempt -- the first and the retry that omits only the model. configure_fake_codex_cmd( &json!({ "overrides": { @@ -26621,6 +27547,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -28015,7 +28942,7 @@ pub(crate) mod tests { assert_eq!(projection["nativeFinalTurnIds"], json!(["turn-1"])); assert_eq!(projection["turnRows"]["turn-1"], json!(["turn-1"])); - // kata z7j7: codex honors all four settings knobs per send (merged over + // Codex honors these settings per send (merged over // the session baseline before turn/start). assert_eq!( snapshot["capabilities"]["settingScopes"], @@ -28024,6 +28951,8 @@ pub(crate) mod tests { "effort": "per-send", "sandbox": "per-send", "permissionMode": "per-send", + "cwd": "per-send", + "collaborationMode": "per-send", }) ); @@ -29307,7 +30236,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); let mut create = create_msg("legacy-effort-create"); create.model = Some("gpt-6-astra".into()); - create.effort = Some("max".into()); + create.effort = Some(Some("max".into())); st.handle_create(create, None).await; let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { f["type"] == "freshAgent.created" @@ -29354,7 +30283,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); let mut create = create_msg("native-model-refusal-create"); create.model = Some("provider-current".into()); - create.effort = Some("ultra".into()); + create.effort = Some(Some("ultra".into())); st.handle_create(create, None).await; let created = collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { frame["type"] == "freshAgent.created" @@ -29442,7 +30371,7 @@ pub(crate) mod tests { kind: "exact".into(), model_id: "provider-current".into(), })); - create.effort = Some("ultra".into()); + create.effort = Some(Some("ultra".into())); st.handle_create(create, None).await; let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { f["type"] == "freshAgent.created" @@ -29536,11 +30465,12 @@ pub(crate) mod tests { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -29722,11 +30652,12 @@ pub(crate) mod tests { images: None, request_id: None, settings: Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some(model.to_string()), permission_mode: None, sandbox: None, - effort: Some(effort.to_string()), + effort: Some(Some(effort.to_string())), }), observed_epoch: None, observed_generation: None, @@ -30744,6 +31675,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -30855,6 +31787,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -30971,6 +31904,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -31385,6 +32319,7 @@ pub(crate) mod tests { // adopted without consulting the coordinator. st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -31476,6 +32411,7 @@ pub(crate) mod tests { // observed generation — the fence discipline refuses typed. st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: Some(registry.boot_epoch()), diff --git a/crates/freshell-freshagent/src/codex/settings.rs b/crates/freshell-freshagent/src/codex/settings.rs new file mode 100644 index 000000000..8cafacf9a --- /dev/null +++ b/crates/freshell-freshagent/src/codex/settings.rs @@ -0,0 +1,183 @@ +//! Codex next-turn defaults, independent of runtime identity and ownership. + +use freshell_codex::{ + normalize_freshcodex_effort, normalize_freshcodex_model, to_codex_reasoning_effort, +}; +use freshell_protocol::FreshAgentSendSettings; +use serde::Serialize; +use serde_json::{json, Value}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CodexWorkMode { + Default, + Plan, +} + +impl CodexWorkMode { + pub fn parse(value: &str) -> Option { + match value { + "default" => Some(Self::Default), + "plan" => Some(Self::Plan), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Plan => "plan", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexEffectiveSettings { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, +} + +#[derive(Debug)] +pub struct SettingsError { + pub code: &'static str, + pub message: String, +} + +impl CodexEffectiveSettings { + /// An absent patch field preserves the exact selection, including native + /// default effort and opaque provider model/effort values. + pub fn resolve(base: &Self, patch: &FreshAgentSendSettings) -> Result { + let model = normalize_freshcodex_model(patch.model.as_deref().or(Some(&base.model))); + let effort = normalize_freshcodex_effort( + Some(&model), + patch + .effort + .as_ref() + .map_or(base.effort.as_deref(), |value| value.as_deref()), + ); + let mut sandbox = patch + .sandbox + .map(super::sandbox_wire_value) + .or_else(|| base.sandbox.clone()); + let mut permission_mode = patch + .permission_mode + .clone() + .or_else(|| base.permission_mode.clone()); + super::normalize_codex_permission(&mut permission_mode, &mut sandbox).map_err( + |message| SettingsError { + code: "INVALID_PERMISSION_MODE", + message: message.into(), + }, + )?; + to_codex_reasoning_effort(effort.as_deref()).map_err(|error| SettingsError { + code: "INVALID_EFFORT", + message: error.to_string(), + })?; + let cwd = patch.cwd.clone().or_else(|| base.cwd.clone()); + if cwd.as_deref().is_some_and(|cwd| cwd.trim().is_empty()) { + return Err(SettingsError { + code: "INVALID_CWD", + message: "Choose a nonempty working directory.".into(), + }); + } + let collaboration_mode = match patch.collaboration_mode.as_deref() { + Some(value) => Some(CodexWorkMode::parse(value).ok_or_else(|| SettingsError { + code: "INVALID_COLLABORATION_MODE", + message: "Choose Build or Plan work mode.".into(), + })?), + None => base.collaboration_mode, + }; + Ok(Self { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }) + } + + /// The caller supplies the same wire-normalized effort used by turn/start. + /// Default is explicit: omission could retain a previous native Plan mode. + pub fn native_collaboration_mode(&self) -> Option { + Some( + json!({"mode":self.collaboration_mode.unwrap_or(CodexWorkMode::Default).as_str(),"settings":{ + "model":self.model,"reasoning_effort":self.effort,"developer_instructions":null, + }}), + ) + } + + pub fn as_json(&self) -> Value { + let mut value = serde_json::to_value(self).expect("Codex settings serialize"); + value["effort"] = json!(self.effort); + value["collaborationMode"] = + json!(self.collaboration_mode.unwrap_or(CodexWorkMode::Default)); + value + } + + /// Adopt overrides actually supplied to thread/start or thread/resume. + /// Effort and work mode are next-turn choices: those requests do not send + /// them. Their native echoes are retained separately as thread observations. + pub fn adopt_native(&mut self, native: &Value) { + if let Some(model) = native + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + self.model = model.into(); + } + if let Some(cwd) = native.get("cwd").and_then(Value::as_str) { + self.cwd = Some(cwd.into()); + } + if let Some(policy) = native.get("approvalPolicy").and_then(Value::as_str) { + self.permission_mode = Some(policy.into()); + } + if let Some(sandbox) = native.get("sandbox") { + let mode = sandbox + .as_str() + .or_else(|| sandbox.get("type").and_then(Value::as_str)); + self.sandbox = match mode { + Some("readOnly" | "read-only") => Some("read-only".into()), + Some("workspaceWrite" | "workspace-write") => Some("workspace-write".into()), + Some("dangerFullAccess" | "danger-full-access") => { + Some("danger-full-access".into()) + } + _ => self.sandbox.clone(), + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_mode_uses_selected_model_and_effort_without_changing_sandbox() { + let settings = CodexEffectiveSettings { + model: "provider-current".into(), + effort: Some("xhigh".into()), + cwd: Some("/work".into()), + sandbox: Some("read-only".into()), + permission_mode: Some("on-request".into()), + collaboration_mode: Some(CodexWorkMode::Plan), + }; + assert_eq!( + settings.native_collaboration_mode(), + Some( + json!({"mode":"plan","settings":{"model":"provider-current","reasoning_effort":"xhigh","developer_instructions":null}}) + ) + ); + assert_eq!(settings.sandbox.as_deref(), Some("read-only")); + } +} diff --git a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs index 07ae9c7e3..408605ee6 100644 --- a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs +++ b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs @@ -95,6 +95,7 @@ async fn create_tracked_session_with_resume( ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -227,6 +228,7 @@ async fn failed_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -629,6 +631,7 @@ async fn create_bail_after_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index 96c694da8..7ec87576d 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -19,6 +19,7 @@ use crate::rollback_record::ROLLBACK_RECORD_VERSION; /// provider-native resume command needs. #[derive(Debug, Clone, Default, PartialEq)] pub struct FreshAgentSettings { + pub collaboration_mode: Option, pub model: Option, pub sandbox: Option, pub permission_mode: Option, @@ -2260,6 +2261,7 @@ mod tests { authoritative: false, settings: FreshAgentSettings { + collaboration_mode: None, model: Some("m".into()), sandbox: None, permission_mode: None, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ae4a44d56..305f71b2b 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5703,6 +5703,7 @@ async fn resume_session_ref_tab( // carries no handoff continuation context). authoritative: false, settings: identity_sink::FreshAgentSettings { + collaboration_mode: None, model: model.clone(), sandbox: None, permission_mode: None, @@ -6752,6 +6753,7 @@ async fn send_keys( // b8ke focused ep5 r2 F1: a REST-lane write. authoritative: false, settings: identity_sink::FreshAgentSettings { + collaboration_mode: None, model: pane.model.clone(), sandbox: None, permission_mode: None, diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 286d15038..62b0333b6 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -1510,7 +1510,10 @@ impl FreshOpencodeState { } let model = normalize_opencode_model(msg.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), msg.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + msg.effort.as_ref().and_then(|value| value.as_deref()), + ); let placeholder = format!("freshopencode-{request_id}"); let mut session = OpencodeSession::new(placeholder.clone(), msg.cwd.clone(), model, effort); @@ -1813,6 +1816,7 @@ impl FreshOpencodeState { ( session.provenance.clone(), crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -1836,7 +1840,7 @@ impl FreshOpencodeState { } let raw_model = msg.model.clone().or_else(|| session.model.clone()); let model = normalize_opencode_model(raw_model.as_deref()); - let raw_effort = msg.effort.clone().or_else(|| session.effort.clone()); + let raw_effort = msg.effort.clone().unwrap_or_else(|| session.effort.clone()); let effort = normalize_opencode_effort(model.as_deref(), raw_effort.as_deref()); session.model = model; session.effort = effort; @@ -1979,6 +1983,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model, sandbox: None, permission_mode: None, @@ -2309,7 +2314,10 @@ impl FreshOpencodeState { // stored model/effort/cwd are reused verbatim. let (model, effort, cwd) = if let Some(settings) = msg.settings.as_ref() { let model = normalize_opencode_model(settings.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), settings.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + settings.effort.as_ref().and_then(|value| value.as_deref()), + ); let cwd = settings .cwd .clone() @@ -2597,6 +2605,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -2729,6 +2738,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -3172,7 +3182,13 @@ impl FreshOpencodeState { } let model = normalize_opencode_model(msg.settings.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), msg.settings.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + msg.settings + .effort + .as_ref() + .and_then(|value| value.as_deref()), + ); let changed = session.model != model || session.effort != effort; if !changed { // Idempotent configure: nothing to converge. @@ -3206,6 +3222,7 @@ impl FreshOpencodeState { observed_generation: None, authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: model.clone(), sandbox: None, permission_mode: None, @@ -6051,6 +6068,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model, sandbox: None, permission_mode: None, @@ -7639,6 +7657,7 @@ impl FreshOpencodeState { // session gains provenance WITHOUT a laundered defaults snapshot). let settings = if recovered.is_some() { crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: rec.model.clone(), sandbox: None, permission_mode: None, @@ -9797,6 +9816,7 @@ mod tests { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -9912,6 +9932,7 @@ mod tests { let placeholder = "freshopencode-req-ign"; let mut msg = send_msg(placeholder, "ignored knobs should log"); msg.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, effort: None, model: None, @@ -13872,7 +13893,7 @@ mod tests { let mut create = create_msg("r1"); create.cwd = Some("/w".to_string()); create.model = Some("big-model".to_string()); - create.effort = Some("high".to_string()); + create.effort = Some(Some("high".to_string())); state.handle_create(create, None).await; state .handle_send(send_msg("freshopencode-r1", "hello")) @@ -14218,15 +14239,16 @@ mod tests { let mut create = create_msg("r2"); create.cwd = Some("/w".to_string()); create.model = Some("big-model".to_string()); - create.effort = Some("high".to_string()); + create.effort = Some(Some("high".to_string())); state.handle_create(create, None).await; let placeholder = "freshopencode-r2"; state.handle_send(send_msg(placeholder, "first")).await; let mut second = send_msg(placeholder, "second"); second.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, - effort: Some("low".to_string()), + effort: Some(Some("low".to_string())), model: Some("small-model".to_string()), permission_mode: None, sandbox: None, @@ -14344,6 +14366,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14387,6 +14410,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14852,6 +14876,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14927,6 +14952,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15456,6 +15482,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15664,6 +15691,7 @@ mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15767,6 +15795,7 @@ mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -22838,11 +22867,12 @@ mod tests { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -22862,11 +22892,12 @@ mod tests { images: None, request_id: Some(format!("req-{text}")), settings: Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }), observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index 96d930547..a1f045b71 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -1132,6 +1132,7 @@ async fn rest_resume_durable_ses_is_born_durable_with_ledger_settings_and_route( sink.settings.lock().unwrap().insert( ("opencode".to_string(), "ses_resumed_1".to_string()), FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".to_string()), sandbox: None, permission_mode: None, @@ -1681,6 +1682,7 @@ async fn rest_resume_body_model_and_effort_beat_the_ledger_record() { sink.settings.lock().unwrap().insert( ("opencode".to_string(), "ses_override_1".to_string()), FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".to_string()), sandbox: None, permission_mode: None, diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 98460787a..391e47ec3 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -701,6 +701,7 @@ async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_p fn create_msg(sid: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, request_id: format!("handoff-create-{}", uuid::Uuid::new_v4()), @@ -6240,6 +6241,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { "claude", &sid, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("opus-x".into()), sandbox: None, permission_mode: Some("plan".into()), @@ -6918,6 +6920,7 @@ async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, request_id: format!("handoff-create-fresh-{}", uuid::Uuid::new_v4()), diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index d4e428d54..dbf3fc98c 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -869,12 +869,19 @@ pub struct ModelSelection { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentCreate { + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, pub request_id: String, pub session_type: SessionType, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + /// Absent preserves saved effort; null selects the provider's default. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_option" + )] + pub effort: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub legacy_restore_context: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -975,13 +982,20 @@ pub struct FreshAgentImage { pub media_type: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentSendSettings { #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, + pub collaboration_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + pub cwd: Option, + /// Absent leaves the selection alone; null clears the Freshell override. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_option" + )] + pub effort: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Free string. diff --git a/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs b/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs index da1718331..5d2cc9420 100644 --- a/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs +++ b/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs @@ -90,9 +90,10 @@ fn configure_request_round_trips_settings_and_the_epoch_fence() { let configure = FreshAgentConfigureRequest { soul_id: SoulId::parse("soul-configure").unwrap(), settings: FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: None, }, @@ -117,6 +118,7 @@ fn configure_request_round_trips_settings_and_the_epoch_fence() { fn host_configure_command_keeps_the_semantic_request_id_and_settings() { let request_id = RequestId::parse("request-configure-1").unwrap(); let settings = FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some("/workspace/nested".into()), model: Some("opus".into()), effort: None, @@ -149,6 +151,7 @@ fn admin_configure_envelope_keeps_the_semantic_request_id() { AdminCommand::FreshAgentConfigure(FreshAgentConfigureRequest { soul_id: SoulId::parse("soul-admin-configure").unwrap(), settings: FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), effort: None, diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 6caac4ced..bcbecfde6 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1792,6 +1792,8 @@ impl FreshProvider { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentLaunchSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, /// Stable presentation/session key used by the external Freshell wire. pub session_id: String, pub provider: FreshProvider, @@ -1845,6 +1847,10 @@ impl FreshAgentLaunchSpec { FreshProvider::Opencode => "freshopencode", }; if self.session_id.is_empty() + || self + .collaboration_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "default" | "plan")) || self.session_type != expected_type || self.runtime_variant.is_empty() || self.provider_store_id.is_empty() @@ -2539,12 +2545,18 @@ pub struct TerminalInputRequest { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentTurnSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_optional" + )] + pub effort: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2554,7 +2566,7 @@ pub struct FreshAgentTurnSettings { impl FreshAgentTurnSettings { /// The effort a provider's live `freshAgent.configure` ends on. A /// configure states only what changed, except that the model selector's - /// Default thinking row is sent as an absent effort. Claude and Kilroy + /// Default thinking row historically used an absent effort. Claude and Kilroy /// read that absence as Default when the model changes, OpenCode always /// does, and Codex keeps the session's effort. Every durable copy of a /// hosted profile (the host actor's and the supervisor's recovery record) @@ -2567,7 +2579,7 @@ impl FreshAgentTurnSettings { current_effort: Option<&str>, ) -> Option { if let Some(effort) = &self.effort { - return Some(effort.clone()); + return effort.clone(); } let absent_means_default = match provider { FreshProvider::Claude | FreshProvider::Kilroy => self @@ -4431,6 +4443,7 @@ mod tests { fn fresh_agent_launch(provider: FreshProvider, session_type: &str) -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "presentation-one".into(), provider, session_type: session_type.into(), diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index c6b1e057b..807edd7b3 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -734,6 +734,7 @@ impl HostedFreshAgentProxy { }; provider_launch_context = context_for_fresh_provider(provider_launch_context); let launch = FreshAgentLaunchSpec { + collaboration_mode: message.collaboration_mode.clone(), session_id: public_session_id.clone(), provider: provider.clone(), session_type: session_type.clone(), @@ -746,7 +747,7 @@ impl HostedFreshAgentProxy { run_as_uid: PROVIDER_UID, run_as_gid: PROVIDER_GID, model: message.model.clone(), - effort: message.effort.clone(), + effort: message.effort.clone().flatten(), permission_mode: message.permission_mode.clone(), sandbox: message .sandbox @@ -1503,6 +1504,7 @@ fn turn_settings( ) -> Option { let mut settings = settings.map( |settings| freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: settings.collaboration_mode, cwd: settings.cwd, model: settings.model, effort: settings.effort, diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 13d79d629..7b3b54da9 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -268,11 +268,12 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { + collaboration_mode: None, pane_id: request.pane_id, request_id: request.request_id, session_type, cwd: request.cwd, - effort: request.effort, + effort: request.effort.map(Some), legacy_restore_context: None, model: request.model, model_selection: request.model_selection, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 68652d3ab..33524e234 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -689,9 +689,10 @@ async fn hosted_configure_calls_the_runtime_with_the_mapped_settings() { assert_eq!( configure.settings, freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some("/workspace".into()), model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: Some("workspace-write".into()), } @@ -2278,7 +2279,7 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() assert_eq!(incarnation_id, &prepared.incarnation_id); assert_eq!(request_id.as_str(), "configure-through-supervisor"); assert_eq!(settings.model.as_deref(), Some("opus")); - assert_eq!(settings.effort.as_deref(), Some("high")); + assert_eq!(settings.effort, Some(Some("high".into()))); let recovered = registry .recovery_context(soul) .await diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 6bbd19ba5..92c2a1d17 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -332,6 +332,7 @@ impl PaneIdentitySink for LedgerIdentitySink { }; let stored_genesis = upsert.genesis.stored_value(); let w = FreshAgentBindingWrite { + collaboration_mode: upsert.settings.collaboration_mode.as_deref(), genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( stored_genesis.as_ref(), upsert.genesis.is_clear(), @@ -451,6 +452,7 @@ impl PaneIdentitySink for LedgerIdentitySink { return None; } let s = FreshAgentSettings { + collaboration_mode: row.collaboration_mode, model: row.model, sandbox: row.sandbox, permission_mode: row.permission_mode, @@ -824,6 +826,94 @@ mod tests { use freshell_freshagent::{FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink}; use std::sync::Arc; + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_complete_codex_defaults_survive_the_actual_ledger_restart() { + let root = tempfile::tempdir().unwrap(); + let settings = FreshAgentSettings { + model: Some("provider-current".into()), + effort: None, + sandbox: Some("read-only".into()), + permission_mode: Some("untrusted".into()), + cwd: Some("/workspace/project".into()), + collaboration_mode: Some("plan".into()), + plugins: None, + }; + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let sink = LedgerIdentitySink::new(ledger.clone(), None); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "codex".into(), + session_id: "settings-ledger".into(), + mode: "freshcodex".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: Some(11), + observed_generation: Some(3), + authoritative: false, + settings: settings.clone(), + }) + .await + .unwrap(); + assert_eq!( + sink.load_settings("codex", "settings-ledger"), + Some(settings.clone()) + ); + drop(sink); + drop(ledger); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let recovered = LedgerIdentitySink::new(ledger.clone(), None); + assert_eq!( + recovered.load_settings("codex", "settings-ledger"), + Some(settings) + ); + let row = ledger.load_binding("codex", "settings-ledger").unwrap(); + assert_eq!((row.owner_epoch, row.owner_generation), (Some(11), Some(3))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_a_work_mode_only_record_is_recoverable() { + let root = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let sink = LedgerIdentitySink::new(ledger, None); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "codex".into(), + session_id: "mode-only".into(), + mode: "freshcodex".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 { + collaboration_mode: Some("plan".into()), + ..Default::default() + }, + }) + .await + .unwrap(); + assert!(sink.was_recorded("codex", "mode-only")); + assert_eq!( + sink.load_settings("codex", "mode-only") + .unwrap() + .collaboration_mode + .as_deref(), + Some("plan") + ); + } + #[tokio::test(flavor = "multi_thread")] async fn writes_through_to_ledger_and_reads_back() { let tmp = tempfile::tempdir().unwrap(); @@ -846,6 +936,7 @@ mod tests { authoritative: false, settings: FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: Some("workspace-write".into()), permission_mode: Some("on-request".into()), @@ -1243,6 +1334,7 @@ mod tests { .expect("seed write (codex)"); let settings = FreshAgentSettings { + collaboration_mode: None, model: None, sandbox: None, permission_mode: None, diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index e8f7bb8c5..563d647ac 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -103,6 +103,7 @@ fn binding_row_at( ) -> BindingRow { let (state, retired_reason, superseded_by) = state_parts; BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: provider.to_string(), @@ -3115,6 +3116,7 @@ fn fresh_agent_create_completed_after_the_pane_closed_is_never_offered() { let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", @@ -3180,6 +3182,7 @@ fn fresh_agent_create_asserted_inside_the_kill_window_stays_offered_despite_a_la let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", @@ -3318,6 +3321,7 @@ fn supersession_after_the_freeze_judges_on_the_parents_assertion_time() { .expect("parent binding write"); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "child-id", diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 8a50fe55c..621550089 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -10367,6 +10367,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-configure-agent"), FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), cwd: "/workspace".into(), @@ -10394,9 +10395,10 @@ mod tests { incarnation_id: state.incarnation_id.clone(), request_id: RequestId::parse("rpc-configure-one").unwrap(), settings: freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, }, @@ -10526,6 +10528,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("child-observer"), FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "freshcodex".into(), cwd: "/workspace".into(), @@ -10603,6 +10606,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("catalog-observer"), FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "freshcodex".into(), cwd: "/workspace".into(), @@ -10674,6 +10678,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-fixture-agent"), FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), cwd: "/workspace".into(), diff --git a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs index baeb3172d..5d7e66173 100644 --- a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs @@ -644,6 +644,7 @@ mod tests { #[test] fn fixture_native_identity_is_store_and_provider_specific() { let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "codex-app-server".into(), cwd: "/workspace".into(), @@ -678,6 +679,7 @@ mod tests { async fn provider_native_completion_crosses_the_durable_actor_once() { let root = tempfile::tempdir().unwrap(); let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "opencode-per-soul-http".into(), cwd: "/workspace".into(), diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index cd35bda19..f111a2b77 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -109,6 +109,7 @@ pub(crate) async fn open_hosted_fresh_agent( fn profile_from_launch(launch: &FreshAgentLaunchSpec) -> FreshAgentProfile { FreshAgentProfile { + collaboration_mode: launch.collaboration_mode.clone(), provider: launch.provider.clone(), runtime_variant: launch.runtime_variant.clone(), cwd: launch.cwd.clone(), @@ -523,6 +524,32 @@ impl FreshAgentTransport for HostedTransport { text: &str, profile: &FreshAgentProfile, logical_attempt_id: Option<&str>, + ) -> Result { + let settings = freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: profile.collaboration_mode.clone(), + cwd: Some(profile.cwd.clone()), + model: profile.model.clone(), + effort: Some(profile.effort.clone()), + permission_mode: profile.effective_permission_mode().map(str::to_owned), + sandbox: profile.sandbox.clone(), + }; + self.dispatch_with_turn_settings( + request_id, + text, + profile, + Some(&settings), + logical_attempt_id, + ) + .await + } + + async fn dispatch_with_turn_settings( + &self, + request_id: &RequestId, + text: &str, + profile: &FreshAgentProfile, + requested: Option<&freshell_runtime_protocol::FreshAgentTurnSettings>, + logical_attempt_id: Option<&str>, ) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -540,6 +567,18 @@ impl FreshAgentTransport for HostedTransport { *self.profile.lock().expect("profile lock") = Some(profile.clone()); let permission_mode = profile.effective_permission_mode().map(str::to_owned); let profile = profile.clone(); + let settings = if self.provider == FreshProvider::Codex { + requested.map(provider_send_settings) + } else { + Some(FreshAgentSendSettings { + collaboration_mode: profile.collaboration_mode, + cwd: Some(profile.cwd), + effort: profile.effort.map(Some), + model: profile.model, + permission_mode, + sandbox: profile.sandbox.as_deref().and_then(parse_sandbox), + }) + }; let mut outcomes = self.send_outcomes.subscribe(); let send = FreshAgentSend { observed_epoch: None, @@ -548,16 +587,10 @@ impl FreshAgentTransport for HostedTransport { session_id, session_type: self.session_type(), text: text.to_string(), - cwd: Some(profile.cwd.clone()), + cwd: settings.as_ref().and_then(|settings| settings.cwd.clone()), images: None, request_id: Some(request_id.as_str().to_string()), - settings: Some(FreshAgentSendSettings { - cwd: Some(profile.cwd), - effort: profile.effort, - model: profile.model, - permission_mode, - sandbox: profile.sandbox.as_deref().and_then(parse_sandbox), - }), + settings, checkpoint_intent: logical_attempt_id.map(|logical_attempt_id| { FreshAgentCheckpointIntent { logical_attempt_id: logical_attempt_id.to_string(), @@ -939,13 +972,7 @@ impl FreshAgentTransport for HostedTransport { session_type: self.session_type(), cwd: settings.cwd.clone(), request_id: Some(request_id.as_str().to_string()), - settings: FreshAgentSendSettings { - cwd: settings.cwd.clone(), - effort: settings.effort.clone(), - model: settings.model.clone(), - permission_mode: settings.permission_mode.clone(), - sandbox: settings.sandbox.as_deref().and_then(parse_sandbox), - }, + settings: provider_send_settings(settings), }; match &self.state { ProviderState::Claude(state) => state.handle_configure(message).await, @@ -1198,6 +1225,19 @@ impl FreshAgentTransport for HostedTransport { } } +fn provider_send_settings( + settings: &freshell_runtime_protocol::FreshAgentTurnSettings, +) -> FreshAgentSendSettings { + FreshAgentSendSettings { + collaboration_mode: settings.collaboration_mode.clone(), + cwd: settings.cwd.clone(), + model: settings.model.clone(), + effort: settings.effort.clone(), + permission_mode: settings.permission_mode.clone(), + sandbox: settings.sandbox.as_deref().and_then(parse_sandbox), + } +} + fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { let (provider, session_type, provider_name) = match profile.provider { FreshProvider::Claude => (AgentProvider::Claude, SessionType::Freshclaude, "claude"), @@ -1210,6 +1250,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { ), }; FreshAgentCreate { + collaboration_mode: profile.collaboration_mode.clone(), pane_id: None, request_id: format!("host-create-{}", uuid::Uuid::new_v4()), observed_epoch: None, @@ -1217,7 +1258,11 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { naming_handle: None, session_type, cwd: Some(profile.cwd.clone()), - effort: profile.effort.clone(), + effort: if profile.provider == FreshProvider::Codex { + Some(profile.effort.clone()) + } else { + profile.effort.clone().map(Some) + }, legacy_restore_context: None, model: profile.model.clone(), model_selection: profile.model_selection.as_ref().map(|selection| { @@ -1899,7 +1944,12 @@ mod tests { _lock: tokio::sync::MutexGuard<'static, ()>, } - async fn native_codex_fixture(behavior: Value) -> NativeCodexFixture { + async fn native_codex_fixture(mut behavior: Value) -> NativeCodexFixture { + behavior + .as_object_mut() + .unwrap() + .entry("echoRequestedModel") + .or_insert(json!(true)); let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -1965,6 +2015,201 @@ mod tests { } } + #[tokio::test] + async fn task008_settings_native_echo_survives_managed_next_send_and_reopen() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("native-effective.jsonl"); + let native_cwd = logs.path().join("native-cwd"); + std::fs::create_dir(&native_cwd).unwrap(); + let mut fixture = native_codex_fixture(json!({ + "appendRequestLogPath":log, "recordTurns":true, + "effectiveThreadSettings":{"model":"provider-current","reasoningEffort":"low", + "cwd":native_cwd,"sandbox":{"type":"readOnly"},"approvalPolicy":"on-request"} + })) + .await; + fixture.profile.effort = Some("ultra".into()); + fixture.profile.collaboration_mode = Some("plan".into()); + let state_dir = fixture.dir.path().join("native-effective-actor"); + let actor = FreshAgentHostActor::open( + &state_dir, + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let startup = actor.snapshot().await.unwrap(); + actor + .dispatch( + RequestId::parse("native-effective-first").unwrap(), + "native effective first send".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while actor.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let profile = actor.profile().await; + actor.stop().await.unwrap(); + let resumed = FreshAgentHostActor::open( + &state_dir, + profile, + HostedTransport::new(FreshProvider::Codex).await, + ) + .await + .unwrap(); + resumed + .dispatch( + RequestId::parse("native-effective-resumed").unwrap(), + "native effective resumed send".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while resumed.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + // An explicit per-send choice still wins even when it equals the old + // launch intent: equality of profiles is not absence of a request. + resumed.dispatch(RequestId::parse("native-effective-explicit").unwrap(), "explicit old selection".into(), Some(serde_json::from_value(json!({ + "model":fixture.profile.model,"cwd":fixture.profile.cwd,"sandbox":"workspace-write","permissionMode":"never" + })).unwrap())).await.unwrap(); + resumed.stop().await.unwrap(); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .map(|row| &row["params"]) + .collect(); + assert_eq!( + startup["settings"]["model"], "provider-current", + "the owning provider already exposes its native-effective settings" + ); + assert_eq!(starts.len(), 3); + assert_eq!(starts[2]["model"], fixture.profile.model.unwrap()); + assert_eq!(starts[2]["cwd"], fixture.profile.cwd); + assert_eq!(starts[2]["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(starts[2]["approvalPolicy"], "never"); + assert_eq!(starts[..2].iter().map(|params| json!({"model":params["model"],"cwd":params["cwd"],"sandbox":params["sandboxPolicy"]["type"],"approval":params["approvalPolicy"],"effort":params["effort"],"mode":params["collaborationMode"]["mode"]})).collect::>(), vec![json!({"model":"provider-current","cwd":native_cwd,"sandbox":"readOnly","approval":"on-request","effort":"ultra","mode":"plan"});2], "unchanged host intent must not put stale requested settings back over native echoes"); + } + + #[tokio::test] + async fn task008_settings_managed_actor_forwards_and_recovers_native_plan_defaults() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("settings-requests.jsonl"); + let fixture = + native_codex_fixture(json!({"appendRequestLogPath":log,"recordTurns":true})).await; + let state_dir = fixture.dir.path().join("settings-actor"); + let actor = FreshAgentHostActor::open( + &state_dir, + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let cwd = fixture.dir.path().join("next"); + std::fs::create_dir(&cwd).unwrap(); + actor.configure(RequestId::parse("managed-settings").unwrap(), serde_json::from_value(json!({"model":"provider-current","effort":"ultra","cwd":cwd,"sandbox":"read-only","permissionMode":"on-request","collaborationMode":"plan"})).unwrap()).await.unwrap(); + actor + .dispatch( + RequestId::parse("managed-plan").unwrap(), + "managed plan".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if actor.snapshot().await.unwrap()["status"] == "idle" { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + actor + .configure( + RequestId::parse("managed-native-default").unwrap(), + serde_json::from_value(json!({"effort":null})).unwrap(), + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("managed-native-default-send").unwrap(), + "native default effort".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while actor.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let profile = actor.profile().await; + actor.stop().await.unwrap(); + let resumed = FreshAgentHostActor::open( + &state_dir, + profile, + HostedTransport::new(FreshProvider::Codex).await, + ) + .await + .unwrap(); + resumed + .dispatch( + RequestId::parse("managed-plan-reload").unwrap(), + "managed plan after reload".into(), + None, + ) + .await + .unwrap(); + let recovered = serde_json::to_value(resumed.profile().await).unwrap(); + resumed.stop().await.unwrap(); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + assert_eq!(starts.len(), 3); + assert_eq!( + starts + .iter() + .map(|row| row["params"]["effort"].clone()) + .collect::>(), + vec![json!("ultra"), Value::Null, Value::Null] + ); + for (index, row) in starts.into_iter().enumerate() { + assert_eq!( + row["params"]["collaborationMode"], + json!({"mode":"plan","settings":{"model":"provider-current","reasoning_effort":if index == 0 {json!("ultra")} else {Value::Null},"developer_instructions":null}}) + ); + assert_eq!(row["params"]["cwd"], json!(cwd)); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(row["params"]["approvalPolicy"], "on-request"); + } + assert_eq!(recovered["collaborationMode"], "plan"); + } + #[tokio::test] async fn model_catalog_uses_the_same_initialized_native_actor_process_without_a_turn() { let logs = tempfile::tempdir().unwrap(); @@ -2923,6 +3168,7 @@ mod tests { fn profile(&self, resume: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), cwd: self.root.path().join("workspace").to_string_lossy().into(), @@ -3282,6 +3528,7 @@ mod tests { .await; let transport = claude.transport.clone(); let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), cwd: claude @@ -3354,7 +3601,7 @@ mod tests { let run = hosted_claude_configure( freshell_runtime_protocol::FreshAgentTurnSettings { model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), ..Default::default() }, false, @@ -3430,7 +3677,7 @@ mod tests { let run = hosted_claude_configure( freshell_runtime_protocol::FreshAgentTurnSettings { model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), ..Default::default() }, true, @@ -3537,6 +3784,7 @@ mod tests { std::env::set_var("FAKE_OPENCODE_AUDIT_LOG", &audit); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), @@ -3739,6 +3987,7 @@ mod tests { // including the product RED and setup-error paths. let observed: Result = async { let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), model: None, effort: None, permission_mode: None, sandbox: None, provider_store_id: "owned-store".into(), @@ -3897,6 +4146,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), @@ -4336,6 +4586,7 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; FreshAgentProfile { + collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), cwd: "/workspace".into(), @@ -4404,9 +4655,10 @@ mod tests { }, FreshAgentOperation::Configure { settings: freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("fixture-configured-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: None, }, @@ -4464,7 +4716,7 @@ mod tests { let mut profile = provider_context.clone(); profile.cwd = message.cwd.clone().unwrap(); profile.model = message.model.clone(); - profile.effort = message.effort.clone(); + profile.effort = message.effort.clone().flatten(); profile.permission_mode = message.permission_mode.clone(); profile.sandbox = message .sandbox @@ -4950,6 +5202,7 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; let profile = FreshAgentProfile { + collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), cwd: "/workspace".into(), diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 05af2834e..9dc861038 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -1908,6 +1908,7 @@ mod tests { let binary = root.path().join("freshell-session-host"); std::fs::write(&binary, b"host").unwrap(); let agent = FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "session-one".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2256,6 +2257,7 @@ mod tests { #[test] fn fresh_agent_config_digest_survives_native_identity_and_turn_settings() { let agent = FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "presentation-one".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2397,6 +2399,7 @@ mod tests { fn fresh_agent_spec_fixture() -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "session-fixture".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), diff --git a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs index b73782777..f9d3dadc9 100644 --- a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs +++ b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs @@ -80,6 +80,7 @@ async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { fixture: None, terminal: None, fresh_agent: Some(FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "fresh-session".into(), provider: provider.clone(), session_type: if provider == FreshProvider::Codex { @@ -268,14 +269,79 @@ fn configure_envelope_with( fn opus_high() -> FreshAgentTurnSettings { FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, } } +#[tokio::test] +async fn task008_settings_admin_codex_absent_effort_preserves_and_null_clears_recovery_defaults() { + let fixture = running_fresh_provider(FreshProvider::Codex).await; + let (received, host) = fake_host(&fixture.runtime_dir); + let epoch = fixture.supervisor.registry.control_epoch(); + let original = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent + .unwrap(); + for (request, patch, expected) in [ + ( + "codex-cwd-only", + serde_json::json!({"cwd":original.cwd,"collaborationMode":"plan","sandbox":"read-only"}), + Some("low"), + ), + ( + "codex-native-default", + serde_json::json!({"effort":null}), + None, + ), + ] { + let reply = fixture + .supervisor + .dispatch(configure_envelope_with( + &fixture.soul, + &RequestId::parse(request).unwrap(), + Some(epoch), + serde_json::from_value(patch).unwrap(), + )) + .await; + assert!(reply.result.is_ok(), "{:?}", reply.result); + let recovered = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent + .unwrap(); + assert_eq!(recovered.effort.as_deref(), expected); + assert_eq!(recovered.collaboration_mode.as_deref(), Some("plan")); + assert_eq!(recovered.sandbox.as_deref(), Some("read-only")); + assert_eq!(recovered.model, original.model); + } + host.abort(); + let _ = host.await; + let configures: Vec<_> = received + .lock() + .unwrap() + .iter() + .filter_map(|command| match command { + HostCommand::FreshAgentConfigure { settings, .. } => Some(settings.clone()), + _ => None, + }) + .collect(); + assert_eq!(configures.len(), 2); + assert_eq!(configures[0].effort, None); + assert_eq!(configures[1].effort, Some(None)); +} + #[tokio::test] async fn admin_configure_routes_to_the_souls_host_and_updates_the_recovery_profile() { let fixture = running_fresh_claude().await; diff --git a/crates/freshell-supervisor/src/recovery.rs b/crates/freshell-supervisor/src/recovery.rs index e91184183..19d0ebbe2 100644 --- a/crates/freshell-supervisor/src/recovery.rs +++ b/crates/freshell-supervisor/src/recovery.rs @@ -2011,9 +2011,14 @@ fn fresh_agent_replay_settings( agent: &freshell_runtime_protocol::FreshAgentLaunchSpec, ) -> freshell_runtime_protocol::FreshAgentTurnSettings { freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: agent.collaboration_mode.clone(), cwd: Some(agent.cwd.clone()), model: agent.model.clone(), - effort: agent.effort.clone(), + effort: if agent.provider == freshell_runtime_protocol::FreshProvider::Codex { + Some(agent.effort.clone()) + } else { + agent.effort.clone().map(Some) + }, permission_mode: agent.permission_mode.clone(), sandbox: agent.sandbox.clone(), } @@ -2419,6 +2424,7 @@ mod tests { #[test] fn queued_fresh_prompt_replays_with_the_exact_persisted_turn_profile() { let agent = FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "presentation".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2446,7 +2452,7 @@ mod tests { let settings = fresh_agent_replay_settings(&agent); assert_eq!(settings.cwd.as_deref(), Some("/workspace/nested")); assert_eq!(settings.model.as_deref(), Some("model-exact")); - assert_eq!(settings.effort.as_deref(), Some("high")); + assert_eq!(settings.effort, Some(Some("high".into()))); assert_eq!(settings.permission_mode.as_deref(), Some("ask")); assert_eq!(settings.sandbox.as_deref(), Some("workspace-write")); } diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index 283dcd643..f1449f815 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -1081,7 +1081,7 @@ impl Registry { let mut spec: FreshAgentLaunchSpec = serde_json::from_str(&encoded) .map_err(|_| RegistryError::Integrity("invalid fresh-agent launch spec".into()))?; let effort = match fold { - SettingsFold::Turn => settings.effort.clone().or_else(|| spec.effort.clone()), + SettingsFold::Turn => settings.effort.clone().unwrap_or_else(|| spec.effort.clone()), SettingsFold::Configure => settings.effort_after_configure( &spec.provider, spec.model.as_deref(), @@ -1112,6 +1112,9 @@ impl Registry { if let Some(value) = settings.sandbox { spec.sandbox = Some(value); } + if let Some(value) = settings.collaboration_mode { + spec.collaboration_mode = Some(value); + } spec.validate().map_err(|error| { RegistryError::InvalidState(error.message) })?; @@ -3796,6 +3799,7 @@ mod tests { launch.provider = "claude".into(); launch.provider_store_id = "fresh-store".into(); launch.fresh_agent = Some(FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "fresh-session".into(), provider: freshell_runtime_protocol::FreshProvider::Claude, session_type: "freshclaude".into(), @@ -4552,9 +4556,10 @@ mod tests { soul.clone(), prepared.incarnation_id, FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some(nested.to_string_lossy().into_owned()), model: Some("recovery-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("accept-edits".into()), sandbox: Some("danger-full-access".into()), }, diff --git a/crates/freshell-supervisor/src/resume_catalog.rs b/crates/freshell-supervisor/src/resume_catalog.rs index c0d00b6dd..2c45d6890 100644 --- a/crates/freshell-supervisor/src/resume_catalog.rs +++ b/crates/freshell-supervisor/src/resume_catalog.rs @@ -355,6 +355,7 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "codex-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "presentation-42".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -400,6 +401,7 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "fixture-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + collaboration_mode: None, session_id: "presentation-fixture-42".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index 6d234b13c..26ed1cae9 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -780,6 +780,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: TID, diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index 2962570f0..b88b4b077 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -1433,6 +1433,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "ses_freshagent", diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 5fb7d37bc..61dd0bc05 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -1324,6 +1324,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: SID, diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 63ab8e401..45415c3a4 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -867,6 +867,8 @@ pub struct BindingRow { #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, /// G15: the claude pane's local plugin directories, which every launch of /// the session hands to the sidecar again. Serde-optional under @@ -1333,6 +1335,7 @@ impl<'a> GenesisWrite<'a> { /// so writes replace rather than merge. #[derive(Debug, Clone, Copy)] pub struct FreshAgentBindingWrite<'a> { + pub collaboration_mode: Option<&'a str>, pub provider: &'a str, pub session_id: &'a str, pub mode: &'a str, @@ -2271,6 +2274,7 @@ impl PaneLedger { .map(str::to_string) .or_else(|| existing.and_then(|r| r.origin_create_request_id.clone())); let row = BindingRow { + collaboration_mode: None, ledger_version: LEDGER_VERSION, provider: w.provider.to_string(), session_id: w.session_id.to_string(), @@ -2827,6 +2831,7 @@ impl PaneLedger { model: w.model.map(str::to_string), sandbox: w.sandbox.map(str::to_string), permission_mode: w.permission_mode.map(str::to_string), + collaboration_mode: w.collaboration_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 @@ -4721,6 +4726,7 @@ impl PaneLedger { && (r.model.is_some() || r.sandbox.is_some() || r.permission_mode.is_some() + || r.collaboration_mode.is_some() || r.effort.is_some() || r.cwd.is_some() || r.plugins.is_some()) diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 4db3edc69..70cc3474b 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -283,6 +283,7 @@ fn write_provenance_at( fn fa_write<'a>(provider: &'a str, session_id: &'a str, now_ms: i64) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -313,6 +314,7 @@ fn fa_write_fenced<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -346,6 +348,7 @@ fn fa_write_target<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -2960,6 +2963,7 @@ fn disabled_ledger_refuses_the_rollback_row_write_with_a_loud_error() { // The other lanes keep their existing disabled policy (silent no-op). ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "claude", session_id: "sid", @@ -4800,6 +4804,7 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { // index load sees the forged rows — the actual post-crash boot shape. for (sid, at) in [("th-old", 1_000i64), ("th-new", 2_000i64)] { let row = BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: "codex".into(), @@ -4931,6 +4936,7 @@ fn fresh_agent_binding_roundtrips_settings_and_pane_kind() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", @@ -4967,6 +4973,7 @@ fn fresh_agent_binding_upsert_preserves_created_at_and_refreshes_settings() { let root = temp_root("fresh-agent-upsert"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_abc", @@ -5018,6 +5025,7 @@ fn supersedes_retires_the_old_row_and_links_the_chain() { let root = temp_root("fresh-agent-supersedes"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "old-thread", @@ -5085,6 +5093,7 @@ fn fresh_agent_upsert_preserves_advisory_create_request_id_when_absent() { let root = temp_root("fresh-agent-req-id"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", @@ -5135,6 +5144,7 @@ fn fresh_agent_settings_recorded_keys_off_settings_bearing_rows() { let root = temp_root("fresh-agent-settings-recorded"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_full", @@ -5187,6 +5197,7 @@ fn supersedes_of_a_missing_old_row_is_a_silent_noop() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "new-thread", @@ -8496,6 +8507,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { live_terminal_id: Option<&str>, ) -> BindingRow { BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: "claude".to_string(), diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index e1a7bd14b..8f8fcae9c 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -476,6 +476,7 @@ impl ClaudeRig { .as_ref() .expect("seeding a binding row needs the rig's durable ledger") .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, provider: "claude", session_id, mode: "freshclaude", diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index a1aaa540b..14ccd2e67 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -207,6 +207,7 @@ impl PaneIdentitySink for LedgerSink { }; let stored_genesis = upsert.genesis.stored_value(); ledger.record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: upsert.settings.collaboration_mode.as_deref(), genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( stored_genesis.as_ref(), upsert.genesis.is_clear(), @@ -295,6 +296,7 @@ impl PaneIdentitySink for LedgerSink { return None; } let settings = FreshAgentSettings { + collaboration_mode: row.collaboration_mode, model: row.model, sandbox: row.sandbox, permission_mode: row.permission_mode, diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 7b95535d7..e9e2ce200 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -3495,6 +3495,7 @@ async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end( ); pane_ledger .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: &sid, diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 3cd8e30af..9314980d0 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -713,6 +713,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "old-t", @@ -736,6 +737,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "new-t", @@ -777,6 +779,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { fn seed_claude_binding(ledger: &PaneLedger, session_id: &str, genesis: Option<&Value>) { ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: genesis.map_or(freshell_ws::pane_ledger::GenesisWrite::Keep, |genesis| { freshell_ws::pane_ledger::GenesisWrite::Set(genesis) }), diff --git a/docs/index.html b/docs/index.html index 24d11e106..b96f539d2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -976,7 +976,7 @@

Task Board

~/code/freshell
-
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Codex child conversations open as read-only observations inside their parent pane. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
+
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Codex child conversations open as read-only observations inside their parent pane. Freshcodex settings include File access and Build or Plan work mode. Model and thinking changes apply to your next message; Default lets Codex choose its thinking level. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
Diffs
src/components/fresh-agent/FreshAgentView.tsx
diff --git a/port/contract/ws-protocol.schema.json b/port/contract/ws-protocol.schema.json index 2ae657b9d..2c158bd48 100644 --- a/port/contract/ws-protocol.schema.json +++ b/port/contract/ws-protocol.schema.json @@ -1849,12 +1849,26 @@ { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "legacyRestoreContext": { "additionalProperties": false, @@ -2172,13 +2186,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, @@ -2294,13 +2322,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, @@ -3551,12 +3593,26 @@ { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "legacyRestoreContext": { "additionalProperties": false, @@ -3874,13 +3930,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, @@ -3996,13 +4066,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, @@ -4660,13 +4744,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, @@ -4705,12 +4803,26 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "legacyRestoreContext": { "additionalProperties": false, @@ -5448,13 +5560,27 @@ "settings": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, "cwd": { "minLength": 1, "type": "string" }, "effort": { - "minLength": 1, - "type": "string" + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] }, "model": { "minLength": 1, diff --git a/port/contract/ws-server-messages.schema.json b/port/contract/ws-server-messages.schema.json index 86d09925b..98c202421 100644 --- a/port/contract/ws-server-messages.schema.json +++ b/port/contract/ws-server-messages.schema.json @@ -3929,6 +3929,16 @@ "additionalProperties": { "additionalProperties": false, "properties": { + "collaborationMode": { + "enum": [ + "default", + "plan" + ], + "type": "string" + }, + "cwd": { + "type": "string" + }, "defaultPermissionMode": { "type": "string" }, @@ -3992,6 +4002,14 @@ } ] }, + "sandbox": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" + }, "style": { "enum": [ "sans", diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index e91e55efc..76272b126 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -18,6 +18,8 @@ export const FreshAgentSettingScopesSchema = z.object({ effort: FreshAgentSettingScopeSchema.optional(), sandbox: FreshAgentSettingScopeSchema.optional(), permissionMode: FreshAgentSettingScopeSchema.optional(), + cwd: FreshAgentSettingScopeSchema.optional(), + collaborationMode: FreshAgentSettingScopeSchema.optional(), }).strict() export type FreshAgentSettingScopes = z.infer @@ -53,9 +55,12 @@ export const FreshAgentTokenUsageSchema = z.object({ }).strict() export const FreshAgentSettingsSchema = z.object({ + cwd: z.string().min(1).optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), model: z.string().min(1).optional(), permissionMode: z.string().min(1).optional(), - effort: z.string().trim().min(1).optional(), + effort: z.string().trim().min(1).nullable().optional(), plugins: z.array(z.string()).optional(), }).strict() diff --git a/shared/settings.ts b/shared/settings.ts index a02e4da65..3b35d5b0b 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -153,6 +153,9 @@ export type FreshAgentModelEffortLevels = { } export type FreshAgentProviderDefaults = { + cwd?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + collaborationMode?: 'default' | 'plan' modelSelection?: FreshAgentModelSelection defaultPermissionMode?: string effort?: FreshAgentEffort @@ -768,6 +771,9 @@ const FreshAgentModelEffortLevelsSchema = z function createFreshAgentProviderDefaultsSchema() { return z .object({ + cwd: z.string().min(1).optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), modelSelection: FreshAgentModelSelectionSchema.optional(), defaultPermissionMode: z.string().optional(), effort: FreshAgentModelCapabilitiesOpaqueStringSchema.optional(), @@ -780,6 +786,9 @@ function createFreshAgentProviderDefaultsSchema() { function createFreshAgentProviderDefaultsPatchSchema() { return z .object({ + cwd: z.string().min(1).optional(), + sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), modelSelection: FreshAgentModelSelectionSchema.nullable().optional(), defaultPermissionMode: z.string().optional(), effort: z.union([FreshAgentModelCapabilitiesOpaqueStringSchema, z.literal('')]).nullable().optional(), @@ -1036,6 +1045,9 @@ function sanitizeFreshAgentServerSettingsPatchInput( && Object.keys(normalizedProviderPatchInput).length > 0 ) { const normalizedProviderPatch: FreshAgentProviderDefaults = {} + if (hasOwn(normalizedProviderPatchInput, 'cwd')) normalizedProviderPatch.cwd = parsed.data.cwd + if (hasOwn(normalizedProviderPatchInput, 'sandbox')) normalizedProviderPatch.sandbox = parsed.data.sandbox + if (hasOwn(normalizedProviderPatchInput, 'collaborationMode')) normalizedProviderPatch.collaborationMode = parsed.data.collaborationMode if (hasOwn(normalizedProviderPatchInput, 'modelSelection')) { normalizedProviderPatch.modelSelection = parsed.data.modelSelection ?? undefined } @@ -1292,7 +1304,7 @@ function normalizeLegacyFreshAgentProviderDefaultsInput( const normalized = pickOwnKeysPreservingUndefined( providerPatch, - ['modelSelection', 'defaultPermissionMode', 'effort', 'style', 'modelEffortLevels'], + ['modelSelection', 'defaultPermissionMode', 'effort', 'style', 'modelEffortLevels', 'cwd', 'sandbox', 'collaborationMode'], ) if ( diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 7e07d3418..84b784268 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -861,9 +861,10 @@ export const FreshAgentCreateSchema = z.object({ model: z.string().optional(), permissionMode: z.string().optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), sessionRef: z.object({ provider: z.string().min(1), sessionId: z.string().min(1) }).optional(), modelSelection: z.object({ kind: z.string().min(1), modelId: z.string().min(1) }).optional().or(z.null()), - effort: z.string().trim().min(1).optional(), + effort: z.string().trim().min(1).nullable().optional(), plugins: z.array(z.string()).optional(), /** D8: the creating tab's client-side id; the server composes the ledger row's * `tabKey` as `deviceId:tabId`. Non-strict schema — tolerated by older servers. */ @@ -930,7 +931,8 @@ export const FreshAgentSendSchema = z.object({ model: z.string().min(1).optional(), permissionMode: z.string().min(1).optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - effort: z.string().trim().min(1).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), + effort: z.string().trim().min(1).nullable().optional(), }).optional(), images: z.array(z.object({ mediaType: z.string(), @@ -978,7 +980,8 @@ export const FreshAgentConfigureSchema = z.object({ model: z.string().min(1).optional(), permissionMode: z.string().min(1).optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), - effort: z.string().trim().min(1).optional(), + collaborationMode: z.enum(['default', 'plan']).optional(), + effort: z.string().trim().min(1).nullable().optional(), }), }) diff --git a/src/components/fresh-agent/FreshAgentModelDialog.tsx b/src/components/fresh-agent/FreshAgentModelDialog.tsx index e33c776a0..21f123385 100644 --- a/src/components/fresh-agent/FreshAgentModelDialog.tsx +++ b/src/components/fresh-agent/FreshAgentModelDialog.tsx @@ -45,7 +45,7 @@ import { highestThinkingLevelId, orderThinkingLevelIds } from '@shared/fresh-age const MAX_RENDERED_MODEL_ROWS = 250 /** A right-column row: a real thinking level declared by the highlighted - * model, or the single Default row (no variant) for models without levels. */ + * model, plus Codex's native Default or a Default for models without levels. */ type LevelRow = { kind: 'level'; id: string } | { kind: 'default' } type ModelRow = { @@ -53,11 +53,12 @@ type ModelRow = { model: FreshAgentModelCapability } -function levelRowsForModel(model: FreshAgentModelCapability): LevelRow[] { +function levelRowsForModel(model: FreshAgentModelCapability, nativeDefault: boolean): LevelRow[] { // Levels arrive pre-ordered from the server; order defensively client-side. const ordered = orderThinkingLevelIds(model.supportedEffortLevels) if (ordered.length === 0) return [{ kind: 'default' }] - return ordered.map((id) => ({ kind: 'level', id })) + const levels: LevelRow[] = ordered.map((id) => ({ kind: 'level', id })) + return nativeDefault ? [{ kind: 'default' }, ...levels] : levels } function getFocusable(container: HTMLElement): HTMLElement[] { @@ -236,6 +237,9 @@ export function FreshAgentModelDialog({ // use the last chosen level, then their highest available level. const preselectLevelIndex = useCallback((rows: LevelRow[], modelId: string): number => { if (modelId === effectiveModelId) { + if (sessionType === 'freshcodex' && paneContent.effortIsNativeDefault && !effectiveEffort) { + return Math.max(rows.findIndex((row) => row.kind === 'default'), 0) + } const currentIndex = rows.findIndex((row) => row.kind === 'level' && row.id === effectiveEffort) if (currentIndex >= 0) return currentIndex } @@ -247,7 +251,7 @@ export function FreshAgentModelDialog({ const defaultEffort = capabilities?.models.find((model) => model.id === modelId)?.defaultEffort const defaultIndex = rows.findIndex((row) => row.kind === 'level' && row.id === defaultEffort) return defaultIndex >= 0 ? defaultIndex : rows.length - 1 - }, [effectiveEffort, effectiveModelId, lastUsedLevelFor, capabilities]) + }, [effectiveEffort, effectiveModelId, lastUsedLevelFor, capabilities, sessionType, paneContent.effortIsNativeDefault]) // Reset only when opening or changing panes, never when an asynchronous // catalog arrives while the user is typing or choosing a level. @@ -356,7 +360,7 @@ export function FreshAgentModelDialog({ ?? (sessionType === 'freshcodex' && stagedModel ? {key:`catalog:${stagedModel.id}`,model:stagedModel} : undefined) ?? flatModelRows[0], [flatModelRows, highlightedRowKey, effectiveModelId, sessionType, stagedModel]) const highlightedModel = highlightedRow?.model - const levelRows: LevelRow[] = highlightedModel ? levelRowsForModel(highlightedModel) : [] + const levelRows: LevelRow[] = highlightedModel ? levelRowsForModel(highlightedModel, sessionType === 'freshcodex') : [] // The level highlight is user-set only when the highlighted model row is // user-set (selectModelRow stages both together); the derived initial // highlight uses the preselection rule directly. @@ -379,7 +383,7 @@ export function FreshAgentModelDialog({ const selectModelRow = useCallback((row: ModelRow) => { if (sessionType === 'freshcodex') setStagedModel(row.model) setHighlightedRowKey(row.key) - setHighlightedLevelIndex(preselectLevelIndex(levelRowsForModel(row.model), row.model.id)) + setHighlightedLevelIndex(preselectLevelIndex(levelRowsForModel(row.model, sessionType === 'freshcodex'), row.model.id)) setActiveColumn('models') }, [preselectLevelIndex,sessionType]) @@ -431,6 +435,7 @@ export function FreshAgentModelDialog({ ? { modelLabel: { modelId: model.id, label: model.displayName } } : {}), effort: level, + ...(sessionType === 'freshcodex' ? { effortIsNativeDefault: level === undefined } : {}), ...(modelEffortLevels ? { modelEffortLevels } : {}), }, })) @@ -438,12 +443,11 @@ export function FreshAgentModelDialog({ // answers with the freshAgent.session.metadata broadcast that converges // the lower-left chip — and every other device's surfaces — immediately, // instead of the next message's send-time settings carrying it. Pre-create - // panes (no sessionId) stage only, exactly as before. An ABSENT effort key - // is the explicit Default row (opencode clears the variant; the per-send - // providers keep the session's current effort). + // panes (no sessionId) stage only. Codex uses null for explicit Default; + // an omitted partial-request field keeps its previous selection. sendFreshAgentConfigure(paneId, paneContent, { model: model.id, - ...(level ? { effort: level } : {}), + ...(level ? { effort: level } : sessionType === 'freshcodex' ? { effort: null } : {}), }) void dispatch(saveServerSettingsPatch({ freshAgent: { diff --git a/src/components/fresh-agent/FreshAgentSettingsButton.tsx b/src/components/fresh-agent/FreshAgentSettingsButton.tsx index 77debeb56..ec5525d47 100644 --- a/src/components/fresh-agent/FreshAgentSettingsButton.tsx +++ b/src/components/fresh-agent/FreshAgentSettingsButton.tsx @@ -167,7 +167,10 @@ export function FreshAgentSettingsButton({ const permissionModeValue = paneContent.permissionMode ?? descriptor?.defaultPermissionMode ?? '' - const settingsDisabled = paneContent.status === 'running' || paneContent.status === 'compacting' + const settingsDisabled = paneContent.provider !== 'codex' && (paneContent.status === 'running' || paneContent.status === 'compacting') + const scopedSettingDisabled = (knob: 'sandbox' | 'collaborationMode') => settingsDisabled + || settingScopes?.[knob] === 'unsupported' + || Boolean(paneContent.sessionId && settingScopes?.[knob] === 'create-only') const styleValue = normalizeFreshAgentStyle( paneContent.style ?? providerDefaults?.style ?? DEFAULT_FRESH_AGENT_STYLE, ) @@ -184,6 +187,8 @@ export function FreshAgentSettingsButton({ const closeModelDialog = useCallback(() => setModelDialogOpen(false), []) const close = useCallback(() => setOpen(false), []) const persistProviderDefaults = useCallback((defaults: { + sandbox?: FreshAgentPaneContent['sandbox'] + collaborationMode?: FreshAgentPaneContent['collaborationMode'] modelSelection?: { kind: 'exact'; modelId: string } defaultPermissionMode?: string effort?: string @@ -199,6 +204,12 @@ export function FreshAgentSettingsButton({ })) }, [dispatch, paneContent.sessionType]) + const commitCodexSettings = (settings: { sandbox?: FreshAgentPaneContent['sandbox']; collaborationMode?: FreshAgentPaneContent['collaborationMode'] }) => { + dispatch(mergePaneContent({ tabId, paneId, updates: settings })) + sendFreshAgentConfigure(paneId, paneContent, settings) + persistProviderDefaults(settings) + } + // freshopencode + claude providers (freshclaude/kilroy): fetch the live // catalog when the popover opens. freshopencode renders display names and // reports catalog unavailability; claude providers merge the probed rows @@ -482,6 +493,42 @@ export function FreshAgentSettingsButton({ ) ) : null} + {descriptor?.settingsVisibility.sandbox ? ( + + ) : null} + + {descriptor?.settingsVisibility.collaborationMode ? ( + + ) : null} + {permissionModes.length > 0 ? (
: null} {!capabilities ? ( diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index c3fc1084c..ef13c9a1e 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -4124,14 +4124,20 @@ export function FreshAgentView({ const codexPaneSelectedModel = paneContent.modelSelection?.kind === 'exact' ? paneContent.modelSelection.modelId : undefined const codexSettingsConvergenceRef = useRef(undefined) useEffect(() => { - if (paneContent.provider !== 'codex' || !liveSessionKey) { + if (paneContent.provider !== 'codex') { codexSettingsConvergenceRef.current = undefined return } + // Track edits from the original create intent, before a native identity + // exists. Its acknowledgement must not reset those newer local choices. + const pendingIdentity = `creating:${paneContent.createRequestId}` + const identity = liveSessionKey ?? pendingIdentity + const previous = codexSettingsConvergenceRef.current + if (liveSessionKey && previous?.identity === pendingIdentity) previous.identity = liveSessionKey const root = appStore.getState().panes.layouts[tabId] const current = (root ? findPaneContent(root, paneId) : null) ?? paneContentRef.current if (current.kind !== 'fresh-agent' || current.provider !== 'codex') return - const { cursor, updates } = convergeCodexSettings(codexSettingsConvergenceRef.current, liveSessionKey, { + const { cursor, updates } = convergeCodexSettings(previous, identity, { model: current.model ?? (current.modelSelection?.kind === 'exact' ? current.modelSelection.modelId : undefined), effort: current.effort, initialCwd: current.initialCwd, @@ -4139,14 +4145,14 @@ export function FreshAgentView({ permissionMode: current.permissionMode, collaborationMode: current.collaborationMode, effortIsNativeDefault: current.effortIsNativeDefault, - }, { + }, liveSessionKey ? { model: codexStatedModel, effort: codexStatedEffort, initialCwd: codexStatedCwd, sandbox: codexStatedSandbox, permissionMode: codexStatedPermission, collaborationMode: codexStatedMode, - }) + } : {}) codexSettingsConvergenceRef.current = cursor if (Object.keys(updates).length === 0) return dispatch(mergePaneContent({ tabId, paneId, updates: { @@ -4154,7 +4160,7 @@ export function FreshAgentView({ ...(updates.model ? { modelSelection: { kind: 'exact', modelId: updates.model } } : {}), } })) }, [ - appStore, dispatch, paneId, tabId, liveSessionKey, paneContent.provider, + appStore, dispatch, paneId, tabId, liveSessionKey, paneContent.provider, paneContent.createRequestId, paneContent.model, codexPaneSelectedModel, paneContent.effort, paneContent.initialCwd, paneContent.sandbox, paneContent.permissionMode, paneContent.collaborationMode, paneContent.effortIsNativeDefault, codexStatedModel, codexStatedEffort, codexStatedCwd, codexStatedSandbox, codexStatedPermission, codexStatedMode, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index a4f3c35f8..65969ea32 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -3533,6 +3533,7 @@ describe('FreshAgentView', () => { settings: { cwd: '/repo', model: 'gpt-5.3-codex-spark', + collaborationMode: 'default', }, })) }) @@ -6998,7 +6999,7 @@ describe('FreshAgentView', () => { const levelsList = await screen.findByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const levelTexts = Array.from(levelsList.querySelectorAll('[role="option"]')).map((el) => el.textContent) expect(levelTexts.map((text) => text?.replace(/last used|highest|current|●/g, '').trim())).toEqual( - ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + ['Default', 'none', 'low', 'medium', 'high', 'xhigh', 'max'], ) fireEvent.click(screen.getByRole('button', { name: 'Use GPT-5.6 Luna · max' })) From 9fd2c77150921e190d34208eb0c87ed653e1f401 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:50:08 -0700 Subject: [PATCH 49/92] test(codex): align browser fixtures with native settings contracts --- .../specs/fresh-agent-control-rust.spec.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) 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 a9e5fc73e..db202da38 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -422,7 +422,7 @@ async function sendComposerText(page: Page, text: string): Promise { const paneRoot = page.locator('[data-context="fresh-agent"]').last() const composer = paneRoot.getByRole('textbox', { name: 'Chat message input' }) await composer.fill(text) - await paneRoot.getByRole('button', { name: 'Send' }).click() + await paneRoot.getByRole('button', { name: 'Send', exact: true }).click() } /** Capture the pane's durable sessionRef id once it exists. */ @@ -1675,7 +1675,7 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect.poll(() => starts().length).toBe(2) const plan = starts()[1].params expect(plan).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { - mode: 'plan', settings: { model: plan.model, reasoning_effort: plan.effort, developer_instructions: null }, + mode: 'plan', settings: { model: plan.model, reasoning_effort: plan.effort ?? null, developer_instructions: null }, } }) await question.getByRole('button', { name: 'Small change', exact: true }).click() await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 'plan-question', result: { answers: { approach: { answers: ['Small change'] } } } }) @@ -1687,7 +1687,7 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await page.keyboard.press('Escape') await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 6, 'Build the change') expect(starts()[2].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { - mode: 'default', settings: { model: plan.model, reasoning_effort: plan.effort, developer_instructions: null }, + mode: 'default', settings: { model: plan.model, reasoning_effort: plan.effort ?? null, developer_instructions: null }, } }) await page.reload() @@ -2370,10 +2370,24 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }) test('per-send settings alter the turn payload against the Rust server (freshcodex)', async ({ page }) => { - const lane = await bootCodexLane(page) + const lane = await bootCodexLane(page, { modelCatalogPages: [{ data: [ + { id: 'astra-record', model: 'gpt-6-astra', displayName: 'GPT-6 Astra', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }, { reasoningEffort: 'xhigh' }], defaultReasoningEffort: 'xhigh' }, + { id: 'sol-record', model: 'gpt-5.6-sol', displayName: 'GPT-5.6 Sol', + supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }], defaultReasoningEffort: 'high' }, + ], nextCursor: null }] }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - // Turn 1 rides the pane's untouched defaults. + // Omission is native no-override. Deliberately select legacy max so the + // original first-turn xhigh assertion exercises the advertised alias. + await page.getByRole('button', { name: 'Agent settings' }).click() + await page.getByRole('dialog', { name: 'Agent settings' }).getByRole('button', { name: /Change/ }).click() + const initialDialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(initialDialog.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' })).toBeVisible() + await initialDialog.getByRole('textbox', { name: 'Reasoning effort', exact: true }).fill('max') + await initialDialog.getByRole('button', { name: 'Use GPT-6 Astra · max' }).click() + await page.keyboard.press('Escape') + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.effort).toBe('max') await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'codex turn one') const threadId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string expect(threadId, 'the durable codex thread id must be known before turn two').toBeTruthy() @@ -2410,9 +2424,8 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // Ground truth: the fake's recorded-turns file under the lane's isolated // CODEX_HOME (/.codex/fake-turns/.json). Each recorded // turn carries its captured turn/start params additively under `start` — - // turn 2 carries the per-send selection while turn 1 kept the defaults - // (gpt-6-astra / the default 'max' effort, wire-mapped 'xhigh' by - // to_codex_reasoning_effort). + // Turn 1 keeps the explicit max intent, wire-mapped to xhigh only because + // this selected native model advertises xhigh and no distinct max. const turnsPath = path.join(lane.info.homeDir, '.codex', 'fake-turns', `${threadId}.json`) await expect(async () => { const turns = JSON.parse(await fs.readFile(turnsPath, 'utf8')) as any[] From cfbe3f4f72903878bdc1fb775f4d7b9699752536 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:07:16 -0700 Subject: [PATCH 50/92] test(codex): join native compaction before the next idle send --- .../specs/fresh-agent-control-rust.spec.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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 db202da38..120038b00 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1947,12 +1947,16 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }) test('compact: thread/compact/start, never a turn; pane returns usable', async ({ page }) => { - const lane = await bootCodexLane(page) + const compactRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-compact-')) + const notificationLogPath = path.join(compactRoot, 'native-notifications.jsonl') + const lane = await bootCodexLane(page, { appendNativeNotificationLogPath: notificationLogPath }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'codex turn one') // Typed slash gesture → freshAgent.compact → thread/compact/start. + const notificationFrom = readJsonl(notificationLogPath).length + const receivedFrom = (await lane.harness.getReceivedWsMessages()).length const paneRoot = page.locator('[data-context="fresh-agent"]').last() await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('/compact') await paneRoot.getByRole('textbox', { name: 'Chat message input' }).press('Enter') @@ -1963,8 +1967,28 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { 'thread/compact/start op', ) expect(compactOp.params.threadId).toBe('thread-new-1') - // Busy→idle ride the probed notification sequence; the pane settles. + // A prior idle snapshot can still be visible when the compact RPC lands. + // Join this native compaction turn's terminal and its owning Rust + // consumption before the next send is allowed to be an idle start. + let compactTurnId: string | undefined + await expect.poll(() => { + compactTurnId = readJsonl(notificationLogPath).slice(notificationFrom) + .find((row) => row.method === 'turn/started' && row.threadId === 'thread-new-1')?.turnId + return compactTurnId + }).toBeTruthy() + await expect.poll(async () => { + const nativeCompleted = readJsonl(notificationLogPath).slice(notificationFrom).some((row) => + row.method === 'turn/completed' && row.threadId === 'thread-new-1' && row.turnId === compactTurnId) + const frames = (await lane.harness.getReceivedWsMessages() as any[]).slice(receivedFrom) + const settled = frames.findIndex((frame) => frame.type === 'freshAgent.event' && frame.sessionId === 'thread-new-1' + && frame.event?.type === 'freshAgent.transcript.patch' + && frame.event.ops?.some((op: any) => op.op === 'settleTurn' && op.nativeTurnId === compactTurnId)) + const consumed = settled >= 0 && frames.slice(settled + 1).some((frame) => + frame.type === 'freshAgent.event' && frame.sessionId === 'thread-new-1' && frame.event?.type === 'freshAgent.turn.complete') + return nativeCompleted && consumed + }).toBe(true) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(1) // AGENT-04's crucial shape pin: compact minted NO turn. The fixture's // recorded-turns store is the ground truth — after the compact the @@ -1977,12 +2001,15 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // Usable after compact: a follow-up prompt mints exactly the next turn // (two recorded turns -> four display rows). await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'codex post-compact turn') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(2) + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/steer')).toHaveLength(0) } catch (error) { await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + await fs.rm(compactRoot, { recursive: true, force: true }).catch(() => {}) } }) From 53f4d09d3110721c9c3425a10901b86588a50b55 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:45:14 -0700 Subject: [PATCH 51/92] feat(codex): support REST CLI and MCP pane automation --- README.md | 13 +- .../src/claude_rest_lane.rs | 3 +- crates/freshell-freshagent/src/codex.rs | 665 +++++++++--- .../src/codex/turn_state.rs | 59 ++ .../src/codex_rest_lane.rs | 316 ++++++ .../src/codex_rest_lane_tests.rs | 996 ++++++++++++++++++ crates/freshell-freshagent/src/lib.rs | 89 +- crates/freshell-freshagent/src/pane_ops.rs | 2 +- crates/freshell-freshagent/src/rest_lane.rs | 32 +- .../src/rest_lane_create.rs | 213 +++- .../src/rest_lane_http_tests.rs | 203 +++- .../freshell-freshagent/src/terminal_tabs.rs | 59 +- .../src/fresh_agent_proxy_rest.rs | 4 +- crates/freshell-server/src/main.rs | 17 +- .../src/providers/fresh_agent.rs | 8 +- crates/freshell-ws/src/terminal.rs | 4 +- crates/freshell-ws/tests/common/claude_rig.rs | 2 +- .../specs/fresh-agent-rest-codex-rust.spec.ts | 183 ++++ test/unit/cli/action-capabilities.test.ts | 7 +- test/unit/cli/commands.test.ts | 36 +- test/unit/mcp/freshell-tool.test.ts | 49 +- tools/freshell-cli/index.ts | 11 +- tools/freshell-cli/output.ts | 4 +- tools/freshell-mcp/freshell-tool.ts | 19 +- .../action-capabilities.ts | 8 +- .../node-client-runtime/automation-errors.ts | 18 + 26 files changed, 2742 insertions(+), 278 deletions(-) create mode 100644 crates/freshell-freshagent/src/codex_rest_lane.rs create mode 100644 crates/freshell-freshagent/src/codex_rest_lane_tests.rs create mode 100644 test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts create mode 100644 tools/node-client-runtime/automation-errors.ts diff --git a/README.md b/README.md index 97ad3b109..36623bc02 100644 --- a/README.md +++ b/README.md @@ -269,12 +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`. +With fresh agents enabled, the CLI and MCP client can create Freshclaude, +Kilroy, or Freshcodex tabs, split them into panes, send prompts, capture their +conversation as plain text, and wait for an output pattern. These operations +also work without an open browser. Use `agent: "claude"`, `agent: "kilroy"`, or +`agent: "codex"` with MCP `new-tab` and `split-pane`, or `--agent claude`, +`--agent kilroy`, or `--agent codex` with the CLI. Both support resuming a saved +conversation through `resume`; Freshcodex uses its canonical Codex session ID. ### Rust server scope diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index dd0ca08bf..c03d7c821 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -46,7 +46,7 @@ impl ClaudeRestLane { model: request.model, model_selection: request.model_selection, permission_mode: request.permission_mode, - effort: request.effort.map(Some), + effort: request.effort, plugins: request.plugins, session_ref, sandbox: request.sandbox, @@ -70,6 +70,7 @@ impl ClaudeRestLane { session_id, session_ref, cleanup_token: cleanup_ownership_id, + effective_settings: None, }), ClaudeCreateOutcome::Failed { code, diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 447307d31..69773bb4e 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -93,6 +93,62 @@ pub(crate) mod turn_state; use turn_state::{CodexTurnState, DispatchToken, NativeSettlement, StopDisposition}; type SharedCodexTurnState = Arc>; +#[derive(Clone, Debug, PartialEq)] +pub enum CodexCreateOutcome { + Created { + session_id: String, + session_ref: Option, + cleanup_ownership_id: Option, + }, + Failed { + code: String, + message: String, + retryable: Option, + owner: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CodexSendOutcome { + Accepted { + session_id: String, + submitted_turn_id: String, + incarnation_id: String, + }, + Refused { + code: String, + message: String, + retryable: Option, + owner: Option, + }, + Ambiguous { + session_id: String, + request_id: String, + incarnation_id: String, + code: String, + message: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CodexSettlementSource { + NativeNotification, + NativeThreadRead, + ConfirmedOwnedExit, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CodexTurnEvent { + Settled { + session_id: String, + turn_id: Option, + incarnation_id: String, + request_ids: Vec, + outcome: NativeSettlement, + source: CodexSettlementSource, + }, +} + /// Unified agent names (Task 2): the ambient `CODEX_HOME` fallback for the /// durability-driven pending bind when the app-server's own initialize /// result is unavailable (pre-initialize edge). Mirrors @@ -365,6 +421,7 @@ pub struct FreshCodexState { /// `freshell-ws` connection. `freshAgent.created` / `freshAgent.send.accepted` / /// `freshAgent.event` are pushed here so the oracle's capture socket records them. broadcast_tx: Arc>, + turn_events: tokio::sync::broadcast::Sender, /// threadId → live codex session (client + settings + owned sidecar). sessions: Arc>>, child_reads: Arc>>, @@ -839,6 +896,7 @@ impl FreshCodexState { Self { sidecar_launch_context: CodexSidecarLaunchContext::default(), broadcast_tx, + turn_events: tokio::sync::broadcast::channel(256).0, sessions: Arc::new(TokioMutex::new(HashMap::new())), child_reads: Arc::new(TokioMutex::new(HashMap::new())), retained_changed: Arc::new(tokio::sync::watch::channel(0).0), @@ -2167,7 +2225,7 @@ impl FreshCodexState { &self, msg: FreshAgentCreate, provenance: Option, - ) { + ) -> CodexCreateOutcome { let request_id = msg.request_id.clone(); // Unified agent names (Task 2): the pane's pre-durable naming handle // (threaded to both create lanes; `None` = an unnamed pane). @@ -2194,12 +2252,19 @@ impl FreshCodexState { session_type: SESSION_TYPE.to_string(), session_ref: Some(SessionLocator { provider: PROVIDER.to_string(), - session_id: cached.session_id, + session_id: cached.session_id.clone(), }), name_ref, session_name, })); - return; + return CodexCreateOutcome::Created { + session_id: cached.session_id.clone(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: cached.session_id, + }), + cleanup_ownership_id: None, + }; } FreshAgentCreateOutcome::Proceed(guard) => guard, }; @@ -2282,8 +2347,7 @@ impl FreshCodexState { let effective = match CodexEffectiveSettings::resolve(&base, &patch) { Ok(settings) => settings, Err(error) => { - self.fail_create(&request_id, error.code, &error.message); - return; + return self.fail_create(&request_id, error.code, &error.message); } }; let CodexEffectiveSettings { @@ -2313,8 +2377,7 @@ impl FreshCodexState { tracing::warn!(target: "freshell_freshagent::codex", session_id = %resume_session_id, request_id = %request_id, "fresh_agent_create_refused: a live terminal PTY owns this session (Task 13b cross-kind live-guard)"); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } // Task 12 (D8 for fresh agents): claim the per-sessionRef lease BEFORE any // spawn -- exactly one in-flight resume (and one live rollout writer) per @@ -2347,8 +2410,7 @@ impl FreshCodexState { session_id = %resume_session_id, request_id = %request_id, code = err.code(), "fresh_agent_create_refused: the observed fence is half-sent (invalid)"); - self.fail_create(&request_id, err.code(), err.message()); - return; + return self.fail_create(&request_id, err.code(), err.message()); } }; let own_ticket = match self.begin_lane_claim_at( @@ -2419,21 +2481,20 @@ impl FreshCodexState { moved on while the live-runtime adopt committed — the \ create is refused (never a live writer left unowned)" ); - self.fail_create( + return self.fail_create( &request_id, "STALE_CLAIM", "ownership moved on while adopting the live session; refresh and retry", ); - return; } } // D8 (focused-ep1-r4 Finding 1): the adopt re-parks/re-stamps // the CURRENT connection's provenance — recovery plans mint // NEW tabIds, so the incumbent must not keep the old tab's // attribution. - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } Some(ticket) } @@ -2442,9 +2503,9 @@ impl FreshCodexState { // session adopts, spawn nothing. With no coordinator // there is no fence to consult. if self.has_live_session(&resume_session_id).await { - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } None } @@ -2452,9 +2513,9 @@ impl FreshCodexState { // A same-kind live runtime the coordinator names — // adopt it, spawn nothing (the COORDINATED map-hit: // the claim already verified the key's state). - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } crate::ownership_lane::LaneClaim::Refused(outcome) => { tracing::warn!(target: "freshell_freshagent::codex", @@ -2463,8 +2524,15 @@ impl FreshCodexState { "fresh_agent_create_refused: the ownership coordinator refused the \ claim (kata b8ke cross-kind authority)" ); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_with_owner( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + crate::ownership_lane::fresh_agent_owner_fields_from_outcome( + &self.ownership, + &outcome, + ), + ); } }; let mut lease_guard: Option = None; @@ -2488,13 +2556,12 @@ impl FreshCodexState { // Under-lock ADOPT (the V5 TOCTOU window). D8 // (focused-ep1-r4 Finding 1): re-parks/re-stamps the // CURRENT connection's provenance on the incumbent. - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } crate::session_lease::FreshSessionClaim::Held { .. } => { - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, @@ -2514,27 +2581,26 @@ impl FreshCodexState { } tracing::error!(target: "invariant", pid, session_id = %resume_session_id, "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } } } - self.handle_create_resume( - request_id, - resume_session_id, - cwd, - model, - effort, - sandbox, - permission_mode, - collaboration_mode, - lease_guard, - own_ticket, - provenance, - naming_handle, - ) - .await; - return; + return self + .handle_create_resume( + request_id, + resume_session_id, + cwd, + model, + effort, + sandbox, + permission_mode, + collaboration_mode, + lease_guard, + own_ticket, + provenance, + naming_handle, + ) + .await; } // b8ke ext r22 F1: the fresh create is coordinator-owned from BEFORE @@ -2580,9 +2646,9 @@ impl FreshCodexState { // an earlier create for this pane completed (the key is // Aliased{to: canonical}); the idempotent re-drive ADOPTS // the live session, never a second spawn. - self.adopt_live_create(&request_id, &claim_key, provenance.clone()) + return self + .adopt_live_create(&request_id, &claim_key, provenance.clone()) .await; - return; } crate::ownership_lane::LaneClaim::Refused(outcome) => { tracing::warn!(target: "freshell_freshagent::codex", @@ -2591,8 +2657,15 @@ impl FreshCodexState { "fresh_agent_create_refused: the ownership coordinator refused the \ fresh create's provisional claim (kata b8ke ext r22 F1)" ); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_with_owner( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + crate::ownership_lane::fresh_agent_owner_fields_from_outcome( + &self.ownership, + &outcome, + ), + ); } } @@ -2600,8 +2673,7 @@ impl FreshCodexState { let (client, notifs, ownership_id, child) = match self.spawn_sidecar(cwd.as_deref()).await { Ok(parts) => parts, Err(err) => { - self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); - return; + return self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); } }; @@ -2641,8 +2713,11 @@ impl FreshCodexState { let _ = child.start_kill(); reap_owned_codex_sidecars(&ownership_id); crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); - self.fail_create(&request_id, "CODEX_THREAD_START_FAILED", &err.to_string()); - return; + return self.fail_create( + &request_id, + "CODEX_THREAD_START_FAILED", + &err.to_string(), + ); } }; @@ -2711,12 +2786,11 @@ impl FreshCodexState { let _ = child.start_kill(); reap_owned_codex_sidecars(&ownership_id); crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "the minted thread id was claimed by another owner during the create; torn down", ); - return; } } } @@ -2744,7 +2818,7 @@ impl FreshCodexState { None, naming_handle, ) - .await; + .await } /// The resume branch of `handle_create` (FINDING 1 -- CODEX-FIRST triage): spawn a @@ -2790,7 +2864,7 @@ impl FreshCodexState { provenance: Option, // Unified agent names (Task 2): the pane's pre-durable naming handle. naming_handle: Option, - ) { + ) -> CodexCreateOutcome { let _ = &mut own_ticket; // moved to finish_create below on the success path // b8ke focused episode-2 round-2 F4: the claim exists — register the // watchdog machinery NOW, before the spawn + thread/resume awaits (up @@ -2820,12 +2894,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!("codex thread {resume_session_id} not found"), ); - return; } // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state @@ -2867,8 +2940,7 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); - return; + return self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); } }; // Task 12: arm the TTL tree-kill path now that the child + its tag exist. @@ -2907,8 +2979,11 @@ impl FreshCodexState { if is_codex_thread_not_found(&err) { self.mark_thread_dead(&resume_session_id).await; } - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); - return; + return self.fail_create( + &request_id, + "FRESH_AGENT_CREATE_FAILED", + &err.to_string(), + ); } }; // TERM-25: never silently proceed against the wrong thread. `thread/resume` @@ -2929,7 +3004,7 @@ impl FreshCodexState { returned = %started.thread_id, "freshagent.codex.wrong_thread_resume_rejected" ); - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( @@ -2938,7 +3013,6 @@ impl FreshCodexState { started.thread_id, resume_session_id ), ); - return; } let thread_id = resume_session_id; let mut effective = CodexEffectiveSettings { @@ -2989,14 +3063,13 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( "codex thread {thread_id} closed while the resume was in flight; torn down" ), ); - return; } // Kata 1wxv Task 2 (r3): the durable rollout meta is the ONE source of @@ -3041,7 +3114,7 @@ impl FreshCodexState { Some((thread_id.as_str(), claim_dead_state)), naming_handle, ) - .await; + .await } /// Shared tail of `handle_create` and `handle_create_resume`: register the session @@ -3092,7 +3165,7 @@ impl FreshCodexState { // (None = an unnamed pane; the frame then carries the durable // session's own projection when a record exists). naming_handle: Option, - ) { + ) -> CodexCreateOutcome { let _ = &mut own_ticket; // consumed at the commit below on the success path // D8 (focused-ep1-r5 Finding 2): a HOLLOW `Some` (a partially // initialized client's hello — all fields absent) behaves like `None` @@ -3102,6 +3175,7 @@ impl FreshCodexState { // kata b8ke Task 3: the sidecar pid for the commit's owner identity // (captured before `child` moves into the exit watcher). let sidecar_pid = child.id(); + let cleanup_ownership_id = Some(ownership_id.clone()); // Unified agent names (Task 2): capture the app-server's initialized // `codexHome` BEFORE `client` moves into the session registration @@ -3136,9 +3210,9 @@ impl FreshCodexState { // re-parks/re-stamps the CURRENT connection's provenance on the // incumbent (this arm returns immediately, so moving `provenance` // in is safe). - self.adopt_live_create(&request_id, &thread_id, provenance) + return self + .adopt_live_create(&request_id, &thread_id, provenance) .await; - return; } // Legacy `activeTurnByThread` mirror for THIS session (adapter.ts:295) -- set on @@ -3295,14 +3369,13 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( "codex thread {thread_id} closed while the resume was in flight; torn down" ), ); - return; } } @@ -3326,12 +3399,11 @@ impl FreshCodexState { if let Some((claim_id, _)) = claim { self.rollback_session_claim(claim_id).await; } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "session lease revoked during create; torn down", ); - return; } } @@ -3407,12 +3479,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "the session's resume record could not be persisted; torn down", ); - return; } if let Err(outcome) = self.commit_lane_claim_at(&mut own_ticket, &thread_id, &thread_id, sidecar_pid) @@ -3431,12 +3502,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "session ownership changed during create; torn down", ); - return; } // DIAG-01: fresh-agent session lifecycle -- provider/session_id/cwd, @@ -3544,6 +3614,14 @@ impl FreshCodexState { }, None, ); + CodexCreateOutcome::Created { + session_id: thread_id.clone(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: thread_id, + }), + cleanup_ownership_id, + } } /// REVIEW FIX (item 3): legacy's `freshAgent.create.failed` sends `retryable: true` on @@ -3557,28 +3635,53 @@ impl FreshCodexState { /// so this was a real, user-visible gap: every Rust `fail_create` call previously sent /// no `retryable` field at all (serde omits `None`), silently hiding the retry button /// legacy always offers. - fn fail_create(&self, request_id: &str, code: &str, message: &str) { - self.broadcast(&ServerMessage::FreshAgentCreateFailed( - FreshAgentCreateFailed { - owner_kind: None, - owner_generation: None, - owner_epoch: None, - code: code.to_string(), - message: message.to_string(), - request_id: request_id.to_string(), - retryable: Some(true), - }, - )); + fn fail_create(&self, request_id: &str, code: &str, message: &str) -> CodexCreateOutcome { + self.fail_create_with_owner(request_id, code, message, None) + } + + fn fail_create_with_owner( + &self, + request_id: &str, + code: &str, + message: &str, + owner: Option, + ) -> CodexCreateOutcome { + let outcome = CodexCreateOutcome::Failed { + code: code.into(), + message: message.into(), + retryable: Some(true), + owner, + }; + if let CodexCreateOutcome::Failed { + code, + message, + retryable, + owner, + } = &outcome + { + self.broadcast(&ServerMessage::FreshAgentCreateFailed( + FreshAgentCreateFailed { + owner_kind: owner.as_ref().map(|owner| owner.owner_kind.into()), + owner_generation: owner.as_ref().map(|owner| owner.owner_generation), + owner_epoch: owner.as_ref().map(|owner| owner.owner_epoch), + code: code.clone(), + message: message.clone(), + request_id: request_id.into(), + retryable: *retryable, + }, + )); + } + outcome } /// The D8 loser answer (Task 12): reuses the existing create-failed frame with the /// fixed reservation code — NO new protocol fields. - fn fail_create_session_reserved(&self, request_id: &str) { + fn fail_create_session_reserved(&self, request_id: &str) -> CodexCreateOutcome { self.fail_create( request_id, "SESSION_RESERVED", "Another resume for this session is in flight", - ); + ) } /// The HAS-LIVE→ADOPT arm (Task 12, V1): answer a loser's create-with-resume with a @@ -3603,7 +3706,7 @@ impl FreshCodexState { request_id: &str, thread_id: &str, provenance: Option, - ) { + ) -> CodexCreateOutcome { if let Some(p) = provenance.filter(|p| p.is_meaningful()) { // Re-park + snapshot the incumbent's settings in one lock scope // (the refresh write below asserts the parked identity together @@ -3686,6 +3789,14 @@ impl FreshCodexState { name_ref, session_name, })); + CodexCreateOutcome::Created { + session_id: thread_id.into(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: thread_id.into(), + }), + cleanup_ownership_id: None, + } } // ── freshAgent.send (WS) ───────────────────────────────────────────────── @@ -3698,7 +3809,11 @@ impl FreshCodexState { /// `ensureRuntime` lazy-restart invariant, adapter.ts:935-946) -- the ONLY visible effect /// is added latency on this one send; there is no user-facing error frame for a /// self-healed crash. - pub async fn handle_send(&self, msg: FreshAgentSend) { + pub(crate) fn subscribe_turn_events(&self) -> tokio::sync::broadcast::Receiver { + self.turn_events.subscribe() + } + + pub async fn handle_send(&self, msg: FreshAgentSend) -> CodexSendOutcome { let request_id = msg.request_id.clone(); let checkpoint_identity_request = msg.checkpoint_intent.as_ref().and_then(|intent| { request_id @@ -3719,8 +3834,7 @@ impl FreshCodexState { tracing::warn!(target: "freshell_freshagent::codex", session_id = %msg.session_id, code = err.code(), "fresh_agent_send_refused: the observed fence is half-sent (invalid)"); - self.send_error(&request_id, err.code(), err.message()); - return; + return self.refuse_send(&request_id, err.code(), err.message()); } }; @@ -3733,17 +3847,19 @@ impl FreshCodexState { session_id = new_session_id; } Err(EnsureAliveError::NotFound) => { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; + return self.refuse_send( + &request_id, + "SESSION_NOT_FOUND", + "codex session not found", + ); } Err(EnsureAliveError::RespawnFailed(err)) => { - self.send_error(&request_id, "CODEX_RESPAWN_FAILED", &err); - return; + return self.refuse_send(&request_id, "CODEX_RESPAWN_FAILED", &err); } Err(EnsureAliveError::Reserved) => { // Task 13 (D8): another create/attach holds this sessionRef -- the // retryable non-lost error channel; the client re-drive converges. - self.send_error( + let outcome = self.refuse_send( &request_id, "SESSION_RESERVED", "Another resume for this session is in flight", @@ -3753,7 +3869,7 @@ impl FreshCodexState { "SESSION_RESERVED", "Another resume for this session is in flight", ); - return; + return outcome; } } @@ -3804,6 +3920,7 @@ impl FreshCodexState { s.quiet_deadman.clone(), s.exited.clone(), s.checkpoint_probe_cancelled.clone(), + s.sidecar_ownership_id.clone(), ) }) }; @@ -3818,10 +3935,10 @@ impl FreshCodexState { quiet_deadman, exited, checkpoint_probe_cancelled, + incarnation_id, )) = looked_up else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; + return self.refuse_send(&request_id, "SESSION_NOT_FOUND", "codex session not found"); }; // Kata 1wxv Task 2 (r2 serialization discipline): take the session's @@ -3845,8 +3962,7 @@ impl FreshCodexState { .get(&session_id) .map(CodexSession::effective_settings); let Some(stored) = stored else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; + return self.refuse_send(&request_id, "SESSION_NOT_FOUND", "codex session not found"); }; let mut patch = msg.settings.clone().unwrap_or_default(); if patch.cwd.is_none() && stored.cwd.is_none() { @@ -3855,8 +3971,7 @@ impl FreshCodexState { let effective = match CodexEffectiveSettings::resolve(&stored, &patch) { Ok(settings) => settings, Err(error) => { - self.send_error(&request_id, error.code, &error.message); - return; + return self.refuse_send(&request_id, error.code, &error.message); } }; let model = effective.model.clone(); @@ -3880,8 +3995,7 @@ impl FreshCodexState { let wire_effort = match to_codex_reasoning_effort(wire_choice.as_deref()) { Ok(value) => value, Err(error) => { - self.send_error(&request_id, "INVALID_EFFORT", &error.to_string()); - return; + return self.refuse_send(&request_id, "INVALID_EFFORT", &error.to_string()); } }; let native_settings = CodexEffectiveSettings { @@ -3917,13 +4031,11 @@ impl FreshCodexState { None } else { if turns.has_owned_exit() { - self.send_error(&request_id, "SESSION_RESERVED", "The owned Codex runtime exited while this input waited; refresh before sending again"); - return; + return self.refuse_send(&request_id, "SESSION_RESERVED", "The owned Codex runtime exited while this input waited; refresh before sending again"); } let expected = turns.active_turn_id().map(str::to_owned); if expected.is_none() && turns.has_unknown_delivery() { - self.send_error(&request_id, "CODEX_DELIVERY_AMBIGUOUS", "Earlier input has unresolved native delivery; recover or inspect the conversation before sending again"); - return; + return self.refuse_send(&request_id, "CODEX_DELIVERY_AMBIGUOUS", "Earlier input has unresolved native delivery; recover or inspect the conversation before sending again"); } Some(( expected, @@ -3963,8 +4075,7 @@ impl FreshCodexState { .lock() .expect("codex turn state") .reject(dispatch); - self.send_error(&request_id, "SESSION_RESERVED", &message); - return; + return self.refuse_send(&request_id, "SESSION_RESERVED", &message); } } } else { @@ -4007,36 +4118,50 @@ impl FreshCodexState { (started.turn_id, latched_stop) } Err(err) => { - let mut refused = err.is_not_submitted(); - let owed = { + let (refused, fact, owed) = { let mut turns = turn_state.lock().expect("codex turn state"); - if refused { - refused = turns.reject(dispatch); - } else { + let refused = err.is_not_submitted() && turns.reject(dispatch); + if !refused { turns.mark_ambiguous(dispatch); } - turns.has_owed_work() + ( + refused, + turns + .facts() + .iter() + .find(|fact| fact.token == dispatch) + .cloned(), + turns.has_owed_work(), + ) }; - // An owned-exit watcher retains crash-attention authority. if !exited.load(Ordering::SeqCst) { turn_in_flight.store(owed, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); } - let code = if refused { - "CODEX_TURN_START_FAILED" + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?request_id, incarnation_id=%incarnation_id, dispatch=?dispatch, expected_turn_id=?expected_turn_id, refused, error=%err, "freshagent.codex.send_failed"); + if let Some(turn_id) = fact + .as_ref() + .filter(|fact| fact.delivery == turn_state::Delivery::Accepted) + .and_then(|fact| fact.turn_id.clone()) + { + // Observed native admission outweighs a contradictory RPC reply. + (turn_id, StopDisposition::None) + } else if refused { + return self.refuse_send( + &request_id, + "CODEX_TURN_START_FAILED", + &err.to_string(), + ); } else { - "CODEX_DELIVERY_AMBIGUOUS" - }; - let receipt = turn_state - .lock() - .expect("codex turn state") - .facts() - .iter() - .find(|fact| fact.token == dispatch) - .map(|fact| fact.request_id.clone()); - tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?receipt, dispatch=?dispatch, expected_turn_id=?expected_turn_id, refused, error=%err, "freshagent.codex.send_failed"); - self.send_error(&request_id, code, &err.to_string()); - return; + self.send_error(&request_id, "CODEX_DELIVERY_AMBIGUOUS", &err.to_string()); + return CodexSendOutcome::Ambiguous { + session_id, + request_id: request_id.unwrap_or_default(), + incarnation_id, + code: "CODEX_DELIVERY_AMBIGUOUS".into(), + message: err.to_string(), + }; + } } }; self.acknowledge_contextual_answers(&session_id, contextual_answers) @@ -4187,6 +4312,11 @@ impl FreshCodexState { &self.broadcast_tx, ); } + let outcome = CodexSendOutcome::Accepted { + session_id: session_id.clone(), + submitted_turn_id: submitted_turn_id.clone(), + incarnation_id, + }; // Release both serialization locks before Stop RPC work. The consumer // must remain free to observe terminal evidence while Stop is pending. drop(_event_emission); @@ -4215,6 +4345,7 @@ impl FreshCodexState { }), ); } + outcome } fn spawn_checkpoint_identity_probe( @@ -4335,6 +4466,21 @@ impl FreshCodexState { }) } + fn refuse_send( + &self, + request_id: &Option, + code: &str, + message: &str, + ) -> CodexSendOutcome { + self.send_error(request_id, code, message); + CodexSendOutcome::Refused { + code: code.into(), + message: message.into(), + retryable: None, + owner: None, + } + } + fn send_error(&self, request_id: &Option, code: &str, message: &str) { self.broadcast(&ServerMessage::Error(ErrorMsg { provider_preparation_failure: None, @@ -5847,7 +5993,39 @@ impl FreshCodexState { /// unconditional `return true`, adapter.ts:1211-1215) — `ws-handler.ts:3607-3626` always /// sends `success:true`. Never touches a process this session did not itself spawn. pub async fn handle_kill(&self, msg: FreshAgentKill) { - self.handle_stop(msg, CodexStopMode::Close).await; + let _ = self.handle_stop(msg, CodexStopMode::Close, None).await; + } + + pub(crate) async fn abandon_created_runtime( + &self, + session_id: &str, + ownership_id: &str, + ) -> Result<(), crate::rest_lane::RestLaneError> { + let result = self + .handle_stop( + FreshAgentKill { + provider: freshell_protocol::AgentProvider::Codex, + session_type: freshell_protocol::SessionType::Freshcodex, + session_id: session_id.into(), + cwd: None, + observed_epoch: None, + observed_generation: None, + }, + CodexStopMode::Close, + Some(ownership_id), + ) + .await; + match &result { + Ok(()) => tracing::info!( + event = "freshagent.codex.rest_lane.create_abandoned", + session_id, + ownership_id + ), + Err(error) => { + tracing::warn!(event="freshagent.codex.rest_lane.create_abandon_failed", session_id, ownership_id, code=%error.code) + } + } + result } /// Stop only the owned writer so the persisted Codex thread can be resumed. @@ -5871,18 +6049,20 @@ impl FreshCodexState { let mode = CodexStopMode::Recovery { request_id: msg.request_id, }; - self.handle_stop( - FreshAgentKill { - provider: msg.provider, - session_id: msg.session_id, - session_type: msg.session_type, - cwd: None, - observed_epoch: msg.observed_epoch, - observed_generation: msg.observed_generation, - }, - mode, - ) - .await; + let _ = self + .handle_stop( + FreshAgentKill { + provider: msg.provider, + session_id: msg.session_id, + session_type: msg.session_type, + cwd: None, + observed_epoch: msg.observed_epoch, + observed_generation: msg.observed_generation, + }, + mode, + None, + ) + .await; } fn broadcast_stop_result(&self, mode: &CodexStopMode, frame: &ServerMessage) { @@ -5916,9 +6096,42 @@ impl FreshCodexState { } } - async fn handle_stop(&self, msg: FreshAgentKill, mode: CodexStopMode) { + async fn handle_stop( + &self, + msg: FreshAgentKill, + mode: CodexStopMode, + expected_owner: Option<&str>, + ) -> Result<(), crate::rest_lane::RestLaneError> { let session_id = msg.session_id.clone(); + // A create receipt identifies only the incarnation it established. Capture + // its retained authority before any stop claim or awaited durable work. + let expected_stamp = if let Some(expected) = expected_owner { + let sessions = self.sessions.lock().await; + let Some(session) = sessions + .get(&session_id) + .filter(|session| session.sidecar_ownership_id == expected) + else { + return Ok(()); + }; + if self.ownership.is_some() { + let stamp = + crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, &session_id) + .filter(|stamp| { + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() + == Some(session_id.as_str()) + && stamp.owner.pid == session.sidecar_pid + }) + .ok_or_else(codex_cleanup_error)?; + Some(stamp) + } else { + None + } + } else { + None + }; + // b8ke delta review F7: a half-sent observed pair (exactly one of // epoch/generation) is the typed invalid-fence refusal — BEFORE the // durable close and any live-state mutation. The kill never @@ -5941,7 +6154,7 @@ impl FreshCodexState { message: Some(err.message().to_string()), }), ); - return; + return Err(codex_cleanup_error()); } }; @@ -5966,9 +6179,12 @@ impl FreshCodexState { // b8ke e3r3 F7: the granted stop's settlement guard. let mut _stop_settlement: Option = None; if let Some(registry) = self.ownership.as_ref() { - if let Some(stamp) = + let stamp = if expected_owner.is_some() { + expected_stamp + } else { crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, &session_id) - { + }; + if let Some(stamp) = stamp { let claim = crate::ownership_lane::stop_claim_from_stamp(&stamp, stop_fence); let kill_op_id = format!("kill-{}", uuid::Uuid::new_v4()); let stop_outcome = crate::ownership_lane::begin_fresh_agent_stop( @@ -6058,7 +6274,7 @@ impl FreshCodexState { message: Some(refusal_message), }), ); - return; + return Err(codex_cleanup_error()); } } else { // b8ke delta round-3 F1: NO retained stamp — the kill STILL @@ -6098,7 +6314,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } let claim = freshell_ownership::StopClaim { expected_kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, @@ -6198,7 +6414,7 @@ impl FreshCodexState { message: Some(refusal_message), }), ); - return; + return Err(codex_cleanup_error()); } } } @@ -6223,7 +6439,7 @@ impl FreshCodexState { .to_string(), ), })); - return; + return Err(codex_cleanup_error()); } freshell_ownership::OwnershipState::Starting { .. } | freshell_ownership::OwnershipState::Stopping { .. } => { @@ -6246,7 +6462,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } freshell_ownership::OwnershipState::Fenced { .. } => { tracing::warn!(target: "freshell_freshagent::codex", @@ -6269,7 +6485,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } _ => { // Genuinely Vacant (or residue): lane-local cleanup @@ -6282,16 +6498,46 @@ impl FreshCodexState { // Record the sidecar identity before the first teardown await. If the // bounded watcher window cannot prove the complete writer tree dead, // the deferred confirmation keeps this exact identity fenced. - let condemned_ownership_id = self.record_condemned_prior(&session_id).await; + let cleanup_removed = if let Some(expected) = expected_owner { + let mut sessions = self.sessions.lock().await; + if !sessions + .get(&session_id) + .is_some_and(|session| session.sidecar_ownership_id == expected) + { + return Err(codex_cleanup_error()); + } + sessions.remove(&session_id) + } else { + None + }; + let condemned_ownership_id = if let Some(session) = &cleanup_removed { + if let Some(pid) = session.sidecar_pid { + let recorded = crate::session_lease::record_condemned_runtime_identity( + pid, + CODEX_SIDECAR_OWNERSHIP_ENV, + &session.sidecar_ownership_id, + ); + self.condemned_priors + .lock() + .expect("condemned priors lock") + .insert(session_id.clone(), recorded); + Some(session.sidecar_ownership_id.clone()) + } else { + None + } + } else { + self.record_condemned_prior(&session_id).await + }; // Ordinary close retires the pane-ledger row before teardown. Recovery // keeps that durable binding so a new writer can resume this thread. // A failed ordinary close stops before touching the live process. - let close_answer = if matches!(mode, CodexStopMode::Recovery { .. }) { - crate::identity_sink::CloseAnswer::Recorded - } else { - self.retire_closed_row(&session_id).await - }; + let close_answer = + if expected_owner.is_some() || matches!(mode, CodexStopMode::Recovery { .. }) { + crate::identity_sink::CloseAnswer::Recorded + } else { + self.retire_closed_row(&session_id).await + }; if close_answer == crate::identity_sink::CloseAnswer::Failed { // b8ke e3r2 F4 + e3r3 F2/F8: THE LIVENESS VERDICT COMES FIRST — // exactly ONE mutation path follows from it (pre-e3r3 the @@ -6370,13 +6616,22 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } let close_reported_failure = close_answer == crate::identity_sink::CloseAnswer::Persisted; - self.clear_controls(&session_id).await; + if let Some(session) = &cleanup_removed { + self.clear_source_controls(&session_id, &session.projection) + .await; + } else { + self.clear_controls(&session_id).await; + } - let removed = self.sessions.lock().await.remove(&session_id); + let removed = if expected_owner.is_some() { + cleanup_removed + } else { + self.sessions.lock().await.remove(&session_id) + }; if let Some(session) = removed.as_ref() { // Requested teardown ends the probe's wait even when the provider never // emits turn/completed. Signal before closing the client so an in-flight @@ -6538,6 +6793,27 @@ impl FreshCodexState { )); } } + } else if expected_owner.is_some() { + match teardown { + crate::session_handoff::StopResult::Reaped + | crate::session_handoff::StopResult::AlreadyGone => {} + crate::session_handoff::StopResult::NotConfirmed { confirmation } => { + tokio::spawn(async move { + let _ = confirmation.await; + }); + kill_failure = Some(( + "TEARDOWN_NOT_CONFIRMED", + "the owned Codex process tree is not yet confirmed stopped".into(), + )); + } + crate::session_handoff::StopResult::PlatformLimited => { + kill_failure = Some(( + "TEARDOWN_PLATFORM_LIMITED", + "the complete owned Codex process tree could not be verified stopped" + .into(), + )); + } + } } // Explicit kill evicts this session's requestId dedup cache entries (mirrors @@ -6550,6 +6826,7 @@ impl FreshCodexState { .clear_for_session(|record| record.session_id == session_id) .await; + let success = !close_reported_failure && kill_failure.is_none(); self.broadcast_stop_result( &mode, &ServerMessage::FreshAgentKilled(FreshAgentKilled { @@ -6559,11 +6836,16 @@ impl FreshCodexState { // A persisted-despite-error close ends the session (consistent // with the durable close) but the kill visibly fails // (delta-r6-r4, focused-episode-6 round 3 Finding 3). - success: !close_reported_failure && kill_failure.is_none(), + success, code: kill_failure.as_ref().map(|(code, _)| code.to_string()), message: kill_failure.map(|(_, message)| message), }), ); + if success { + Ok(()) + } else { + Err(codex_cleanup_error()) + } } /// The kill-side lane of `retire_closed` (delta-review round 5): @@ -6607,6 +6889,29 @@ impl FreshCodexState { .is_some_and(|s| !s.exited.load(Ordering::SeqCst)) } + pub(crate) async fn created_rest_settings( + &self, + session_id: &str, + expected_owner: Option<&str>, + ) -> Option { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id)?; + if session.exited.load(Ordering::SeqCst) + || expected_owner.is_some_and(|owner| owner != session.sidecar_ownership_id) + { + return None; + } + crate::ownership_lane::current_local_snapshot_owner( + &self.ownership, + &self.ownership_stamps, + PROVIDER, + session_id, + session_id, + session.sidecar_pid, + )?; + Some(session.effective_settings().as_json()) + } + pub(crate) async fn local_snapshot_owner( &self, native_id: &str, @@ -8757,7 +9062,7 @@ impl FreshCodexState { _ => None, }; if let Some(terminal) = terminal { - turns.settle_native(id, terminal); + turns.settle_native_read(id, terminal); } } } @@ -10833,6 +11138,16 @@ fn checkpoint_codex_turn_identity( Some((native_turn_id.to_string(), display_turn_id.to_string())) } +fn codex_cleanup_error() -> crate::rest_lane::RestLaneError { + let mut error = crate::rest_lane::RestLaneError::new( + crate::rest_lane::RestLaneErrorKind::Unavailable, + "CODEX_CREATE_CLEANUP_UNCONFIRMED", + "the exact created Codex runtime could not be confirmed stopped", + ); + error.retryable = Some(true); + error +} + /// Watch an owned sidecar child to completion. Two ways out: /// /// - The child exits ON ITS OWN (crash / unexpected disconnect, never requested): self-heal @@ -11098,6 +11413,16 @@ fn spawn_exit_watcher_tracking_turns( turn_state: Option, retained_state: Option, ) -> tokio::task::JoinHandle { + if let (Some(turns), Some(state)) = (&turn_state, &retained_state) { + turns + .lock() + .expect("codex turn state") + .bind_settlement_stream( + thread_id.clone(), + ownership_id.clone(), + state.turn_events.clone(), + ); + } // wfah: the thread id is fixed by the time the watcher is constructed at // every successful spawn site; enrich the durable record once, here. crate::codex_sidecar_tracking::enrich_record_session_id(&ownership_id, &thread_id); @@ -12683,6 +13008,10 @@ impl crate::model_capabilities::ModelCatalogProbe for LocalCodexCatalogProbe { } } +#[cfg(test)] +#[path = "codex_rest_lane_tests.rs"] +mod rest_lane_tests; + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/freshell-freshagent/src/codex/turn_state.rs b/crates/freshell-freshagent/src/codex/turn_state.rs index 12b692bf5..d0f114433 100644 --- a/crates/freshell-freshagent/src/codex/turn_state.rs +++ b/crates/freshell-freshagent/src/codex/turn_state.rs @@ -1,6 +1,7 @@ //! Session-local dispatch ownership. RPC replies describe delivery; only native //! terminal evidence (or confirmed owned exit) describes the end of work. +use super::{CodexSettlementSource, CodexTurnEvent}; use std::collections::VecDeque; use std::sync::Arc; use tokio::sync::Notify; @@ -58,12 +59,32 @@ pub(crate) struct CodexTurnState { stop: Option, user_stop: Option, owned_exit: bool, + settlement_stream: Option, pub changed: Arc, #[cfg(test)] pub waiting_for_stop: Arc, } +#[derive(Debug)] +struct SettlementStream { + session_id: String, + incarnation_id: String, + sender: tokio::sync::broadcast::Sender, +} + impl CodexTurnState { + pub fn bind_settlement_stream( + &mut self, + session_id: String, + incarnation_id: String, + sender: tokio::sync::broadcast::Sender, + ) { + self.settlement_stream = Some(SettlementStream { + session_id, + incarnation_id, + sender, + }); + } pub fn begin_dispatch(&mut self, request_id: &str) -> DispatchToken { self.next_dispatch = self .next_dispatch @@ -220,7 +241,29 @@ impl CodexTurnState { } pub fn settle_native(&mut self, turn_id: &str, evidence: NativeSettlement) -> bool { + self.settle( + turn_id, + evidence, + if evidence == NativeSettlement::OwnedExit { + CodexSettlementSource::ConfirmedOwnedExit + } else { + CodexSettlementSource::NativeNotification + }, + ) + } + + pub fn settle_native_read(&mut self, turn_id: &str, evidence: NativeSettlement) -> bool { + self.settle(turn_id, evidence, CodexSettlementSource::NativeThreadRead) + } + + fn settle( + &mut self, + turn_id: &str, + evidence: NativeSettlement, + source: CodexSettlementSource, + ) -> bool { let mut settled = false; + let mut receipts = std::collections::BTreeMap::, Vec>::new(); for fact in &mut self.facts { if fact.settlement.is_none() && fact.delivery != Delivery::Refused @@ -228,6 +271,10 @@ impl CodexTurnState { || fact.turn_id.as_deref() == Some(turn_id)) { fact.settlement = Some(evidence); + receipts + .entry(fact.turn_id.clone()) + .or_default() + .push(fact.request_id.clone()); settled = true; } } @@ -242,6 +289,18 @@ impl CodexTurnState { self.user_stop = None; self.changed.notify_waiters(); } + if let Some(stream) = &self.settlement_stream { + for (turn_id, request_ids) in receipts { + let _ = stream.sender.send(CodexTurnEvent::Settled { + session_id: stream.session_id.clone(), + incarnation_id: stream.incarnation_id.clone(), + turn_id, + request_ids, + outcome: evidence, + source, + }); + } + } settled } diff --git a/crates/freshell-freshagent/src/codex_rest_lane.rs b/crates/freshell-freshagent/src/codex_rest_lane.rs new file mode 100644 index 000000000..bceda606d --- /dev/null +++ b/crates/freshell-freshagent/src/codex_rest_lane.rs @@ -0,0 +1,316 @@ +//! REST automation of the existing, ownership-bearing Codex runtime. + +use crate::codex::turn_state::NativeSettlement; +use crate::codex::{ + CodexCreateOutcome, CodexSendOutcome, CodexSnapshotError, CodexTurnEvent, FreshCodexState, +}; +use crate::rest_lane::*; +use freshell_protocol::{ + AgentProvider, FreshAgentCreate, FreshAgentSend, SessionLocator, SessionType, +}; +use std::sync::Arc; +use std::time::Duration; + +pub struct CodexRestLane { + state: FreshCodexState, + enabled: Arc bool + Send + Sync>, +} + +impl CodexRestLane { + pub fn new(state: FreshCodexState, enabled: Arc bool + Send + Sync>) -> Self { + Self { state, enabled } + } +} + +fn codex_identity(provider: &str, session_type: &str) -> Result<(), RestLaneError> { + if provider == "codex" && session_type == "freshcodex" { + return Ok(()); + } + Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "the local Codex lane supports freshcodex sessions", + )) +} + +fn failure( + code: String, + message: String, + retryable: Option, + owner: Option, +) -> RestLaneError { + let kind = match code.as_str() { + "SESSION_RESERVED" | "STALE_CLAIM" | "CODEX_DELIVERY_AMBIGUOUS" => { + RestLaneErrorKind::Conflict + } + "SESSION_NOT_FOUND" => RestLaneErrorKind::NotFound, + code if code.starts_with("INVALID_") => RestLaneErrorKind::BadRequest, + _ => RestLaneErrorKind::Unavailable, + }; + let mut error = RestLaneError::new(kind, code, message); + error.retryable = retryable; + error.owner = owner; + error +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for CodexRestLane { + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + codex_identity(&request.provider, &request.session_type)?; + if let Some(ownership_id) = created.cleanup_token.as_deref() { + self.state + .abandon_created_runtime(&created.session_id, ownership_id) + .await?; + } + Ok(()) + } + + async fn snapshot( + &self, + request: RestLaneSnapshot, + ) -> Result, RestLaneSnapshotError> { + codex_identity(&request.provider, &request.session_type) + .map_err(|_| RestLaneSnapshotError::OwnershipUnavailable)?; + self.state.get_snapshot(&request.session_id, None).await.map(Some).map_err(|error| { + tracing::warn!(event="freshagent.codex.rest_lane.snapshot_failed", session_id=%request.session_id, error=%error); + RestLaneSnapshotError::ManagedUnavailable + }) + } + + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + codex_identity(&request.provider, &request.session_type) + .map_err(|_| RestLaneCaptureError::Unsupported)?; + // A headless turn need not publish text-item events. The existing exact + // snapshot path hydrates native saved history and merges retained live + // fragments, including its finite unavailable/interrupted states. + let snapshot = self.state.get_snapshot(&request.session_id, None).await.map_err(|error| { + tracing::warn!(event="freshagent.codex.rest_lane.capture_failed", session_id=%request.session_id, error=%error); + match error { CodexSnapshotError::NotFound => RestLaneCaptureError::NotFound, _ => RestLaneCaptureError::Unavailable } + })?; + if snapshot["extensions"]["codex"]["ownerKind"] == "vacant" + && snapshot["turns"].as_array().is_some_and(Vec::is_empty) + { + return Err(RestLaneCaptureError::NotFound); + } + let (text, truncated) = freshell_agent_runtime::snapshot_text::render_snapshot_text( + &snapshot, + request.max_bytes.min(256 * 1024), + ); + tracing::info!(event="freshagent.codex.rest_lane.capture", session_id=%request.session_id, bytes=text.len(), truncated); + Ok(RestLaneCaptureResult { + native_session_id: snapshot["threadId"] + .as_str() + .unwrap_or(&request.session_id) + .into(), + session_id: request.session_id, + text, + truncated, + }) + } + + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + codex_identity(&request.provider, &request.session_type)?; + if !(self.enabled)() { + return Err(RestLaneError::new( + RestLaneErrorKind::Forbidden, + "FRESH_AGENT_DISABLED", + "fresh agents are disabled", + )); + } + let outcome = self + .state + .handle_create( + FreshAgentCreate { + provider: Some(AgentProvider::Codex), + session_type: SessionType::Freshcodex, + request_id: request.request_id, + cwd: request.cwd, + model: request.model, + effort: request.effort, + collaboration_mode: request.collaboration_mode, + model_selection: request.model_selection, + sandbox: request.sandbox, + permission_mode: request.permission_mode, + plugins: request.plugins, + session_ref: request.native_session_id.map(|session_id| SessionLocator { + provider: "codex".into(), + session_id, + }), + 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; + match outcome { + CodexCreateOutcome::Created { + session_id, + session_ref, + cleanup_ownership_id, + } => { + let effective_settings = self + .state + .created_rest_settings(&session_id, cleanup_ownership_id.as_deref()) + .await; + if effective_settings.is_none() { + if let Some(owner) = cleanup_ownership_id.as_deref() { + self.state + .abandon_created_runtime(&session_id, owner) + .await?; + } + return Err(failure( + "CODEX_CREATE_CHANGED".into(), + "the owning Codex runtime changed before its pane could be published" + .into(), + Some(true), + None, + )); + } + Ok(RestLaneCreated { + session_id, + session_ref, + cleanup_token: cleanup_ownership_id, + effective_settings, + }) + } + CodexCreateOutcome::Failed { + code, + message, + retryable, + owner, + } => Err(failure(code, message, retryable, owner)), + } + } + + async fn send_agent(&self, request: RestLaneSend) -> Result { + codex_identity(&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, + completed: true, + noop: true, + ..Default::default() + }); + } + let text = request + .text + .strip_suffix(['\r', '\n']) + .unwrap_or(&request.text) + .to_string(); + // Fast native turns can settle before the dispatch future returns. + let mut events = self.state.subscribe_turn_events(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(request.timeout_ms); + match self + .state + .handle_send(FreshAgentSend { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: request.session_id.clone(), + request_id: Some(request.request_id.clone()), + text, + cwd: None, + images: None, + settings: None, + checkpoint_intent: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, + }) + .await + { + CodexSendOutcome::Accepted { + session_id, + submitted_turn_id, + incarnation_id, + } => { + let settled = tokio::time::timeout_at(deadline, async { + loop { + match events.recv().await { + Ok(CodexTurnEvent::Settled { session_id: settled_session, turn_id, incarnation_id: settled_incarnation, request_ids, outcome, source }) + if settled_session == session_id && turn_id.as_deref() == Some(&submitted_turn_id) + && settled_incarnation == incarnation_id && request_ids.iter().any(|id| id == &request.request_id) => { + tracing::info!(event="freshagent.codex.rest_lane.settled", session_id=%session_id, native_turn_id=%submitted_turn_id, incarnation_id=%incarnation_id, request_id=%request.request_id, outcome=?outcome, source=?source); + break Ok(outcome); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(event="freshagent.codex.rest_lane.events_lagged", session_id=%session_id, request_id=%request.request_id, skipped), + Err(tokio::sync::broadcast::error::RecvError::Closed) => break Err(failure("FRESH_AGENT_EVENTS_UNAVAILABLE".into(), "Codex turn events are unavailable".into(), None, None)), + } + } + }).await; + let completed = match settled { + Ok(Ok(NativeSettlement::Completed)) => true, + Err(_) => false, + Ok(Ok(outcome)) => { + let (code, message) = match outcome { + NativeSettlement::Interrupted => ( + "CODEX_TURN_INTERRUPTED", + "the submitted Codex turn was interrupted", + ), + NativeSettlement::Failed => { + ("CODEX_TURN_FAILED", "the submitted Codex turn failed") + } + NativeSettlement::OwnedExit => ( + "FRESH_AGENT_SESSION_ENDED", + "the owned Codex runtime exited before the turn completed", + ), + NativeSettlement::Completed => unreachable!(), + }; + let mut error = failure(code.into(), message.into(), Some(false), None); + error.kind = RestLaneErrorKind::Conflict; + error.session_id = Some(session_id); + error.request_id = Some(request.request_id); + error.incarnation_id = Some(incarnation_id); + error.submitted_turn_id = Some(submitted_turn_id); + return Err(error); + } + Ok(Err(error)) => return Err(error), + }; + tracing::info!(event="freshagent.codex.rest_lane.send", session_id=%session_id, native_turn_id=%submitted_turn_id, incarnation_id=%incarnation_id, request_id=%request.request_id, completed); + Ok(RestLaneSendResult { + session_id, + submitted_turn_id: Some(submitted_turn_id), + completed, + noop: false, + }) + } + CodexSendOutcome::Refused { + code, + message, + retryable, + owner, + } => Err(failure(code, message, retryable, owner)), + CodexSendOutcome::Ambiguous { + session_id, + request_id, + incarnation_id, + code, + message, + } => { + let mut error = failure(code, message, Some(false), None); + error.session_id = Some(session_id); + error.request_id = Some(request_id); + error.incarnation_id = Some(incarnation_id); + Err(error) + } + } + } +} diff --git a/crates/freshell-freshagent/src/codex_rest_lane_tests.rs b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs new file mode 100644 index 000000000..fdcb9f921 --- /dev/null +++ b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs @@ -0,0 +1,996 @@ +use super::*; +use crate::codex_rest_lane::CodexRestLane; +use crate::rest_lane::*; +use axum::http::StatusCode; +use freshell_protocol::{AgentProvider, SessionType}; + +fn http_pane(state: FreshCodexState, id: &str) -> (axum::Router, String) { + let api = crate::FreshAgentState::new( + Arc::new("tok".into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ) + .with_terminal_registry(freshell_terminal::TerminalRegistry::new()); + let (tab, pane) = api.layout.create_tab(None); + api.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":id}})); + api.set_codex_rest_lane(Arc::new(CodexRestLane::new(state, Arc::new(|| true)))) + .unwrap(); + (crate::router(api), pane) +} + +async fn post_http(router: axum::Router, uri: &str, body: Value) -> (StatusCode, Value) { + use tower::util::ServiceExt; + let response = router + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(uri) + .header("x-auth-token", "tok") + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +async fn accepted(frames: &mut tokio::sync::broadcast::Receiver, request: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let value: Value = serde_json::from_str(&frames.recv().await.unwrap()).unwrap(); + if value["type"] == "freshAgent.send.accepted" && value["requestId"] == request { + break; + } + } + }) + .await + .unwrap(); +} + +async fn capture_from_peer( + lane: &CodexRestLane, + request: RestLaneCapture, + peer: &freshell_codex::ChannelPeer, + raw: &Value, +) -> Result { + let mut capturing = Box::pin(lane.capture(request)); + tokio::select! { + result = &mut capturing => result, + (id, method, _) = peer.expect_request() => { + assert_eq!(method, "thread/read"); + peer.respond(&id, raw.clone()); + capturing.await + } + } +} + +async fn channel_session(id: &str) -> (FreshCodexState, freshell_codex::ChannelPeer) { + let (state, _) = super::tests::state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = + CodexAppServerClient::connect_with_timeout(transport, Duration::from_millis(200)); + super::tests::insert_fake_session_with_real_consumer( + &state, + id, + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + super::tests::spawn_sleeper(), + &format!("task009-owner-{id}"), + ) + .await; + (state, peer) +} + +fn send(id: &str, request: &str) -> FreshAgentSend { + FreshAgentSend { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: id.into(), + request_id: Some(request.into()), + text: "original recoverable prompt".into(), + cwd: None, + images: None, + settings: None, + checkpoint_intent: None, + observed_epoch: None, + observed_generation: None, + } +} + +async fn initialize(peer: &freshell_codex::ChannelPeer) { + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&id, json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + assert_eq!(peer.expect_notification().await.0, "initialized"); +} + +async fn typed_send_receipt(response: &str) { + let session = format!("task009-send-{response}"); + let (state, peer) = channel_session(&session).await; + let submitting = { + let state = state.clone(); + let session = session.clone(); + tokio::spawn(async move { state.handle_send(send(&session, "request-9")).await }) + }; + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + assert_eq!(params["input"][0]["text"], "original recoverable prompt"); + match response { + "accepted" => peer.respond(&id, json!({"turn":{"id":"native-9"}})), + "refused" => peer.respond_error(&id, -32602, "not admitted"), + "internal" => peer.respond_error(&id, -32603, "possibly admitted"), + _ => {} + } + let result = submitting.await.unwrap(); + let facts = state + .sessions + .lock() + .await + .get(&session) + .unwrap() + .turn_state + .lock() + .unwrap() + .facts() + .clone(); + drop(peer); + state.shutdown().await; + match (response, result) { + ( + "accepted", + CodexSendOutcome::Accepted { + session_id, + submitted_turn_id, + incarnation_id, + }, + ) => { + assert_eq!( + ( + session_id.as_str(), + submitted_turn_id.as_str(), + incarnation_id.as_str() + ), + ( + session.as_str(), + "native-9", + format!("task009-owner-{session}").as_str() + ) + ); + assert_eq!(facts[0].settlement, None, "acceptance is not settlement"); + } + ("refused", CodexSendOutcome::Refused { code, .. }) => { + assert_eq!(code, "CODEX_TURN_START_FAILED"); + assert_eq!(facts[0].delivery, turn_state::Delivery::Refused); + } + ( + "internal" | "timeout", + CodexSendOutcome::Ambiguous { + session_id, + request_id, + incarnation_id, + code, + .. + }, + ) => { + assert_eq!( + ( + session_id.as_str(), + request_id.as_str(), + incarnation_id.as_str(), + code.as_str() + ), + ( + session.as_str(), + "request-9", + format!("task009-owner-{session}").as_str(), + "CODEX_DELIVERY_AMBIGUOUS" + ) + ); + assert_eq!(facts[0].settlement, None); + assert_eq!(facts[0].delivery, turn_state::Delivery::Ambiguous); + } + (_, other) => { + panic!("{response} must return its actual native delivery receipt: {other:?}") + } + } +} + +#[tokio::test] +async fn task009_rest_lane_typed_acceptance_is_not_settlement() { + typed_send_receipt("accepted").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_preadmission_refusal() { + typed_send_receipt("refused").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_internal_error_stays_ambiguous() { + typed_send_receipt("internal").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_timeout_stays_ambiguous() { + typed_send_receipt("timeout").await; +} + +#[tokio::test] +async fn task009_rest_lane_private_settlement_ignores_idle_and_stop_ack_and_names_actual_evidence() +{ + let (state, peer) = channel_session("task009-events").await; + let mut events = state.subscribe_turn_events(); + let submitting = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_send(send("task009-events", "request-events")) + .await + }) + }; + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"native-events"}})); + let _ = submitting.await.unwrap(); + let interrupt = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_interrupt(FreshAgentInterrupt { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: "task009-events".into(), + request_id: None, + cwd: None, + }) + .await + }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + peer.respond(&id, json!({})); + interrupt.await.unwrap(); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"task009-events","status":{"type":"idle"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"foreign","turn":{"id":"native-events","status":"completed"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-events","turn":{"id":"unrelated","status":"completed"}}), + ); + let premature = tokio::time::timeout(Duration::from_millis(50), events.recv()).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-events","turn":{"id":"native-events","status":"interrupted"}}), + ); + let settled = tokio::time::timeout(Duration::from_secs(1), events.recv()).await; + drop(peer); + state.shutdown().await; + assert!( + premature.is_err(), + "ACK/status/foreign terminal cannot settle: {premature:?}" + ); + assert!( + matches!(settled, Ok(Ok(CodexTurnEvent::Settled { + session_id, turn_id, incarnation_id, request_ids, + outcome: NativeSettlement::Interrupted, source: CodexSettlementSource::NativeNotification, + })) if session_id == "task009-events" && turn_id.as_deref() == Some("native-events") && incarnation_id == "task009-owner-task009-events" && request_ids == ["request-events"]), + "matching native terminal must publish its typed evidence" + ); +} + +#[tokio::test] +async fn task009_rest_lane_typed_create_and_adoption_distinguish_cleanup_authority() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-created","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let request: FreshAgentCreate = serde_json::from_value(json!({ + "requestId":"task009-create", "provider":"codex", "sessionType":"freshcodex", + "model":"provider-current", "effort":"xhigh", "collaborationMode":"plan", + "sandbox":"workspace-write", "permissionMode":"on-request", + })) + .unwrap(); + let created = state.handle_create(request.clone(), None).await; + let mut resume = request; + resume.request_id = "task009-adopt".into(); + resume.session_ref = Some(SessionLocator { + provider: "codex".into(), + session_id: "task009-created".into(), + }); + let adopted = state.handle_create(resume, None).await; + let writers = state.sessions.lock().await.len(); + state.shutdown().await; + assert_eq!(writers, 1); + assert!( + matches!(created, CodexCreateOutcome::Created { session_id, session_ref: Some(reference), cleanup_ownership_id: Some(owner) } if session_id == "task009-created" && reference.session_id == session_id && !owner.is_empty()) + ); + assert!( + matches!(adopted, CodexCreateOutcome::Created { session_id, cleanup_ownership_id: None, .. } if session_id == "task009-created") + ); +} + +#[tokio::test] +async fn task009_rest_lane_http_same_id_steers_wait_for_one_actual_terminal() { + let (state, peer) = channel_session("task009-http-steer").await; + let (router, pane) = http_pane(state.clone(), "task009-http-steer"); + let mut frames = state.broadcast_tx.subscribe(); + let first_router = router.clone(); + let first_uri = format!("/api/panes/{pane}/send-keys"); + let mut first = tokio::spawn(async move { + post_http( + first_router, + &first_uri, + json!({"data":"first\r","requestId":"first","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + assert_eq!(params["input"][0]["text"], "first"); + peer.respond(&id, json!({"turn":{"id":"shared-native-turn"}})); + accepted(&mut frames, "first").await; + let first_early = tokio::time::timeout(Duration::from_millis(30), &mut first) + .await + .ok() + .map(Result::unwrap); + let second_uri = format!("/api/panes/{pane}/send-keys"); + let mut second = tokio::spawn(async move { + post_http( + router, + &second_uri, + json!({"data":"steer\n","requestId":"second","timeout":3}), + ) + .await + }); + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!(params["expectedTurnId"], "shared-native-turn"); + peer.respond(&id, json!({"turnId":"shared-native-turn"})); + accepted(&mut frames, "second").await; + let second_early = tokio::time::timeout(Duration::from_millis(30), &mut second) + .await + .ok() + .map(Result::unwrap); + let early = (first_early.is_some(), second_early.is_some()); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"task009-http-steer","status":{"type":"idle"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-http-steer","turn":{"id":"unrelated","status":"completed"}}), + ); + peer.emit_notification("turn/completed", json!({"threadId":"task009-http-steer","turn":{"id":"shared-native-turn","status":"completed"}})); + let one = match first_early { + Some(value) => value, + None => first.await.unwrap(), + }; + let two = match second_early { + Some(value) => value, + None => second.await.unwrap(), + }; + drop(peer); + state.shutdown().await; + assert_eq!( + early, + (false, false), + "HTTP must remain pending after native admission" + ); + for (status, body) in [one, two] { + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["status"], "ok", "{body}"); + assert_eq!(body["data"]["status"], "idle", "{body}"); + assert_eq!(body["data"]["submittedTurnId"], "shared-native-turn"); + } +} + +#[tokio::test] +async fn task009_rest_lane_http_uncertainty_keeps_native_request_receipt_and_never_replays() { + let (state, peer) = channel_session("task009-http-uncertain").await; + let (router, pane) = http_pane(state.clone(), "task009-http-uncertain"); + let uri = format!("/api/panes/{pane}/send-keys"); + let submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"uncertain prompt\r","requestId":"uncertain-9"}), + ) + .await + }); + initialize(&peer).await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond_error(&id, -32603, "delivery cannot be proven"); + let (status, body) = submitting.await.unwrap(); + let no_replay = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(); + drop(peer); + state.shutdown().await; + assert!(no_replay); + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "CODEX_DELIVERY_AMBIGUOUS"); + assert_eq!(body["retryable"], false); + assert_eq!(body["requestId"], "uncertain-9"); + assert_eq!(body["sessionId"], "task009-http-uncertain"); + assert!(body["incarnationId"] + .as_str() + .is_some_and(|id| !id.is_empty())); + assert!( + body.get("submittedTurnId").is_none(), + "unknown delivery cannot invent a native turn id" + ); +} + +#[tokio::test] +async fn task009_rest_lane_capture_preserves_native_output_and_bounds_utf8_separately() { + let (state, peer) = channel_session("task009-capture").await; + let mut events = state.subscribe_turn_events(); + let submitting = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_send(send("task009-capture", "capture-control")) + .await + }) + }; + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"captured-turn"}})); + submitting.await.unwrap(); + let reading = { + let state = state.clone(); + tokio::spawn(async move { state.get_snapshot("task009-capture", None).await }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + let raw = json!({"thread":{"id":"task009-capture","status":{"type":"idle"},"turns":[{"id":"captured-turn","status":"completed","items":[{"type":"userMessage","id":"u","content":[{"type":"text","text":"éé"}]},{"type":"agentMessage","id":"a","text":"actual retained answer"}]}]}}); + peer.respond(&id, raw.clone()); + let ready = reading.await.unwrap().unwrap(); + let evidence = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(); + let lane = CodexRestLane::new(state.clone(), Arc::new(|| true)); + let request = RestLaneCapture { + session_id: "task009-capture".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + max_bytes: 7, + }; + let short = capture_from_peer(&lane, request.clone(), &peer, &raw).await; + let full = capture_from_peer( + &lane, + RestLaneCapture { + max_bytes: 4096, + ..request + }, + &peer, + &raw, + ) + .await; + drop(peer); + state.shutdown().await; + assert_eq!( + ready["status"], "idle", + "native terminal read established fixture readiness" + ); + assert!(matches!( + evidence, + CodexTurnEvent::Settled { + outcome: NativeSettlement::Completed, + source: CodexSettlementSource::NativeThreadRead, + .. + } + )); + let short = short.unwrap(); + let full = full.unwrap(); + assert_eq!(short.text, "user: "); + assert!(short.truncated); + assert!(!full.truncated); + assert!(full.text.contains("user: éé")); + assert!(full.text.contains("assistant: actual retained answer")); +} + +#[tokio::test] +async fn task009_rest_lane_socket_loss_does_not_settle_until_the_owned_process_exits() { + let (state, peer) = channel_session("task009-owned-exit").await; + let (router, pane) = http_pane(state.clone(), "task009-owned-exit"); + let mut events = state.subscribe_turn_events(); + let mut frames = state.broadcast_tx.subscribe(); + let uri = format!("/api/panes/{pane}/send-keys"); + let mut submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"owned exit","requestId":"owned-exit","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"owned-exit-native"}})); + accepted(&mut frames, "owned-exit").await; + drop(peer); + let early = tokio::time::timeout(Duration::from_millis(50), &mut submitting) + .await + .ok() + .map(Result::unwrap); + let before_exit = early.is_none(); + let no_false_event = events.try_recv().is_err(); + state.shutdown().await; + let (status, body) = match early { + Some(result) => result, + None => submitting.await.unwrap(), + }; + let evidence = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(); + assert!( + before_exit && no_false_event, + "transport loss is not native termination" + ); + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(body["submittedTurnId"], "owned-exit-native"); + assert!(matches!( + evidence, + CodexTurnEvent::Settled { + outcome: NativeSettlement::OwnedExit, + source: CodexSettlementSource::ConfirmedOwnedExit, + .. + } + )); +} + +#[tokio::test] +async fn task009_rest_lane_old_cleanup_receipt_cannot_stop_a_resumed_incarnation() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-replaced","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let request = RestLaneCreate { + request_id: "original-incarnation".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }; + let original = lane.clone().create_agent(request.clone()).await.unwrap(); + state + .handle_recovery_stop(FreshAgentRecoveryStop { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: original.session_id.clone(), + request_id: "owned-stop".into(), + observed_epoch: None, + observed_generation: None, + }) + .await; + let resume = RestLaneCreate { + request_id: "replacement-incarnation".into(), + native_session_id: Some(original.session_id.clone()), + ..request.clone() + }; + let replacement = lane.clone().create_agent(resume.clone()).await.unwrap(); + lane.abandon_create(&request, &original).await.unwrap(); + let survives = state.has_live_session(&replacement.session_id).await; + let sent = lane + .send_agent(RestLaneSend { + provider: "codex".into(), + session_type: "freshcodex".into(), + session_id: replacement.session_id.clone(), + request_id: "replacement-send".into(), + text: "replacement is still the writer\r".into(), + timeout_ms: 2000, + observed_epoch: None, + observed_generation: None, + }) + .await; + let cleanup = lane.abandon_create(&resume, &replacement).await; + let stopped = !state.has_live_session(&replacement.session_id).await; + state.shutdown().await; + assert_ne!(original.cleanup_token, replacement.cleanup_token); + assert!(survives); + assert!(sent.unwrap().completed); + assert!(cleanup.is_ok() && stopped); +} + +#[tokio::test] +async fn task009_rest_lane_http_deadline_returns_only_native_admission_as_approximate() { + let (state, peer) = channel_session("task009-deadline").await; + let (router, pane) = http_pane(state.clone(), "task009-deadline"); + let uri = format!("/api/panes/{pane}/send-keys"); + let submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"not yet terminal\r\n","requestId":"deadline-request","timeout":0}), + ) + .await + }); + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":"deadline-native"}})); + let (status, body) = submitting.await.unwrap(); + drop(peer); + state.shutdown().await; + assert_eq!( + params["input"][0]["text"], "not yet terminal\r", + "strip exactly one terminator" + ); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "approx"); + assert_eq!(body["data"]["status"], "approx"); + assert_eq!(body["data"]["submittedTurnId"], "deadline-native"); +} + +#[tokio::test] +async fn task009_rest_lane_enter_only_is_a_noop_without_creating_a_runtime() { + let (state, _) = super::tests::state_with_bus(); + let lane = CodexRestLane::new(state.clone(), Arc::new(|| true)); + for text in ["\r", "\n", "\r\n", "\n\r\n"] { + let sent = lane + .send_agent(RestLaneSend { + provider: "codex".into(), + session_type: "freshcodex".into(), + session_id: "never-started".into(), + request_id: "enter-only".into(), + text: text.into(), + timeout_ms: 0, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + assert!(sent.noop && sent.completed); + assert!(sent.submitted_turn_id.is_none()); + } + assert!(state.sessions.lock().await.is_empty()); + state.shutdown().await; +} + +#[tokio::test] +async fn task009_rest_lane_headless_capture_hydrates_native_history_without_a_browser() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-headless-history","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let created = lane + .clone() + .create_agent(RestLaneCreate { + request_id: "headless-history".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }) + .await + .unwrap(); + let sent = lane + .send_agent(RestLaneSend { + request_id: "headless-history-send".into(), + session_id: created.session_id.clone(), + provider: "codex".into(), + session_type: "freshcodex".into(), + text: "headless saved history\r".into(), + timeout_ms: 2000, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + let captured = lane + .capture(RestLaneCapture { + session_id: created.session_id.clone(), + provider: "codex".into(), + session_type: "freshcodex".into(), + max_bytes: 4096, + }) + .await; + // Readiness proof follows capture: no UI or earlier GET can mask a cold + // headless capture by hydrating the retained projection first. + let native = state.get_snapshot(&created.session_id, None).await.unwrap(); + state.shutdown().await; + assert!(sent.completed); + assert_eq!(native["turns"].as_array().unwrap().len(), 2); + let captured = captured.unwrap(); + assert!( + captured.text.contains("user: headless saved history"), + "{}", + captured.text + ); + assert!( + captured.text.contains("assistant: Fixture turn"), + "{}", + captured.text + ); +} + +#[tokio::test] +async fn task009_rest_lane_abandon_only_stops_the_runtime_its_create_established() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-cleanup","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let request = RestLaneCreate { + request_id: "cleanup-create".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }; + let created = lane.clone().create_agent(request.clone()).await.unwrap(); + let resume = RestLaneCreate { + request_id: "cleanup-adopt".into(), + native_session_id: Some(created.session_id.clone()), + ..request.clone() + }; + let adopted = lane.clone().create_agent(resume.clone()).await.unwrap(); + lane.abandon_create(&resume, &adopted).await.unwrap(); + let adopted_survives = state.has_live_session(&created.session_id).await; + let stale = RestLaneCreated { + cleanup_token: Some("foreign-incarnation".into()), + ..created.clone() + }; + let stale_cleanup = lane.abandon_create(&request, &stale).await; + let stale_survives = state.has_live_session(&created.session_id).await; + let owned_cleanup = lane.abandon_create(&request, &created).await; + let owned_survives = state.has_live_session(&created.session_id).await; + state.shutdown().await; + assert!(adopted.cleanup_token.is_none()); + assert!( + stale_cleanup.is_ok() && owned_cleanup.is_ok(), + "stale={stale_cleanup:?}, owned={owned_cleanup:?}" + ); + assert!( + adopted_survives && stale_survives, + "adoption or a foreign receipt cannot kill this writer" + ); + assert!( + !owned_survives, + "abandoned create must confirm its owned runtime stopped" + ); +} + +#[tokio::test] +async fn task009_rest_lane_http_startup_stop_waits_for_native_terminal_and_blocks_later_start() { + let (state, peer) = channel_session("task009-startup-stop").await; + let (router, pane) = http_pane(state.clone(), "task009-startup-stop"); + let first_uri = format!("/api/panes/{pane}/send-keys"); + let first_router = router.clone(); + let mut first = tokio::spawn(async move { + post_http( + first_router, + &first_uri, + json!({"data":"startup","requestId":"startup","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + state + .handle_interrupt(FreshAgentInterrupt { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: "task009-startup-stop".into(), + request_id: None, + cwd: None, + }) + .await; + let before_startup = tokio::time::timeout(Duration::from_millis(30), &mut first) + .await + .is_err(); + let mut events = state.subscribe_turn_events(); + peer.respond(&start_id, json!({"turn":{"id":"startup-native"}})); + let (stop_id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "startup-native"); + peer.emit_notification("turn/completed", json!({"threadId":"task009-startup-stop","turn":{"id":"startup-native","status":"interrupted"}})); + let terminal = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .unwrap() + .unwrap(); + let turns = state + .sessions + .lock() + .await + .get("task009-startup-stop") + .unwrap() + .turn_state + .clone(); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let later_uri = format!("/api/panes/{pane}/send-keys"); + let later = tokio::spawn(async move { + post_http( + router, + &later_uri, + json!({"data":"later","requestId":"later","timeout":3}), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + let no_later_start = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(); + peer.respond(&stop_id, json!({})); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":"later-native"}})); + peer.emit_notification("turn/completed", json!({"threadId":"task009-startup-stop","turn":{"id":"later-native","status":"completed"}})); + let first = first.await.unwrap(); + let later = later.await.unwrap(); + drop(peer); + state.shutdown().await; + assert!( + before_startup && no_later_start, + "startup result and unresolved Stop gate native issuance" + ); + assert!(matches!( + terminal, + CodexTurnEvent::Settled { + outcome: NativeSettlement::Interrupted, + source: CodexSettlementSource::NativeNotification, + .. + } + )); + assert_eq!(first.0, StatusCode::CONFLICT, "{:?}", first.1); + assert_eq!(first.1["code"], "CODEX_TURN_INTERRUPTED"); + assert_eq!(later.1["data"]["status"], "idle", "{:?}", later.1); +} + +#[tokio::test] +async fn task009_rest_lane_http_create_refusal_names_the_actual_owner() { + use freshell_ownership::{ + BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind, RuntimeOwnershipRegistry, + }; + let registry = Arc::new(RuntimeOwnershipRegistry::with_epoch(91)); + let BeginOutcome::Granted { generation } = registry.begin_start( + "codex", + "owned-codex", + RuntimeOwnerKind::Terminal, + "terminal-start", + None, + "fixture", + 1, + ) else { + panic!("initial owner granted"); + }; + assert_eq!( + registry.commit_live( + "codex", + "owned-codex", + "terminal-start", + generation, + OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some("owning-terminal".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + CommitOutcome::Committed + ); + let (mut state, _) = super::tests::state_with_bus(); + state.set_ownership(registry); + let (router, _) = http_pane(state.clone(), "owned-codex"); + let (status, body) = post_http( + router, + "/api/tabs", + json!({"agent":"codex","sessionRef":{"provider":"codex","sessionId":"owned-codex"}}), + ) + .await; + state.shutdown().await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!( + json!([ + body["ownerKind"], + body["ownerEpoch"], + body["ownerGeneration"] + ]), + json!(["terminal", 91, generation]) + ); +} + +#[tokio::test] +async fn task009_rest_lane_headless_create_and_resume_publish_the_native_effective_settings() { + let _environment = super::tests::ENV_LOCK.lock().await; + let workspace = tempfile::tempdir().unwrap(); + let native_cwd = workspace.path().join("native-cwd"); + std::fs::create_dir(&native_cwd).unwrap(); + let request_log = workspace.path().join("native-requests.jsonl"); + super::tests::configure_fake_codex_cmd(&json!({ + "recordTurns":true,"uniqueThreadIds":true,"appendRequestLogPath":request_log, + "effectiveThreadSettings":{"model":"native-effective","cwd":native_cwd,"sandbox":"read-only","approvalPolicy":"on-request"} + }).to_string()); + let (state, _) = super::tests::state_with_bus(); + let api = crate::FreshAgentState::new( + Arc::new("tok".into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ) + .with_terminal_registry(freshell_terminal::TerminalRegistry::new()); + api.set_codex_rest_lane(Arc::new(CodexRestLane::new( + state.clone(), + Arc::new(|| true), + ))) + .unwrap(); + api.set_rest_create_settings_reader(Arc::new(|| Box::pin(async { + freshell_protocol::SettingsFreshAgent { default_plugins:vec![], enabled:true, initial_setup_done:None, + providers:json!({"freshcodex":{"modelSelection":{"kind":"exact","modelId":"provider-current"},"effort":"ultra","collaborationMode":"plan","sandbox":"workspace-write","defaultPermissionMode":"on-request"}}) + }.into() + }))).unwrap(); + let router = crate::router(api.clone()); + let (status, created) = post_http( + router.clone(), + "/api/tabs", + json!({"agent":"codex","cwd":workspace.path(),"effort":null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let native = created["data"]["sessionRef"]["sessionId"].as_str().unwrap(); + let ready = state.get_snapshot(native, None).await.unwrap(); + let (resume_status, resumed) = post_http( + router, + "/api/tabs", + json!({"agent":"codex","sessionRef":{"provider":"codex","sessionId":native}}), + ) + .await; + let contents = [&created, &resumed].map(|body| { + api.layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap() + }); + let native_requests = std::fs::read_to_string(request_log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + state.shutdown().await; + assert_eq!( + ready["settings"]["model"], "native-effective", + "the real owning runtime established native-effective settings before layout assertions" + ); + assert_eq!(resume_status, StatusCode::OK, "{resumed}"); + assert_eq!( + native_requests + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1 + ); + assert_eq!( + native_requests + .iter() + .filter(|row| row["method"] == "thread/resume") + .count(), + 0, + "live canonical resume adopts the existing writer" + ); + for content in contents { + assert_eq!( + json!({"model":content["model"],"cwd":content["initialCwd"],"sandbox":content["sandbox"],"permissionMode":content["permissionMode"],"collaborationMode":content["collaborationMode"],"effortIsNativeDefault":content["effortIsNativeDefault"]}), + json!({"model":"native-effective","cwd":native_cwd,"sandbox":"read-only","permissionMode":"on-request","collaborationMode":"plan","effortIsNativeDefault":true}) + ); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 305f71b2b..79b2eef8a 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -59,6 +59,7 @@ pub mod claude_spawn; pub(crate) mod claude_test_holds; pub(crate) mod claude_turns; pub mod codex; +pub mod codex_rest_lane; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; pub mod identity_sink; @@ -2095,6 +2096,7 @@ pub struct FreshAgentState { /// requests never touch this web process's legacy provider manager. hosted_rest: Arc>, local_claude_rest: Arc>, + local_codex_rest: Arc>, rest_create_settings: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, @@ -2442,6 +2444,7 @@ impl FreshAgentState { 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()), + local_codex_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, @@ -2591,15 +2594,22 @@ impl FreshAgentState { } 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 - } + self.hosted_rest_gateway().or_else(|| match provider { + "claude" => self.local_claude_rest.get().cloned(), + "codex" => self.local_codex_rest.get().cloned(), + _ => None, }) } + pub fn set_codex_rest_lane( + &self, + lane: rest_lane::SharedFreshAgentRestLane, + ) -> Result<(), &'static str> { + self.local_codex_rest + .set(lane) + .map_err(|_| "local Codex REST lane is already installed") + } + /// Install a scripted model-catalog probe (tests) — the registry's /// `opencodeCatalogProvider` injection seam /// (`model-capability-registry.ts:196-202`). Rebuilds the registry around @@ -4732,7 +4742,7 @@ pub fn router(state: FreshAgentState) -> Router { .route("/api/panes/{id}", patch(rename_pane)) .route("/api/panes/{id}/send-keys", post(send_keys)) .route("/api/panes/{id}/capture", get(capture)) - .route("/api/panes/{id}/wait-for", get(terminal_tabs::wait_for)) + .route("/api/panes/{id}/wait-for", get(wait_for)) .with_state(state.clone()) .merge(model_capabilities::router(state.clone())) .merge(pane_ops::router(state)) @@ -4799,6 +4809,16 @@ fn rest_lane_failure(error: rest_lane::RestLaneError) -> Response { RestLaneErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR, }; let mut body = json!({ "status": "error", "code": error.code, "message": error.message }); + for (key, value) in [ + ("sessionId", error.session_id), + ("requestId", error.request_id), + ("incarnationId", error.incarnation_id), + ("submittedTurnId", error.submitted_turn_id), + ] { + if let Some(value) = value { + body[key] = json!(value); + } + } if let Some(retryable) = error.retryable { body["retryable"] = json!(retryable); } @@ -5137,7 +5157,7 @@ async fn create_lane_agent_tab( session_type: request.session_type.clone(), cwd: request.cwd.clone(), model: request.model.clone(), - effort: request.effort.clone(), + effort: request.effort.clone().flatten(), durable_id: Some( created .session_ref @@ -7203,6 +7223,59 @@ fn text_plain(body: String) -> Response { .into_response() } +async fn wait_for( + State(state): State, + Path(pane_id): Path, + headers: HeaderMap, + Query(params): Query>, +) -> Response { + if !authorized(&headers, &state.auth_token) { + return fail_json(StatusCode::UNAUTHORIZED, "unauthorized".into()); + } + if pane_entry_for_request(&state, &pane_id).is_none() { + return terminal_tabs::wait_for(State(state), Path(pane_id), headers, Query(params)).await; + } + let (pattern, deadline) = match terminal_tabs::wait_pattern(¶ms) { + Ok(parsed) => parsed, + Err(response) => return response, + }; + loop { + let response = capture( + State(state.clone()), + Path(pane_id.clone()), + headers.clone(), + Query(Default::default()), + ) + .await; + if response.status() != StatusCode::OK { + return response; + } + let bytes = match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(event="agent_api.wait_for.capture_failed", pane_id, error=%error); + return fail_json( + StatusCode::SERVICE_UNAVAILABLE, + "fresh-agent transcript is temporarily unavailable".into(), + ); + } + }; + if pattern + .is_match(&String::from_utf8_lossy(&bytes)) + .unwrap_or(false) + { + return ok_json( + json!({"matched":true,"reason":"pattern"}), + "pattern matched", + ); + } + if std::time::Instant::now() >= deadline { + return approx_json(json!({"matched":false}), "timeout"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + /// Render an opencode message page to plain text: collect every `{type:'text', text}` /// part's text (the transcript's assistant + user turns), joined by newlines. Robust to /// the exact message/part envelope (walks the tree). Falls back to the raw JSON so the diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 6a3b324a1..f7215ecc2 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -241,7 +241,7 @@ pub(crate) async fn split_pane( session_type: session_type.into(), cwd: request.cwd.clone(), model: request.model.clone(), - effort: request.effort.clone(), + effort: request.effort.clone().flatten(), durable_id: Some( created .session_ref diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index 8a4867fe0..cafc4390c 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -6,11 +6,23 @@ 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, + dyn Fn() -> futures_util::future::BoxFuture<'static, RestCreateSettings> + Send + Sync, >; +pub struct RestCreateSettings { + pub fresh_agent: freshell_protocol::SettingsFreshAgent, + pub coding_cli: Option, +} + +impl From for RestCreateSettings { + fn from(fresh_agent: freshell_protocol::SettingsFreshAgent) -> Self { + Self { + fresh_agent, + coding_cli: None, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RestLaneErrorKind { BadRequest, @@ -29,6 +41,10 @@ pub struct RestLaneError { pub retryable: Option, pub retry_after_ms: Option, pub owner: Option, + pub session_id: Option, + pub request_id: Option, + pub incarnation_id: Option, + pub submitted_turn_id: Option, } impl RestLaneError { @@ -44,6 +60,10 @@ impl RestLaneError { retryable: None, retry_after_ms: None, owner: None, + session_id: None, + request_id: None, + incarnation_id: None, + submitted_turn_id: None, } } } @@ -125,7 +145,8 @@ pub struct RestLaneCreate { pub session_type: String, pub cwd: Option, pub model: Option, - pub effort: Option, + pub effort: Option>, + pub collaboration_mode: Option, pub native_session_id: Option, pub plugins: Option>, pub model_selection: Option>, @@ -148,6 +169,9 @@ pub struct RestLaneCreated { /// Provider-owned identity of a runtime newly started by this create. /// An adopted existing writer has no cleanup token. pub cleanup_token: Option, + /// Provider-resolved next-turn choices captured from the same writer. + /// Native thread echoes can differ from the requested launch defaults. + pub effective_settings: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs index cf93f22c9..522c71fd2 100644 --- a/crates/freshell-freshagent/src/rest_lane_create.rs +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -66,8 +66,18 @@ impl FreshAgentState { .map(str::to_string), effort: body .get("effort") - .and_then(Value::as_str) - .map(str::to_string), + .map(|value| { + serde_json::from_value(value.clone()) + .map_err(|_| invalid("effort must be a string or null".into())) + }) + .transpose()?, + collaboration_mode: body + .get("collaborationMode") + .map(|value| { + serde_json::from_value(value.clone()) + .map_err(|_| invalid("collaborationMode must be a string".into())) + }) + .transpose()?, native_session_id, plugins: inputs.plugins, model_selection: inputs.model_selection, @@ -77,29 +87,33 @@ impl FreshAgentState { observed_generation, ..Default::default() }; - if provider == "claude" { + if matches!(provider, "claude" | "codex") { let settings = if let Some(reader) = self.rest_create_settings.get() { reader().await } else { - SettingsFreshAgent { + rest_lane::RestCreateSettings::from(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 provider == "codex" { + resolve_codex_rest_create(&mut request, &settings)?; + } else { + let resolved = crate::claude_create_defaults::resolve_claude_rest_create( + session_type, + body, + &settings.fresh_agent, + ) + .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.map(Some); + 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( @@ -194,7 +208,7 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) for (key, value) in [ ("initialCwd", request.cwd.as_ref()), ("model", request.model.as_ref()), - ("effort", request.effort.as_ref()), + ("effort", request.effort.as_ref().and_then(Option::as_ref)), ("permissionMode", request.permission_mode.as_ref()), ("namingHandle", request.naming_handle.as_ref()), ] { @@ -202,6 +216,14 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) content[key] = json!(value); } } + if request.provider == "codex" { + if request.effort.is_some() { + content["effortIsNativeDefault"] = json!(request.effort == Some(None)); + } + if let Some(mode) = &request.collaboration_mode { + content["collaborationMode"] = json!(mode); + } + } if let Some(value) = &request.plugins { content["plugins"] = json!(value); } @@ -217,5 +239,160 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) if let Some(value) = &created.session_ref { content["sessionRef"] = json!(value); } + if let Some(settings) = &created.effective_settings { + for (from, to) in [ + ("model", "model"), + ("cwd", "initialCwd"), + ("sandbox", "sandbox"), + ("permissionMode", "permissionMode"), + ("collaborationMode", "collaborationMode"), + ] { + if let Some(value) = settings.get(from) { + if value.is_null() { + content.as_object_mut().expect("pane object").remove(to); + } else { + content[to] = value.clone(); + } + } + } + if let Some(effort) = settings.get("effort") { + if effort.is_null() { + content + .as_object_mut() + .expect("pane object") + .remove("effort"); + } else { + content["effort"] = effort.clone(); + } + if request.provider == "codex" { + content["effortIsNativeDefault"] = json!(effort.is_null()); + } + } + } content } + +fn resolve_codex_rest_create( + request: &mut RestLaneCreate, + settings: &rest_lane::RestCreateSettings, +) -> Result<(), RestLaneError> { + use crate::codex::settings::CodexEffectiveSettings; + if request + .effort + .as_ref() + .and_then(Option::as_deref) + .is_some_and(|effort| effort.trim().is_empty()) + { + return Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_EFFORT", + "effort must be a nonempty string or null for native Default", + )); + } + let saved = &settings.fresh_agent.providers["freshcodex"]; + let cli = settings + .coding_cli + .as_ref() + .and_then(|settings| settings.providers.get("codex")); + // A canonical resume inherits its conversation's saved choices in the runtime. + // Provider defaults apply only when creating a new conversation. + if request.native_session_id.is_none() { + let selected_model = match &request.model_selection { + Some(selection) => selection + .as_ref() + .map(|selection| selection.model_id.clone()), + None => saved + .pointer("/modelSelection/modelId") + .and_then(Value::as_str) + .map(str::to_owned), + }; + request.model = request + .model + .take() + .or(selected_model) + .or_else(|| cli.and_then(|settings| settings.model.clone())); + request.effort = request.effort.take().or_else(|| { + Some( + saved + .get("effort") + .and_then(Value::as_str) + .map(str::to_owned), + ) + }); + request.cwd = request + .cwd + .take() + .or_else(|| saved.get("cwd").and_then(Value::as_str).map(str::to_owned)); + request.permission_mode = request + .permission_mode + .take() + .or_else(|| { + saved + .get("defaultPermissionMode") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| cli.and_then(|settings| settings.permission_mode.clone())) + .or_else(|| Some("on-request".into())); + request.collaboration_mode = request + .collaboration_mode + .take() + .or_else(|| { + saved + .get("collaborationMode") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| Some("default".into())); + if request.sandbox.is_none() { + let sandbox = saved + .get("sandbox") + .and_then(Value::as_str) + .or_else(|| cli.and_then(|settings| settings.sandbox.as_deref())); + request.sandbox = sandbox + .map(|value| serde_json::from_value(json!(value))) + .transpose() + .map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_SANDBOX", + "saved Codex file access is invalid", + ) + })?; + } + } + let effective = CodexEffectiveSettings::resolve( + &CodexEffectiveSettings { + model: request.model.clone().unwrap_or_default(), + effort: request.effort.clone().flatten(), + cwd: request.cwd.clone(), + sandbox: None, + permission_mode: None, + collaboration_mode: None, + }, + &freshell_protocol::FreshAgentSendSettings { + sandbox: request.sandbox, + permission_mode: request.permission_mode.clone(), + collaboration_mode: request.collaboration_mode.clone(), + ..Default::default() + }, + ) + .map_err(|error| { + RestLaneError::new(RestLaneErrorKind::BadRequest, error.code, error.message) + })?; + // Keep resume omissions absent; otherwise full defaults would override saved intent. + if request.native_session_id.is_none() || request.model.is_some() { + request.model = Some(effective.model); + } + if request.effort.is_some() { + request.effort = Some(effective.effort); + } + request.permission_mode = effective.permission_mode; + request.sandbox = effective + .sandbox + .map(|sandbox| serde_json::from_value(json!(sandbox)).expect("validated Codex sandbox")); + request.collaboration_mode = effective + .collaboration_mode + .map(|mode| mode.as_str().into()); + Ok(()) +} diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 63095c71d..d4f4b84af 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -24,6 +24,10 @@ impl FreshAgentRestLane for RefusingLane { owner_generation: 7, owner_epoch: 3, }), + session_id: None, + request_id: None, + incarnation_id: None, + submitted_turn_id: None, }) } @@ -177,7 +181,7 @@ async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { "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" }, }), - } }) + }.into() }) })).unwrap(); let router = app(state.clone()); for (agent, model, mode, effort) in [ @@ -196,7 +200,7 @@ async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { .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.effort.as_ref().and_then(Option::as_deref), effort); assert_eq!(request.plugins, Some(vec!["/saved-plugin".into()])); assert_eq!(pane["model"], model); assert_eq!(pane["permissionMode"], mode); @@ -238,6 +242,7 @@ async fn an_explicit_model_override_clears_a_different_saved_exact_selection() { "modelEffortLevels": { "modelId": "saved-model", "levels": [] }, } }), } + .into() }) })) .unwrap(); @@ -319,6 +324,25 @@ async fn hosted_gateway_takes_precedence_over_the_local_claude_lane() { assert!(local.creates.lock().unwrap().is_empty()); } +#[tokio::test] +async fn hosted_gateway_takes_precedence_over_the_local_codex_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_codex_rest_lane(local.clone()).unwrap(); + state.set_hosted_rest_gateway(hosted.clone()).unwrap(); + let (status, body) = post(app(state), "/api/tabs", json!({"agent":"codex"}), 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>, @@ -327,6 +351,181 @@ struct ControlLane { concurrent_replacement: Option<(crate::layout_store::LayoutStore, String, String, Value)>, } +#[tokio::test] +async fn codex_rest_lane_wait_for_reads_the_current_fresh_pane_capture() { + 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":"codex", "sessionType":"freshcodex", + "sessionRef":{"provider":"codex","sessionId":"durable-codex"}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let router = app(state); + let matched = get( + router.clone(), + &format!("/api/panes/{pane}/wait-for?pattern=saved%20conversation&T=0"), + true, + ) + .await; + let absent = get( + router, + &format!("/api/panes/{pane}/wait-for?pattern=absent&T=0"), + true, + ) + .await; + assert_eq!( + (matched.0, absent.0), + (StatusCode::OK, StatusCode::OK), + "{matched:?} {absent:?}" + ); + assert_eq!(matched.1["data"]["matched"], true); + assert_eq!(absent.1["status"], "approx"); + assert_eq!(absent.1["data"]["matched"], false); + assert!(lane + .captures + .lock() + .unwrap() + .iter() + .all(|request| request.session_id == "durable-codex")); +} + +#[tokio::test] +async fn codex_rest_lane_create_resolves_saved_defaults_and_explicit_native_default_once() { + 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(std::sync::atomic::AtomicUsize::new(0)); + let count = reads.clone(); + state + .set_rest_create_settings_reader(Arc::new(move || { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { + freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + initial_setup_done: None, + providers: json!({"freshcodex":{ + "modelSelection":{"kind":"exact","modelId":"provider-current"}, + "effort":"ultra", "cwd":"/work", "sandbox":"workspace-write", + "defaultPermissionMode":"on-request", "collaborationMode":"plan" + }}), + } + .into() + }) + })) + .unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"codex", "effort":null}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let pane = state + .layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!( + json!({ + "model":pane["model"], "effortIsNativeDefault":pane["effortIsNativeDefault"], + "cwd":pane["initialCwd"], "sandbox":pane["sandbox"], + "permissionMode":pane["permissionMode"], "collaborationMode":pane["collaborationMode"], + }), + json!({"model":"provider-current","effortIsNativeDefault":true,"cwd":"/work","sandbox":"workspace-write","permissionMode":"on-request","collaborationMode":"plan"}) + ); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn codex_rest_lane_explicit_model_selection_clear_uses_the_cli_default() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_codex_rest_lane(lane.clone()).unwrap(); + state.set_rest_create_settings_reader(Arc::new(|| Box::pin(async { + RestCreateSettings { + fresh_agent: freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], enabled:true, initial_setup_done:None, + providers:json!({"freshcodex":{"modelSelection":{"kind":"exact","modelId":"saved-picker-model"}}}), + }, + coding_cli:Some(serde_json::from_value(json!({ + "enabledProviders":["codex"], "mcpServer":false, + "providers":{"codex":{"model":"cli-default-model"}}, + })).unwrap()), + } + }))).unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"codex","modelSelection":null}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!( + request.model.as_deref(), + Some("cli-default-model"), + "explicit null must not restore a saved picker selection" + ); + assert_eq!(request.model_selection, Some(None)); + let content = state + .layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["model"], "cli-default-model"); + assert_eq!(content["modelSelection"], Value::Null); +} + +#[tokio::test] +async fn codex_rest_lane_blank_effort_is_not_an_explicit_native_default() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_codex_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let mut outcomes = vec![]; + for effort in ["", " "] { + outcomes.push( + post( + router.clone(), + "/api/tabs", + json!({"agent":"codex","effort":effort}), + true, + ) + .await, + ); + } + assert!( + outcomes + .iter() + .all(|(status, body)| *status == StatusCode::BAD_REQUEST + && body["code"] == "INVALID_EFFORT"), + "{outcomes:?}" + ); + assert!( + lane.creates.lock().unwrap().is_empty(), + "a blank string must not clear a choice or create a runtime" + ); +} + #[async_trait::async_trait] impl FreshAgentRestLane for ControlLane { async fn create_agent( diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 05ba2b84d..aa1099309 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -3937,22 +3937,56 @@ pub(crate) async fn wait_for( ); }; + let (pattern, deadline) = match wait_pattern(¶ms) { + Ok(parsed) => parsed, + Err(response) => return response, + }; + + loop { + let text = registry + .directory() + .into_iter() + .find(|d| d.terminal_id == terminal_id) + .map(|d| d.snapshot) + .unwrap_or_default(); + if pattern.is_match(&text).unwrap_or(false) { + return ok_json( + json!({ "matched": true, "reason": "pattern" }), + "pattern matched", + ); + } + if std::time::Instant::now() >= deadline { + return crate::approx_json(json!({ "matched": false }), "timeout"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} + +#[allow(clippy::result_large_err)] +pub(crate) fn wait_pattern( + params: &std::collections::HashMap, +) -> Result<(fancy_regex::Regex, std::time::Instant), Response> { let raw_pattern = params.get("pattern").or_else(|| params.get("p")); let pattern = match raw_pattern { Some(p) => match fancy_regex::Regex::new(p) { Ok(re) => Some(re), - Err(_) => return fail_json(StatusCode::BAD_REQUEST, "invalid pattern".to_string()), + Err(_) => { + return Err(fail_json( + StatusCode::BAD_REQUEST, + "invalid pattern".to_string(), + )) + } }, None => None, }; if pattern.is_none() { // Slice 1 scope: `stable`/`exit`/`prompt` fallback modes are deferred. - return fail_json( + return Err(fail_json( StatusCode::BAD_REQUEST, "wait-for requires `pattern` in this Rust port slice (stable/exit/prompt \ are deferred -- see docs/plans/2026-07-18-agent-api-mcp-parity-spec.md §8)" .to_string(), - ); + )); } let pattern = pattern.expect("checked above"); @@ -3964,24 +3998,7 @@ pub(crate) async fn wait_for( .unwrap_or(30.0); let deadline = std::time::Instant::now() + std::time::Duration::from_secs_f64(timeout_secs); - loop { - let text = registry - .directory() - .into_iter() - .find(|d| d.terminal_id == terminal_id) - .map(|d| d.snapshot) - .unwrap_or_default(); - if pattern.is_match(&text).unwrap_or(false) { - return ok_json( - json!({ "matched": true, "reason": "pattern" }), - "pattern matched", - ); - } - if std::time::Instant::now() >= deadline { - return crate::approx_json(json!({ "matched": false }), "timeout"); - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } + Ok((pattern, deadline)) } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 7b3b54da9..53fcecce9 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -268,12 +268,12 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { - collaboration_mode: None, + collaboration_mode: request.collaboration_mode, pane_id: request.pane_id, request_id: request.request_id, session_type, cwd: request.cwd, - effort: request.effort.map(Some), + effort: request.effort, legacy_restore_context: None, model: request.model, model_selection: request.model_selection, diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index b176ab6d9..ee9e2476f 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1174,7 +1174,13 @@ async fn main() -> ExitCode { 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() }) + Box::pin(async move { + let settings = settings.read().await; + freshell_freshagent::rest_lane::RestCreateSettings { + fresh_agent: settings.fresh_agent.clone(), + coding_cli: Some(settings.coding_cli.clone()), + } + }) })) .expect("REST create settings reader is installed once"); // The freshopencode WS fresh-agent slice: the post-handshake loop dispatches @@ -2047,6 +2053,15 @@ async fn main() -> ExitCode { ), )) .expect("local Claude REST lane is installed once"); + let codex_rest_enabled = fresh_codex_state.clone(); + fresh_agent_state + .set_codex_rest_lane(Arc::new( + freshell_freshagent::codex_rest_lane::CodexRestLane::new( + fresh_codex_state.clone(), + Arc::new(move || codex_rest_enabled.is_enabled()), + ), + )) + .expect("local Codex 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-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index f111a2b77..74076c4d7 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -479,7 +479,9 @@ impl FreshAgentTransport for HostedTransport { ProviderState::Claude(state) => { state.handle_create(create, None).await; } - ProviderState::Codex(state) => state.handle_create(create, None).await, + ProviderState::Codex(state) => { + state.handle_create(create, None).await; + } ProviderState::Opencode { runtime, .. } => runtime.handle_create(create, None).await, } let session_id = self.wait_created().await?; @@ -603,7 +605,9 @@ impl FreshAgentTransport for HostedTransport { ProviderState::Claude(state) => { state.handle_send(send).await; } - ProviderState::Codex(state) => state.handle_send(send).await, + ProviderState::Codex(state) => { + state.handle_send(send).await; + } ProviderState::Opencode { runtime, .. } => runtime.handle_send(send).await, } match tokio::time::timeout(SEND_ACK_TIMEOUT, async { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 413f9a7d3..4d063a709 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1998,7 +1998,9 @@ async fn handle_client_text( let forward: futures::future::BoxFuture<'static, ()> = match send.provider { freshell_protocol::AgentProvider::Codex => { let fresh_codex = state.fresh_codex.clone(); - Box::pin(async move { fresh_codex.handle_send(send).await }) + Box::pin(async move { + fresh_codex.handle_send(send).await; + }) } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 8f8fcae9c..e8353a0c3 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -1168,7 +1168,7 @@ async fn spawn_rig_server( 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() }) + Box::pin(async move { defaults.read().await.fresh_agent.clone().into() }) })) .expect("install the rig's REST defaults reader"); router = router.merge(freshell_freshagent::router(fresh_agent_state.clone())); diff --git a/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts new file mode 100644 index 000000000..23a2c91b9 --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts @@ -0,0 +1,183 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { test, expect, createFreshE2ePage } 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' +import { ensureMcpServerBuilt, McpStdioClient, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) +const runFile = promisify(execFile) + +function findLeaf(node: any, id: string): any { + if (node?.type === 'leaf') return node.id === id ? node : undefined + return (node?.children ?? []).map((child: any) => findLeaf(child, id)).find(Boolean) +} + +test('REST creates and splits Freshcodex through the browser owning native runtime without moving focus', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-rest-')) + const wrapper = path.join(root, 'codex.mjs') + const requests = path.join(root, 'requests.jsonl') + await fs.writeFile(wrapper, `#!/usr/bin/env node +import { randomUUID } from 'node:crypto' +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ + ...JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR || '{}'), + threadStartThreadId: randomUUID(), +}) +await import(${JSON.stringify(source)}) +`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, echoRequestedModel: true, appendRequestLogPath: requests }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + let context: Awaited>['context'] | undefined + let mcp: McpStdioClient | undefined + try { + const mcpPath = ensureMcpServerBuilt(REPO_ROOT).path + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const headers = { 'x-auth-token': info.token } + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + const controlPrompt = 'Task009 browser native readiness' + await input.fill(controlPrompt) + await pane.getByRole('button', { name: 'Send' }).click() + await expect(pane.getByText('Fixture turn', { exact: true })).toBeVisible({ timeout: 30_000 }) + const nativeRequests = () => fs.readFile(requests, 'utf8').then((text) => text.trim().split('\n').map((line) => JSON.parse(line))) + await expect.poll(async () => (await nativeRequests()).filter((row) => row.method === 'turn/start').length).toBe(1) + const nativeTurn = (await nativeRequests()).find((row) => row.method === 'turn/start') + const sessionId = nativeTurn.params.threadId + expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + await expect.poll(async () => { + const response = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${sessionId}`, { headers }) + const snapshot = await response.json() + return { status: snapshot.status, rows: snapshot.turns?.length } + }, { timeout: 30_000 }).toEqual({ status: 'idle', rows: 2 }) + console.log('TASK009_BROWSER_NATIVE_READY', sessionId) + const originalTab = await harness.getActiveTabId() + const originalPane = await pane.getAttribute('data-pane-id') + await expect.poll(async () => { + const response = await page.request.get(`${info.baseUrl}/api/panes`, { headers }) + return JSON.stringify(await response.json()).includes(originalPane!) + }).toBe(true) + await input.focus() + const createBody = { agent: 'codex', cwd: root, model: 'provider-current', effort: 'xhigh', collaborationMode: 'plan', sandbox: 'workspace-write', permissionMode: 'on-request' } + const created = await page.request.post(`${info.baseUrl}/api/tabs`, { headers, data: createBody }) + const split = await page.request.post(`${info.baseUrl}/api/panes/${originalPane}/split`, { headers, data: { ...createBody, direction: 'vertical' } }) + const createdBody = await created.json() + const splitBody = await split.json() + expect({ create: created.status(), split: split.status() }, JSON.stringify({ create: createdBody, split: splitBody })).toEqual({ create: 200, split: 200 }) + const createdPane = createdBody.data.paneId as string + const createdTab = createdBody.data.tabId as string + const createdRef = createdBody.data.sessionRef + const splitPane = splitBody.data.paneId as string + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + const splitView = page.locator(`[data-context="fresh-agent"][data-pane-id="${splitPane}"]`) + await expect(splitView.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled({ timeout: 30_000 }) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await expect.poll(() => harness.getActiveTabId()).toBe(originalTab) + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${originalPane}"]`).getByRole('textbox', { name: 'Chat message input' })).toBeFocused() + expect(await page.evaluate(() => document.activeElement?.closest('[data-pane-id]')?.getAttribute('data-pane-id'))).toBe(originalPane) + + const sent = await page.request.post(`${info.baseUrl}/api/panes/${createdPane}/send-keys`, { headers, data: { data: 'REST native Task009\r', requestId: 'browser-rest-send' } }) + const sentBody = await sent.json() + expect(sent.ok(), JSON.stringify(sentBody)).toBe(true) + expect(sentBody.data.status).toBe('idle') + expect(sentBody.data.submittedTurnId).toBe('turn-1') + const capture = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/capture`, { headers }) + expect(capture.ok()).toBe(true) + expect(await capture.text()).toContain('user: REST native Task009') + expect(await capture.text()).toContain('assistant: Fixture turn') + const bounded = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/capture?maxBytes=10`, { headers }) + expect(Buffer.byteLength(await bounded.text())).toBeLessThanOrEqual(10) + const waiting = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/wait-for?pattern=Fixture%20turn&T=0`, { headers }) + expect((await waiting.json()).data.matched).toBe(true) + const absent = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/wait-for?pattern=not-present-Task009&T=0`, { headers }) + expect(await absent.json()).toMatchObject({ status: 'approx', data: { matched: false } }) + + mcp = new McpStdioClient({ command: process.execPath, args: [mcpPath], env: { ...process.env, FRESHELL_URL: info.baseUrl, FRESHELL_TOKEN: info.token }, requestTimeoutMs: 60_000 }) + await mcp.initialize() + const mcpCreated = await mcp.callFreshellAction('new-tab', { agent: 'codex', cwd: root, model: 'provider-current', effort: 'xhigh', prompt: 'MCP native Task009' }) + expect(mcpCreated.status, JSON.stringify(mcpCreated)).toBe('ok') + expect(mcpCreated.data.sessionRef.provider).toBe('codex') + expect(await mcp.callFreshellAction('capture-pane', { target: mcpCreated.data.paneId })).toContain('user: MCP native Task009') + const startsBeforeCli = (await nativeRequests()).filter((row) => row.method === 'thread/start').length + const cli = await runFile(process.execPath, [path.join(REPO_ROOT, 'dist/tools/freshell-cli/index.js'), 'new-tab', '--agent', 'codex', '--resume', mcpCreated.data.sessionRef.sessionId, '--prompt', 'CLI native Task009'], { + env: { ...process.env, FRESHELL_URL: info.baseUrl, FRESHELL_TOKEN: info.token }, encoding: 'utf8', timeout: 60_000, + }) + const cliCreated = JSON.parse(cli.stdout) + expect(cliCreated.status, cli.stdout).toBe('ok') + expect(cliCreated.data.sessionRef).toEqual(mcpCreated.data.sessionRef) + expect((await nativeRequests()).filter((row) => row.method === 'thread/start')).toHaveLength(startsBeforeCli) + expect((await nativeRequests()).filter((row) => row.method === 'thread/resume')).toHaveLength(0) + expect(await mcp.callFreshellAction('capture-pane', { target: cliCreated.data.paneId })).toContain('user: CLI native Task009') + expect(await mcp.callFreshellAction('wait-for', { target: cliCreated.data.paneId, pattern: 'CLI native Task009', timeout: 0 })).toMatchObject({ status: 'ok', data: { matched: true } }) + const turnsBeforeNoop = (await nativeRequests()).filter((row) => row.method === 'turn/start').length + expect(await mcp.callFreshellAction('send-keys', { target: splitPane, keys: '\r', literal: true })).toMatchObject({ status: 'ok', data: { status: 'noop' } }) + expect((await nativeRequests()).filter((row) => row.method === 'turn/start')).toHaveLength(turnsBeforeNoop) + + const savedName = 'Task009 saved Codex' + const renamed = await mcp.callFreshellAction('rename-pane', { target: createdPane, name: savedName, nameIntent: 'user' }) + expect(renamed.status, JSON.stringify(renamed)).toBe('ok') + const readName = async () => { + const response = await page.request.post(`${info.baseUrl}/api/session-names/read`, { headers, data: { refs: [{ kind: 'session', provider: 'codex', sessionId: createdRef.sessionId }] } }) + return (await response.json()).names[0]?.record + } + const observedName = await readName() + console.log('TASK009_NAME_EVIDENCE', JSON.stringify({ + sessionId: createdRef.sessionId, renameStatus: renamed.status, + renameNameRef: renamed.data?.nameRef, + readNameRef: observedName?.nameRef, name: observedName?.name, source: observedName?.source, + })) + await expect.poll(readName, { timeout: 30_000 }).toMatchObject({ name: savedName, source: 'manual' }) + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + const startsBeforeReload = (await nativeRequests()).filter((row) => row.method === 'thread/start').length + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + await mcp.callFreshellAction('select-tab', { target: createdTab }) + const restored = page.locator(`[data-context="fresh-agent"][data-pane-id="${createdPane}"]`) + await expect(restored.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled({ timeout: 30_000 }) + await expect(restored).toContainText('REST native Task009') + await expect(restored).toContainText('Fixture turn') + await expect(page.locator(`[data-context="tab"][data-tab-id="${createdTab}"]`)).toContainText(savedName) + expect(await readName()).toMatchObject({ name: savedName, source: 'manual' }) + expect(await mcp.callFreshellAction('capture-pane', { target: createdPane })).toContain('user: REST native Task009') + expect((await nativeRequests()).filter((row) => row.method === 'thread/start')).toHaveLength(startsBeforeReload) + const snapshot = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${createdRef.sessionId}`, { headers }) + expect((await snapshot.json()).settings).toMatchObject({ model: 'provider-current', effort: 'xhigh', collaborationMode: 'plan', sandbox: 'workspace-write', permissionMode: 'on-request', cwd: root }) + const prompts = (await nativeRequests()).filter((row) => row.method === 'turn/start').map((row) => row.params.input.map((part: any) => part.text ?? '').join('')) + for (const prompt of [controlPrompt, 'REST native Task009', 'MCP native Task009', 'CLI native Task009']) expect(prompts.filter((value) => value === prompt)).toHaveLength(1) + } finally { + await mcp?.close().catch(() => {}) + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts index b5d00757d..a75b45aee 100644 --- a/test/unit/cli/action-capabilities.test.ts +++ b/test/unit/cli/action-capabilities.test.ts @@ -75,9 +75,11 @@ describe('standalone CLI capability contract', () => { it.each([ ['new-tab', 'claude'], ['new-tab', 'kilroy'], + ['new-tab', 'codex'], ['split-pane', 'claude'], ['split-pane', 'kilroy'], - ])('%s sends %s fresh-agent options and canonical Claude resume identity', async (action, agent) => { + ['split-pane', 'codex'], + ])('%s sends %s fresh-agent options and canonical resume identity', async (action, agent) => { const bodies: Array<{ url: string; body: Record }> = [] const server = createServer(async (request, response) => { response.setHeader('content-type', 'application/json') @@ -106,7 +108,7 @@ describe('standalone CLI capability contract', () => { 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' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, }, }) expect(bodies[0].body).not.toHaveProperty('resumeSessionId') @@ -120,7 +122,6 @@ describe('standalone CLI capability contract', () => { ['run', 'echo', 'blocked'], ['fresh-send', 'blocked'], ['attach', '--terminal', 'term-1'], - ['new-tab', '--agent', 'codex'], ['split-pane', '--agent', 'opencode'], ['split-pane', '--model', 'model'], ['split-pane', '--effort', 'high'], diff --git a/test/unit/cli/commands.test.ts b/test/unit/cli/commands.test.ts index 4577b06a4..b453876a5 100644 --- a/test/unit/cli/commands.test.ts +++ b/test/unit/cli/commands.test.ts @@ -16,10 +16,39 @@ const require = createRequire(import.meta.url) const cliPath = resolve(process.cwd(), 'tools/freshell-cli/index.ts') const tsxLoader = pathToFileURL(require.resolve('tsx')).href +describe('Freshcodex creation through the real CLI', () => { + it('keeps an uncertain send receipt without replaying or exposing unrelated error payloads', async () => { + const receipt = { status: 'error', code: 'CODEX_DELIVERY_AMBIGUOUS', message: 'delivery is uncertain', retryable: false, requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation', answer: 'do-not-log-this-answer' } + const result = await invokeCli(['new-tab', '--agent', 'codex', '--prompt', 'recoverable prompt'], { sendError: receipt }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(result.requests.map((request) => request.url)).toEqual(['/api/tabs', '/api/panes/p1/send-keys']) + const diagnostics = result.stderr.trim().split('\n').map((line) => JSON.parse(line)) + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_DELIVERY_AMBIGUOUS', retryable: false, requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation' })) + expect(result.stderr).not.toContain('do-not-log-this-answer') + }) + it('promotes canonical resume identity and sends the initial prompt separately', async () => { + const result = await invokeCli(['new-tab', '--agent', 'codex', '--resume', '550e8400-e29b-41d4-a716-446655440000', '--model', 'provider-current', '--effort', 'xhigh', '--prompt', 'initial Codex prompt']) + expect(result.code, result.stderr).toBe(0) + expect(result.requests).toEqual([ + { url: '/api/tabs', body: expect.objectContaining({ agent: 'codex', model: 'provider-current', effort: 'xhigh', sessionRef: { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' } }) }, + { url: '/api/panes/p1/send-keys', body: { data: 'initial Codex prompt\r' } }, + ]) + expect(result.requests[0].body).not.toHaveProperty('resumeSessionId') + }) + + it.each(['new-tab', 'split-pane'])('%s still refuses terminal-mode raw Codex resume before HTTP', async (action) => { + const result = await invokeCli([action, '--mode', 'codex', '--resume', 'raw-codex-id']) + expect(result.code).toBe(1) + expect(result.requests).toHaveLength(0) + expect(result.stderr).toContain('Restore requires sessionRef') + }) +}) + /** Spawns the real CLI (retained-flags.test.ts pattern) against a stub HTTP * server and records every resulting HTTP call. `outcome` (name-reliability * Task 4) is the rename outcome the stub's accepted update carries. */ -async function invokeCli(args: string[], opts: { outcome?: unknown } = {}) { +async function invokeCli(args: string[], opts: { outcome?: unknown; sendError?: Record } = {}) { const requests: Array<{ url: string; body: any }> = [] const server = createServer(async (request, response) => { const chunks: Buffer[] = [] @@ -27,7 +56,10 @@ async function invokeCli(args: string[], opts: { outcome?: unknown } = {}) { const text = Buffer.concat(chunks).toString() requests.push({ url: request.url ?? '', body: text ? JSON.parse(text) : undefined }) response.setHeader('content-type', 'application/json') - if (request.method === 'GET' && request.url === '/api/tabs') { + if (request.method === 'POST' && request.url?.endsWith('/send-keys') && opts.sendError) { + response.statusCode = 409 + response.end(JSON.stringify(opts.sendError)) + } else if (request.method === 'GET' && request.url === '/api/tabs') { response.end(JSON.stringify({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' })) } else if (request.method === 'GET' && request.url === '/api/panes?tabId=t1') { response.end(JSON.stringify({ panes: [{ id: 'p1', index: 0, kind: 'terminal' }] })) diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index 804acdfed..bafb6a23c 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -79,7 +79,6 @@ describe('Rust capability rejection wall', () => { ['run', { command: 'echo blocked' }], ['fresh-send', { sessionId: 's', sessionType: 't', provider: 'codex', text: 'blocked' }], ['attach', { target: 'p', terminalId: 't' }], - ['new-tab', { agent: 'codex' }], ['split-pane', { agent: 'opencode' }], ['split-pane', { model: 'model' }], ['split-pane', { effort: 'high' }], @@ -127,17 +126,41 @@ 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) => { + it('preserves uncertain native delivery identity and never replays the rejected HTTP operation', async () => { + 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 }] } })) + const receipt = { code: 'CODEX_DELIVERY_AMBIGUOUS', requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation', retryable: false, answer: 'do-not-return-this-answer' } + mockClient.post.mockRejectedValue(Object.assign(new Error('delivery is uncertain'), { status: 409, details: receipt })) + const result = await executeAction('send-keys', { target: 'pmain', keys: 'recoverable prompt', literal: true }) + expect(result).toMatchObject({ code: receipt.code, requestId: receipt.requestId, sessionId: receipt.sessionId, incarnationId: receipt.incarnationId, retryable: false }) + expect(mockClient.post).toHaveBeenCalledTimes(1) + expect(JSON.stringify(result)).not.toContain('do-not-return-this-answer') + }) + it.each(['new-tab', 'split-pane'])('%s creates fresh Codex with canonical identity and exact settings', async (action) => { + 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({ status: 'ok', data: { tabId: 't1', paneId: 'p1' } }) + const sessionRef = { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' } + const result = await executeAction(action, { ...(action === 'split-pane' ? { target: 'pmain' } : {}), agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', sessionRef }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith(action === 'new-tab' ? '/api/tabs' : '/api/panes/pmain/split', expect.objectContaining({ + agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', sessionRef, + })) + }) + + it.each(['claude', 'kilroy', 'codex'])('new-tab supports agent %s and maps resume sugar to canonical 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' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : '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) => { + it.each(['claude', 'kilroy', 'codex'])('split-pane supports agent %s and maps resume sugar to canonical 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 }] } })) @@ -146,7 +169,7 @@ describe('executeAction -- tab actions', () => { 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' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, })) }) @@ -381,19 +404,7 @@ describe('executeAction -- tab actions', () => { })) }) - it('new-tab with agent: codex is locally rejected by the Rust capability contract', async () => { - mockClient.post.mockResolvedValue({ id: 't1' }) - - const result = await executeAction('new-tab', { - agent: 'codex', - resume: 'thread-pre-durable', - }) - - 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 accepts the resumeSessionId alias', async (agent) => { + it.each(['claude', 'kilroy', 'codex'])('new-tab with agent %s accepts the resumeSessionId alias', async (agent) => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { @@ -403,7 +414,7 @@ describe('executeAction -- tab actions', () => { expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ agent, - sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, })) expect(mockClient.post.mock.calls[0][1]).not.toHaveProperty('resumeSessionId') }) diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index 5bc2a2141..7a6822fef 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -10,6 +10,7 @@ import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { freshAgentResumeProvider, renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' import { renameNotAppliedMessage } from '../node-client-runtime/rename-outcome.js' +import { automationErrorReceipt } from '../node-client-runtime/automation-errors.js' type Flags = Record @@ -160,8 +161,8 @@ function resolveSessionRefFlag(mode: unknown, raw: unknown): { rejected: boolean // `--resume ` is promoted to the canonical `sessionRef {provider: mode, // sessionId}` before it hits the wire — exactly like the MCP freshell tool // (the MCP freshell tool's new-tab action) — so the CLI never sends the legacy -// `resumeSessionId` field. An explicit `--session-ref` wins; codex is -// rejected earlier by rejectRawCodexResume. Without a mode there is no +// `resumeSessionId` field. An explicit `--session-ref` wins. Raw Codex terminal +// resume is rejected earlier by rejectRawCodexResume. Without a mode or agent there is no // provider to promote with, so the flag combination is refused loudly rather // than silently dropping the resume identity. function promoteResumeFlag( @@ -463,7 +464,7 @@ async function main() { 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 + if (rejectRawCodexResume(mode, resumeSessionId)) return if (sessionRefResult.rejected) return const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return @@ -585,7 +586,7 @@ async function main() { const resumeSessionId = getFlag(flags, 'resume') as string | undefined const resumeProvider = mode ?? freshAgentResumeProvider(agent) const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) - if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return + if (rejectRawCodexResume(mode, resumeSessionId)) return if (sessionRefResult.rejected) return const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return @@ -991,7 +992,7 @@ async function main() { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((err) => { - writeError(err) + writeError(err, automationErrorReceipt(err)) process.exitCode = 1 }) } diff --git a/tools/freshell-cli/output.ts b/tools/freshell-cli/output.ts index 81d52d65d..1fa5417e7 100644 --- a/tools/freshell-cli/output.ts +++ b/tools/freshell-cli/output.ts @@ -11,9 +11,9 @@ export function writeJson(data: unknown, pretty = true) { writeText(payload) } -export function writeError(err: unknown) { +export function writeError(err: unknown, receipt?: Record) { const message = err instanceof Error ? err.message : String(err) // stderr is intentionally machine-readable JSONL. CLI command results still // own stdout, so diagnostics never corrupt piping/JSON output there. - process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'cli.error', message })}\n`) + process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'cli.error', message, ...receipt })}\n`) } diff --git a/tools/freshell-mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts index a563d7550..bd2cf49a8 100644 --- a/tools/freshell-mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -10,6 +10,7 @@ import { createApiClient, resolveConfig, type ApiClient } from './http-client.js import { translateKeys } from '../node-client-runtime/keys.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { renameNotAppliedMessage } from '../node-client-runtime/rename-outcome.js' +import { automationErrorReceipt } from '../node-client-runtime/automation-errors.js' import { ACTION_ALIASES, freshAgentResumeProvider, @@ -59,7 +60,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Fresh agents (in-app) -- 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: +- Use new-tab with agent="claude", "kilroy", "codex", or "opencode", optional model=, effort=, cwd=. Use split-pane with agent="claude", "kilroy", or "codex" for a fresh pane beside an existing one. Fresh Codex resume uses sessionRef: { provider: "codex", sessionId }; the resume shorthand promotes to that identity. 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 @@ -450,9 +451,13 @@ export async function executeAction( if (err instanceof MissingParamError) { return { error: err.message, hint: `Run action 'help' to see required parameters for '${action}'.` } } + const receipt = automationErrorReceipt(err) return { error: `Action '${action}' failed: ${err.message || err}`, - hint: 'Check that the Freshell server is running and FRESHELL_URL/FRESHELL_TOKEN are set correctly.', + hint: receipt?.code === 'CODEX_DELIVERY_AMBIGUOUS' + ? 'Delivery is uncertain; inspect the conversation before sending again.' + : 'Check that the Freshell server is running and FRESHELL_URL/FRESHELL_TOKEN are set correctly.', + ...receipt, } } } @@ -475,11 +480,11 @@ async function routeAction( const legacyResume = typeof resume === 'string' ? resume : resumeSessionId // Provider the resume sugar keys on: an explicit mode wins; otherwise the // fresh-agent shorthand maps Claude/Kilroy to Claude identity and keeps - // the existing OpenCode promotion and raw-Codex refusal. + // the provider's canonical identity. Raw terminal Codex ids remain refused. const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) - const codexResumeError = rejectRawCodexResume(resumeProvider, legacyResume, explicitSessionRef) + const codexResumeError = rejectRawCodexResume(mode, legacyResume, explicitSessionRef) if (codexResumeError) return codexResumeError - const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof legacyResume === 'string' + const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && typeof legacyResume === 'string' ? { provider: resumeProvider, sessionId: legacyResume } : undefined) const nameIntent = resolveNameIntent(params) @@ -548,9 +553,9 @@ async function routeAction( const paneId = resolved.pane.id const { direction, browser, editor, mode, shell, cwd, target: _t, resume, sessionRef, ...rest } = params || {} const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) - const codexResumeError = rejectRawCodexResume(resumeProvider, resume, sessionRef) + const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) if (codexResumeError) return codexResumeError - const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof resume === 'string' + const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && typeof resume === 'string' ? { provider: resumeProvider, sessionId: resume } : undefined) return c.post(`/api/panes/${encodeURIComponent(paneId)}/split`, { diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index 9b74b78fc..ccd9fb8fd 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -132,12 +132,12 @@ export function unsupportedInvocationResult( const unsupported = unsupportedActionResult(action) if (unsupported) return unsupported const canonical = resolveCanonicalAction(action) - 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 === 'new-tab' && invocation?.agent !== undefined && !['claude', 'kilroy', 'codex', 'opencode'].includes(String(invocation.agent))) { + return { error: 'This fresh-agent provider is unavailable with the Rust Freshell server.', hint: 'Use agent claude, kilroy, codex, or opencode.' } } 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.' } + && !['claude', 'kilroy', 'codex'].includes(String(invocation?.agent))) { + return { error: 'Fresh-agent splits require agent claude, kilroy, or codex.', 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' && ( diff --git a/tools/node-client-runtime/automation-errors.ts b/tools/node-client-runtime/automation-errors.ts new file mode 100644 index 000000000..0fe34812c --- /dev/null +++ b/tools/node-client-runtime/automation-errors.ts @@ -0,0 +1,18 @@ +/** Preserve the typed automation receipt without forwarding provider payloads. */ +export function automationErrorReceipt(error: unknown): Record | undefined { + if (!error || typeof error !== 'object' || !('details' in error)) return undefined + const details = error.details + if (!details || typeof details !== 'object' || Array.isArray(details)) return undefined + const body = details as Record + if (typeof body.code !== 'string') return undefined + const receipt: Record = { code: body.code } + for (const key of ['sessionId', 'requestId', 'incarnationId', 'submittedTurnId', 'ownerKind']) { + if (typeof body[key] === 'string' && body[key].length > 0) receipt[key] = body[key] + } + if (!receipt.incarnationId && !receipt.ownerKind) return undefined + if (typeof body.retryable === 'boolean') receipt.retryable = body.retryable + for (const key of ['ownerEpoch', 'ownerGeneration', 'retryAfterMs']) { + if (typeof body[key] === 'number' && Number.isFinite(body[key])) receipt[key] = body[key] + } + return receipt +} From d6559c6771d14972f2c22ba7c80740589a86d415 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:55:46 -0700 Subject: [PATCH 52/92] test(codex): verify automation naming with native metadata ordering --- .../tests/codex_rest_automation.rs | 383 ++++++++++++++++++ .../specs/fresh-agent-rest-codex-rust.spec.ts | 25 +- 2 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 crates/freshell-server/tests/codex_rest_automation.rs diff --git a/crates/freshell-server/tests/codex_rest_automation.rs b/crates/freshell-server/tests/codex_rest_automation.rs new file mode 100644 index 000000000..0384fd65e --- /dev/null +++ b/crates/freshell-server/tests/codex_rest_automation.rs @@ -0,0 +1,383 @@ +#![cfg(unix)] + +//! Headless REST automation uses the owning Codex runtime and shared name store. +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{ + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +use tokio_tungstenite::tungstenite::Message; + +const TOKEN: &str = "codex-rest-owned-fixture"; + +struct OwnedServer(Child); +impl Drop for OwnedServer { + fn drop(&mut self) { + unsafe { + libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); + } + let end = Instant::now() + Duration::from_secs(10); + while Instant::now() < end { + if self.0.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn find_file(root: &Path, name: &str) -> Option { + for entry in std::fs::read_dir(root).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_file(&path, name) { + return Some(found); + } + } else if entry.file_name() == name { + return Some(path); + } + } + None +} + +#[tokio::test] +async fn task009_codex_rest_saved_name_binds_to_the_actual_persisted_native_thread() { + assert_saved_name(false, false).await; +} + +#[tokio::test] +async fn task009_codex_rest_name_survives_a_connected_ws_control_runtime() { + assert_saved_name(true, false).await; +} + +#[tokio::test] +async fn task009_codex_rest_name_survives_native_hydration_during_dispatch() { + assert_saved_name(false, true).await; +} + +async fn assert_saved_name(with_ws: bool, poll_during_send: bool) { + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path(); + std::fs::create_dir_all(home.path().join(".freshell")).unwrap(); + std::fs::write( + home.path().join(".freshell/config.json"), + json!({"settings":{ + "freshAgent":{"enabled":true},"codingCli":{"enabledProviders":["codex"]},"network":{"configured":true,"host":"127.0.0.1"} + }}) + .to_string(), + ) + .unwrap(); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); + let wrapper = workspace.path().join("codex.mjs"); + std::fs::write(&wrapper, format!(r#"#!/usr/bin/env node +import {{ randomUUID }} from 'node:crypto' +import path from 'node:path' +const threadId=randomUUID() +const now=new Date() +const rollout=path.join(process.env.CODEX_HOME,'sessions',String(now.getUTCFullYear()),String(now.getUTCMonth()+1).padStart(2,'0'),String(now.getUTCDate()).padStart(2,'0'),`rollout-${{threadId}}.jsonl`) +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify({{ + ...JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR),threadStartThreadId:threadId, + writeRolloutOnMethods:{{'turn/start':{{path:rollout,record:{{type:'session_meta',payload:{{id:threadId,cwd:process.cwd()}}}}}}}}, +}}) +await import({}) +"#,json!(fixture))).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let log = std::fs::File::create(home.path().join("server.log")).unwrap(); + let mut server = OwnedServer( + Command::new( + std::env::var_os("FRESHELL_SERVER_BIN") + .expect("use the strict run-focused-rust.py --build-server route"), + ) + .current_dir(home.path()) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", TOKEN) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home.path()) + .env("FRESHELL_HOME", home.path()) + .env("CODEX_HOME", home.path().join(".codex")) + .env("CODEX_CMD", &wrapper) + .env("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1") + .env( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + json!({"recordTurns":true,"echoRequestedModel":true}).to_string(), + ) + .env_remove("FRESHELL_MANAGED_RUNTIME_V1") + .env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .unwrap(), + ); + let base = format!("http://127.0.0.1:{port}"); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let end = Instant::now() + Duration::from_secs(20); + loop { + if http + .get(format!("{base}/api/health")) + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + break; + } + assert!( + server.0.try_wait().unwrap().is_none(), + "owned server exited" + ); + assert!(Instant::now() < end, "owned server did not start"); + tokio::time::sleep(Duration::from_millis(30)).await; + } + let mut control_socket = None; + if with_ws { + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")) + .await + .unwrap(); + socket.send(Message::Text(json!({"type":"hello","protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"token":TOKEN}).to_string())).await.unwrap(); + async fn frame( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + wanted: &str, + ) -> Value { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Message::Text(raw) = socket.next().await.unwrap().unwrap() { + let value: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!(value["type"], "freshAgent.create.failed", "{value}"); + if value["type"] == wanted { + break value; + } + } + } + }) + .await + .unwrap() + } + frame(&mut socket, "ready").await; + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"control-create","provider":"codex","sessionType":"freshcodex","cwd":project,"namingHandle":"nh-ws-control"}).to_string())).await.unwrap(); + let control = frame(&mut socket, "freshAgent.created").await; + socket.send(Message::Text(json!({"type":"freshAgent.send","requestId":"control-send","provider":"codex","sessionType":"freshcodex","sessionId":control["sessionId"],"text":"normal native control"}).to_string())).await.unwrap(); + frame(&mut socket, "freshAgent.send.accepted").await; + let end = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{}", + control["sessionId"].as_str().unwrap() + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["status"] == "idle" + && snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!(Instant::now() < end, "control turn must settle normally"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + control_socket = Some(socket); + } + let created: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","cwd":project,"model":"provider-current","effort":"xhigh","collaborationMode":"plan","sandbox":"workspace-write","permissionMode":"on-request"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let thread = created["data"]["sessionRef"]["sessionId"].as_str().unwrap(); + uuid::Uuid::parse_str(thread).unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + let send_http = http.clone(); + let send_url = format!("{base}/api/panes/{pane}/send-keys"); + let sending = tokio::spawn(async move { + send_http.post(send_url).header("x-auth-token",TOKEN) + .json(&json!({"data":"persist native naming metadata\r","requestId":"task009-name-send","timeout":3})) + .send().await.unwrap().error_for_status().unwrap().json::().await.unwrap() + }); + if poll_during_send { + let end = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{thread}" + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!(Instant::now() < end, "native turn must become readable"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + let sent = sending.await.unwrap(); + assert_eq!(sent["data"]["status"], "idle", "{sent}"); + let metadata_deadline = Instant::now() + Duration::from_secs(5); + let rollout = loop { + if let Some(path) = find_file( + &home.path().join(".codex/sessions"), + &format!("rollout-{thread}.jsonl"), + ) { + break path; + } + assert!( + Instant::now() < metadata_deadline, + "the executable native fake must persist its actual rollout" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + }; + let persisted = std::fs::read_to_string(&rollout).unwrap(); + let meta: Value = serde_json::from_str(persisted.lines().next().unwrap()).unwrap(); + assert_eq!(meta["type"], "session_meta"); + assert_eq!(meta["payload"]["id"], thread); + assert_eq!(meta["payload"]["cwd"], project.to_str().unwrap()); + let snapshot_deadline = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{thread}" + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["status"] == "idle" + && snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!( + Instant::now() < snapshot_deadline, + "the owning runtime must consume normal completion: {snapshot}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // Other background writers and canonical live adoption must not detach + // this conversation's pending-to-durable naming binding. + let split: Value = http + .post(format!("{base}/api/panes/{pane}/split")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","direction":"vertical","cwd":project})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_ne!(split["data"]["sessionRef"], created["data"]["sessionRef"]); + let other: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","cwd":project})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let adopted: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","sessionRef":other["data"]["sessionRef"]})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(adopted["data"]["sessionRef"], other["data"]["sessionRef"]); + let renamed: Value = http + .patch(format!("{base}/api/panes/{pane}")) + .header("x-auth-token", TOKEN) + .json(&json!({"name":"Task009 saved Codex","nameIntent":"user"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let end = Instant::now() + Duration::from_secs(5); + loop { + let names: Value = http + .post(format!("{base}/api/session-names/read")) + .header("x-auth-token", TOKEN) + .json(&json!({"refs":[{"kind":"session","provider":"codex","sessionId":thread}]})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let record = &names["names"][0]["record"]; + if record["ref"] == json!({"kind":"session","provider":"codex","sessionId":thread}) + && record["name"] == "Task009 saved Codex" + && record["source"] == "manual" + { + break; + } + let saved_names = find_file(home.path(), "session-names.json") + .map(|path| std::fs::read_to_string(path).unwrap()); + assert!(Instant::now() row.method === 'turn/start')).toHaveLength(turnsBeforeNoop) const savedName = 'Task009 saved Codex' + const namingContent = findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content + const sessionsRoot = path.join(info.homeDir, '.codex', 'sessions') + const nativeFile = (await fs.readdir(sessionsRoot, { recursive: true })) + .find((name) => name.endsWith(`rollout-${createdRef.sessionId}.jsonl`)) + const nativeMeta = nativeFile ? JSON.parse((await fs.readFile(path.join(sessionsRoot, nativeFile), 'utf8')).split('\n')[0]) : undefined + const nameDocument = JSON.parse(await fs.readFile(path.join(info.homeDir, '.freshell', 'session-names.json'), 'utf8')) + console.log('TASK009_NAME_BINDING_CONTEXT', JSON.stringify({ + sessionId: createdRef.sessionId, + pane: { sessionId: namingContent?.sessionId, sessionRef: namingContent?.sessionRef, nameRef: namingContent?.nameRef, namingHandle: namingContent?.namingHandle }, + nativeMeta: { type: nativeMeta?.type, id: nativeMeta?.payload?.id, cwd: nativeMeta?.payload?.cwd }, + records: Object.values(nameDocument.records).map((record: any) => ({ ref: record.ref, name: record.name, source: record.source })), + redirects: Object.values(nameDocument.redirects), + })) + expect(nativeMeta?.type).toBe('session_meta') + expect(nativeMeta?.payload?.id).toBe(createdRef.sessionId) + expect(namingContent?.sessionRef).toEqual(createdRef) const renamed = await mcp.callFreshellAction('rename-pane', { target: createdPane, name: savedName, nameIntent: 'user' }) expect(renamed.status, JSON.stringify(renamed)).toBe('ok') const readName = async () => { @@ -152,7 +173,7 @@ await import(${JSON.stringify(source)}) console.log('TASK009_NAME_EVIDENCE', JSON.stringify({ sessionId: createdRef.sessionId, renameStatus: renamed.status, renameNameRef: renamed.data?.nameRef, - readNameRef: observedName?.nameRef, name: observedName?.name, source: observedName?.source, + readNameRef: observedName?.ref, name: observedName?.name, source: observedName?.source, })) await expect.poll(readName, { timeout: 30_000 }).toMatchObject({ name: savedName, source: 'manual' }) await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) From 57b62d90c7f4b9121e180272f5aa35ca29afdf5c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:52:23 -0700 Subject: [PATCH 53/92] fix(testing): join guards that exit during procfs reads --- scripts/testing/sandbox-owner-guard.py | 2 +- test/unit/tooling/sandbox-test.sandbox.ts | 31 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/testing/sandbox-owner-guard.py b/scripts/testing/sandbox-owner-guard.py index a74432907..874193f97 100644 --- a/scripts/testing/sandbox-owner-guard.py +++ b/scripts/testing/sandbox-owner-guard.py @@ -14,7 +14,7 @@ def identity(pid): try: fields = Path(f"/proc/{pid}/stat").read_text().rpartition(") ")[2].split() return None if fields[0] == "Z" else fields[19] - except FileNotFoundError: + except (FileNotFoundError, ProcessLookupError): return None diff --git a/test/unit/tooling/sandbox-test.sandbox.ts b/test/unit/tooling/sandbox-test.sandbox.ts index 81d07d1b1..f30d79313 100644 --- a/test/unit/tooling/sandbox-test.sandbox.ts +++ b/test/unit/tooling/sandbox-test.sandbox.ts @@ -185,6 +185,37 @@ with (root / 'receipt.jsonl').open('a') as log: } describe('sandbox wrapper owned launch settlement', () => { + it('joins a completed guard when procfs reports ESRCH during its exit', () => { + const f = fixture('published-success') + try { + const result = spawnSync('python3', ['-c', String.raw` +import errno, json, runpy, sys +from pathlib import Path +from unittest.mock import patch +guard = runpy.run_path(sys.argv[1]) +control = Path(sys.argv[2]) +(control / 'owner-guard.json').write_text(json.dumps({'pid': 194708, 'identity': 'owned-birth'})) +original_read = Path.read_text +def read_during_exit(self, *args, **kwargs): + if str(self) == '/proc/194708/stat': + raise ProcessLookupError(errno.ESRCH, 'Process exited during procfs read') + return original_read(self, *args, **kwargs) +results = [] +with patch.object(Path, 'read_text', read_during_exit): + for code in [0, 1]: + (control / 'owner-guard.done').write_text(json.dumps({'code': code})) + results.append(guard['join'](control)) + (control / 'owner-guard.done').unlink() + results.append(guard['join'](control)) +print(json.dumps(results)) +`, path.resolve('scripts/testing/sandbox-owner-guard.py'), path.join(f.root, 'control')], { + encoding: 'utf8', timeout: 10_000, + }) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([0, 1, 1]) + } finally { f.cleanup() } + }) + it.each(['published', 'published-success'])('fails when the independent guard reports an error before %s completion', mode => { const f = fixture(mode) try { From d9614a847815b697271a932566180d76cc5e28f2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:50:31 -0700 Subject: [PATCH 54/92] fix(freshcodex): synchronize hosted native conversation names --- .../freshell-agent-runtime/src/host_actor.rs | 56 +++- .../src/host_actor_tests.rs | 115 ++++++- .../src/snapshot_projection.rs | 7 + .../src/snapshot_projection_tests.rs | 23 ++ crates/freshell-freshagent/src/codex.rs | 107 ++++++- .../src/codex/projection.rs | 25 ++ crates/freshell-freshagent/src/naming.rs | 23 +- crates/freshell-runtime-client/src/lib.rs | 33 ++ .../src/fresh_agent_ops.rs | 54 +++- crates/freshell-runtime-protocol/src/lib.rs | 12 + .../freshell-server/src/fresh_agent_proxy.rs | 170 +++++++++-- .../src/fresh_agent_proxy_naming.rs | 192 ++++++++++++ .../src/fresh_agent_proxy_rest.rs | 2 +- .../src/fresh_agent_proxy_tests.rs | 163 +++++++++- crates/freshell-server/src/main.rs | 6 + .../src/session_name_native.rs | 35 ++- crates/freshell-session-host/src/main.rs | 24 ++ .../src/providers/cli.rs | 50 +-- .../src/providers/codex.rs | 48 +++ .../providers/deterministic_fresh_agent.rs | 2 + .../src/providers/fresh_agent.rs | 284 +++++++++++++++++- .../src/providers/mod.rs | 1 + crates/freshell-supervisor/src/backend.rs | 3 + .../src/fresh_agent_configure_tests.rs | 193 +++++++++++- crates/freshell-supervisor/src/recovery.rs | 1 + crates/freshell-supervisor/src/registry.rs | 30 ++ .../freshell-supervisor/src/resume_catalog.rs | 2 + crates/freshell-supervisor/src/service.rs | 87 ++++++ scripts/testing/runtime-build-preflight.ts | 2 +- .../providers/codex-local-provider.mjs | 61 +++- .../server/fresh-agent-parity.test.ts | 263 +++++++++++++++- 31 files changed, 1989 insertions(+), 85 deletions(-) create mode 100644 crates/freshell-server/src/fresh_agent_proxy_naming.rs diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 731dbd944..0f4e1439b 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -8,7 +8,8 @@ pub use freshell_runtime_protocol::{ AgentEvent, AgentEventBatch as EventBatch, AgentJournalEvent as JournalEvent, FreshProvider, }; use freshell_runtime_protocol::{ - CommandState, FreshAgentCapture, FreshAgentRollbackDirection, FreshAgentRollbackMode, + CommandState, FreshAgentCapture, FreshAgentNativeNameError, FreshAgentNativeNameOperation, + FreshAgentNativeNameReply, FreshAgentRollbackDirection, FreshAgentRollbackMode, FreshAgentTurnSettings, ProviderLaunchContext, ProviderModelSelection, ProviderSecretReference, ProviderSessionReference, RequestId, }; @@ -50,6 +51,8 @@ mod double_optional { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub naming_handle: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, pub provider: FreshProvider, @@ -335,6 +338,17 @@ pub trait FreshAgentTransport: Send + Sync { Err("provider does not expose a model catalog".into()) } + async fn native_name( + &self, + _native_session_id: &str, + _codex_home: &str, + _operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "provider has no live native naming authority".into(), + }) + } + async fn read_child(&self, _child_thread_id: &str) -> Result { Err("provider does not expose child observation".into()) } @@ -871,6 +885,7 @@ impl FreshAgentHostActor { return Err(ActorError::AmbiguousDispatch); } state.profile.native_session_id = Some(transition.child_session_id.clone()); + state.profile.naming_handle = None; if let Some(reference) = state.profile.session_ref.as_mut() { reference.session_id = transition.child_session_id.clone(); } @@ -1069,6 +1084,7 @@ impl FreshAgentHostActor { if let Some(native_session_id) = native_session_id { if state.profile.native_session_id.as_deref() != Some(&native_session_id) { state.profile.native_session_id = Some(native_session_id.clone()); + state.profile.naming_handle = None; if let Some(reference) = state.profile.session_ref.as_mut() { reference.session_id = native_session_id.clone(); } @@ -1198,6 +1214,44 @@ impl FreshAgentHostActor { Ok(value) } + pub async fn native_name( + &self, + native_session_id: &str, + codex_home: &str, + operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.as_deref() != Some(native_session_id) + || codex_home.is_empty() + || !self.transport.is_live().await + { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "native naming target is no longer current".into(), + }); + } + let reply = self + .transport + .native_name(native_session_id, codex_home, operation) + .await; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || !self.transport.is_live().await + { + // A write may already have applied to the captured source. + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed); + } + if serde_json::to_vec(&reply).map_or(true, |bytes| { + bytes.len() > freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES - 4096 + }) { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::InvalidResponse { + detail: "native naming reply exceeds control frame limit".into(), + }); + } + reply + } + pub async fn read_child(&self, child_thread_id: &str) -> Result { let before = self.profile().await; if before.provider != FreshProvider::Codex diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 57db0d4b2..70b885cca 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -141,6 +141,104 @@ impl FreshAgentTransport for SnapshotTransport { async fn model_catalog(&self, _: Option<&str>) -> Result { self.snapshot().await } + async fn native_name( + &self, + native: &str, + _: &str, + _: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + assert_eq!( + Some(native), + self.initial_native.as_deref(), + "a stale target must never reach the transport" + ); + assert!( + self.live.load(Ordering::SeqCst), + "a dead transport must not receive metadata work" + ); + let value = self.snapshot().await.unwrap(); + FreshAgentNativeNameReply::Title(value["title"].as_str().map(str::to_string)) + } +} + +#[tokio::test] +async fn native_name_preserves_actor_state_and_rejects_stale_targets_and_source_loss() { + for scenario in [ + "live", + "wrong-native", + "dead-before", + "exit-during", + "replaced", + "oversized", + ] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"title": if scenario=="oversized" { "x".repeat(freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES) } else { "Saved title".into() }}), + live: std::sync::atomic::AtomicBool::new(scenario != "dead-before"), + exit_during_read: scenario == "exit-during", + initial_native: Some("name-native".into()), + registered: None, + pause: (scenario == "replaced").then(|| (entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "name-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let operation = tokio::spawn({ + let actor = actor.clone(); + async move { + actor + .native_name( + if scenario == "wrong-native" { + "stale-native" + } else { + "name-native" + }, + "/owned/codex", + &FreshAgentNativeNameOperation::Read, + ) + .await + } + }); + if scenario == "replaced" { + entered.notified().await; + // Controlled source replacement while the actual actor read is held. + actor.state.lock().await.profile.native_session_id = Some("replacement".into()); + release.notify_one(); + } + let reply = operation.await.unwrap(); + match scenario { + "live" => assert_eq!( + reply, + FreshAgentNativeNameReply::Title(Some("Saved title".into())) + ), + "wrong-native" | "dead-before" => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { .. }) + )), + "oversized" => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::InvalidResponse { .. } + ) + )), + _ => assert_eq!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed) + ), + } + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before, + "native naming is not a conversation command or writer transition" + ); + } } #[tokio::test] @@ -632,6 +730,7 @@ impl FreshAgentTransport for FakeTransport { fn profile(provider: FreshProvider, store: &str, native: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: None, collaboration_mode: None, runtime_variant: provider.as_str().into(), provider, @@ -1541,13 +1640,11 @@ async fn provider_native_fork_transitions_the_same_actor_without_a_second_writer let transport = Arc::new(ForkingTransport { forks: AtomicUsize::new(0), }); - let actor = FreshAgentHostActor::open( - dir.path(), - profile(FreshProvider::Codex, "same-store", Some("native-parent")), - transport.clone(), - ) - .await - .unwrap(); + let mut parent = profile(FreshProvider::Codex, "same-store", Some("native-parent")); + parent.naming_handle = Some("parent-only-pending-name".into()); + let actor = FreshAgentHostActor::open(dir.path(), parent, transport.clone()) + .await + .unwrap(); let request = RequestId::parse("fork-same-soul-1").unwrap(); let input = Some(serde_json::json!({"atTurnId":"turn-7"})); let transition = actor @@ -1556,6 +1653,10 @@ async fn provider_native_fork_transitions_the_same_actor_without_a_second_writer .unwrap(); assert_eq!(transition.child_session_id, "native-child"); assert!(transition.parent_retired_by_runtime); + assert!( + actor.profile().await.naming_handle.is_none(), + "an ordinary native fork must not reuse its parent's pending name on reopen" + ); assert_eq!( actor.profile().await.native_session_id.as_deref(), Some("native-child") diff --git a/crates/freshell-agent-runtime/src/snapshot_projection.rs b/crates/freshell-agent-runtime/src/snapshot_projection.rs index 5e3dbd75f..c385a56c9 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection.rs @@ -73,6 +73,13 @@ fn visit(value: &mut Value, provider: &str, session_type: &str, known_pair: bool } fn project_capabilities(snapshot: &mut Map, provider: &str, known_pair: bool) { + if let Some(extension) = snapshot + .get_mut("extensions") + .and_then(|value| value.get_mut("codex")) + .and_then(Value::as_object_mut) + { + extension.remove("hostedNamingFacts"); + } let native = snapshot.get("capabilities").and_then(Value::as_object); let required_valid = native.is_some_and(|capabilities| { ["send", "interrupt", "approvals", "questions", "fork"] diff --git a/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs b/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs index f03360461..27ee86d41 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs @@ -22,6 +22,29 @@ fn advertised_capabilities() -> Value { }) } +#[test] +fn hosted_public_snapshots_keep_transcript_and_hide_internal_naming_facts() { + let snapshot = json!({"type":"freshAgent.session.snapshot","provider":"codex","sessionType":"freshcodex", + "capabilities":advertised_capabilities(),"turns":[{"role":"user","items":[{"kind":"text","text":"visible prompt"}]}], + "extensions":{"codex":{"usage":{"last":{"totalTokens":15}},"hostedNamingFacts":[{"kind":"activity","data":{"first_user_message":"internal naming input"}}]}}}); + let mut event = json!({"type":"freshAgent.event","event":snapshot.clone()}); + project_hosted_snapshot(&mut event, "codex", "freshcodex"); + assert!(event["event"]["extensions"]["codex"] + .get("hostedNamingFacts") + .is_none()); + assert_eq!(event["event"]["turns"], snapshot["turns"]); + assert_eq!( + event["event"]["extensions"]["codex"]["usage"], + snapshot["extensions"]["codex"]["usage"] + ); + let mut rest = snapshot; + crate::snapshot_projection::project_hosted_rest_snapshot(&mut rest, "codex", "freshcodex"); + assert!(rest["extensions"]["codex"] + .get("hostedNamingFacts") + .is_none()); + assert_eq!(rest["turns"], event["event"]["turns"]); +} + #[test] fn every_hosted_provider_and_snapshot_envelope_intersects_native_and_gateway_truth() { let providers = [ diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 69773bb4e..837165fb0 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -538,6 +538,8 @@ pub struct FreshCodexState { /// set-once/shared model as [`Self::identity_sink`]. Wired by /// `freshell-server::main`; unwired = scoped naming fails unavailable. naming: crate::naming::NamingSink, + hosted_naming_events: bool, + naming_rollout_probe: Arc>, /// Unified agent names: thread id → the pre-durable naming handle /// admitted at create. A `thread/start` id/path is PROSPECTIVE (the /// rollout hasn't materialized), so the handle stays pending and the @@ -571,6 +573,18 @@ pub struct FreshCodexState { pub type SnapshotPauseHook = std::sync::Arc futures_util::future::BoxFuture<'static, ()> + Send + Sync>; +/// A hosted runtime reads private native metadata as the provider identity. +/// The initialized root and exact thread ID remain the proof inputs. +pub type CodexNamingRolloutProbe = Arc< + dyn Fn( + String, + String, + ) + -> futures_util::future::BoxFuture<'static, Result, String>> + + Send + + Sync, +>; + /// The cached result of a completed codex `freshAgent.create`, keyed by `requestId` in /// [`FreshCodexState::create_dedup`]. Only `session_id` is needed: every other field of /// the `freshAgent.created` replay frame ([`FreshCodexState::handle_create`]'s replay @@ -919,6 +933,8 @@ impl FreshCodexState { codex_quiet_window_ms: Arc::new(AtomicU64::new(codex_quiet_window_ms_from_env())), rollback_in_flight: crate::InFlightRegistry::new(), naming: crate::naming::NamingSink::default(), + hosted_naming_events: false, + naming_rollout_probe: Arc::new(std::sync::OnceLock::new()), naming_handles: Arc::new(TokioMutex::new(HashMap::new())), snapshot_pause: Arc::new(StdMutex::new(None)), snapshot_pause_after_capture: Arc::new(StdMutex::new(None)), @@ -931,6 +947,48 @@ impl FreshCodexState { self } + pub fn with_hosted_naming_events(mut self) -> Self { + self.hosted_naming_events = true; + self + } + + pub fn set_naming_rollout_probe(&self, probe: CodexNamingRolloutProbe) { + let _ = self.naming_rollout_probe.set(probe); + } + + async fn report_hosted_naming_fact( + &self, + thread_id: &str, + fact: crate::naming::HostedCodexNamingFact, + ) { + if !self.hosted_naming_events { + return; + } + let Ok(fact) = serde_json::to_value(fact) else { + return; + }; + let projection = self + .sessions + .lock() + .await + .get(thread_id) + .map(|session| Arc::clone(&session.projection)); + let Some(projection) = projection else { + return; + }; + if !projection + .lock() + .expect("codex projection lock") + .retain_hosted_naming_fact(fact.clone()) + { + return; + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), session_type: SESSION_TYPE.into(), session_id: thread_id.into(), + event: json!({"type":crate::naming::HOSTED_CODEX_NAMING_EVENT,"sessionId":thread_id,"fact":fact}), + })); + } + /// Wire the server-owned durable checkpoint-event path before this state is /// cloned into routers and session consumers. pub fn set_checkpoint_event_persister( @@ -1520,6 +1578,12 @@ impl FreshCodexState { return None; } let handle = handle.to_string(); + if self.hosted_naming_events { + self.naming_handles + .lock() + .await + .insert(thread_id.into(), handle.clone()); + } let sink = self.naming(); let projection = crate::naming::admit_pending_projection( &sink, @@ -1623,8 +1687,19 @@ impl FreshCodexState { let home = codex_home .map(str::to_string) .or_else(codex_home_from_env)?; - let sessions_root = std::path::Path::new(&home).join("sessions"); - let Some(rollout) = locate_thread_rollout(&sessions_root, thread_id) else { + let rollout = if let Some(probe) = self.naming_rollout_probe.get() { + match probe(home.clone(), thread_id.to_string()).await { + Ok(path) => path, + Err(error) => { + tracing::warn!(provider = "codex", session_id = thread_id, %error, + "freshagent.codex.naming_proof_unavailable"); + None + } + } + } else { + locate_thread_rollout(&std::path::Path::new(&home).join("sessions"), thread_id) + }; + let Some(rollout) = rollout else { return None; // still prospective: the next turn-completed edge retries }; let target = freshell_protocol::session_names::SessionNameRef::Session { @@ -1650,6 +1725,20 @@ impl FreshCodexState { acquisition, pending: Some(handle), }; + if self.hosted_naming_events { + self.report_hosted_naming_fact( + thread_id, + crate::naming::HostedCodexNamingFact::Transition { + target: target.clone(), + transition: transition.clone(), + }, + ) + .await; + // This is the provider's verified materialization fact. Canonical + // acceptance remains in the gateway; the retained source keeps + // this fact available even if its final notification was dropped. + self.naming_handles.lock().await.remove(thread_id); + } match crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) .await { @@ -4255,6 +4344,20 @@ impl FreshCodexState { session_id: session_id.clone(), }, ); + if !msg.text.trim().is_empty() { + self.report_hosted_naming_fact( + &session_id, + crate::naming::HostedCodexNamingFact::Activity(crate::naming::NameActivity { + target: naming_target.clone(), + mode: SESSION_TYPE.into(), + event_id: submitted_turn_id.to_string(), + reason: crate::naming::NameActivityReason::AcceptedUserMessage, + first_user_message: Some(msg.text.trim().to_string()), + cwd: turn_cwd.clone(), + }), + ) + .await; + } crate::naming::report_accepted_input( &self.naming(), &naming_target, diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index 1f246aeec..d70579cac 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -154,6 +154,7 @@ pub(super) struct CodexProjection { pub usage: Option, pub plan: Option, pub diff: Option, + hosted_naming_facts: Vec, pending_ops: Vec, pending_after_revision: Option, } @@ -180,6 +181,7 @@ impl CodexProjection { usage: None, plan: None, diff: None, + hosted_naming_facts: vec![], pending_ops: vec![], pending_after_revision: None, } @@ -1053,6 +1055,9 @@ impl CodexProjection { } pub(super) fn overlay(&self, snapshot: &mut Value, metadata: Value) { + if !self.hosted_naming_facts.is_empty() { + snapshot["extensions"]["codex"]["hostedNamingFacts"] = json!(self.hosted_naming_facts); + } snapshot["extensions"]["codex"]["projection"] = metadata; snapshot["tokenUsage"]["observed"] = json!(self.usage.is_some()); if let Some(usage) = &self.usage { @@ -1074,6 +1079,26 @@ impl CodexProjection { } } + /// Retain the first accepted naming input and latest verified transition + /// with this source, so a dropped final event can reconcile without a token. + pub(super) fn retain_hosted_naming_fact(&mut self, fact: Value) -> bool { + if self.retired { + return false; + } + if let Some(existing) = self + .hosted_naming_facts + .iter_mut() + .find(|old| old["kind"] == fact["kind"]) + { + if fact["kind"] != "activity" { + *existing = fact; + } + } else { + self.hosted_naming_facts.push(fact); + } + true + } + #[cfg(test)] fn snapshot_rows(&self, turn_id: &str) -> Option> { super::build_codex_turn_json(&self.turns.iter().find(|t| t.raw["id"] == turn_id)?.raw, 0) diff --git a/crates/freshell-freshagent/src/naming.rs b/crates/freshell-freshagent/src/naming.rs index 5db8812aa..6b997188b 100644 --- a/crates/freshell-freshagent/src/naming.rs +++ b/crates/freshell-freshagent/src/naming.rs @@ -11,6 +11,7 @@ //! rename answer ALWAYS reports the actual accepted winner (including an //! unchanged winner when an automatic suggestion loses). +use serde::{Deserialize, Serialize}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -132,7 +133,7 @@ pub struct RenameNameInput { /// Why activity arrived. `AcceptedUserMessage`/`IndexUserMessage` may carry a /// first user message (fallback upgrade input); `Opened`/`Resumed` arm an /// unattempted generation series only when a first message exists. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NameActivityReason { AcceptedUserMessage, IndexUserMessage, @@ -143,7 +144,7 @@ pub enum NameActivityReason { /// One activity event. `event_id` is the existing provider/event message id /// (duplicate delivery folds through the series' input fingerprint, never a /// new event store). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NameActivity { pub target: SessionNameRef, pub mode: String, @@ -309,7 +310,7 @@ pub fn named_provider_for(mode: Option<&str>, session_type: Option<&str>) -> Opt /// Task 2 interfaces) — carried SEPARATELY from the ledger's `supersedes` /// edge, which cannot distinguish a rollback fork from a crash-recovery /// mint-new from a deliberate new conversation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NameTransitionReason { /// First verified durable materialization of a pre-durable handle /// (requires verified persistence — never a bare start ack). @@ -338,13 +339,27 @@ pub enum NameTransitionReason { /// classification plus the native routing evidence the store retains with /// the record. `pending` is the pre-durable handle this transition resolves /// (`None` on resume/switch of established identities). -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NameTransition { pub reason: NameTransitionReason, pub acquisition: NativeAcquisition, pub pending: Option, } +pub const HOSTED_CODEX_NAMING_EVENT: &str = "freshAgent.host.naming"; + +/// Provider facts transported to the gateway's existing name authority. +/// These never contain accepted name records or native control answers. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum HostedCodexNamingFact { + Activity(NameActivity), + Transition { + target: SessionNameRef, + transition: NameTransition, + }, +} + impl NameTransition { /// A verified transition resolving `pending` onto its durable identity. pub fn binding( diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index 95d97d3a7..ee8adb020 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -854,6 +854,39 @@ impl RuntimeClient { } } + pub async fn fresh_agent_native_name( + &self, + mut request: freshell_runtime_protocol::FreshAgentNativeNameRequest, + ) -> Result { + request.expected_control_epoch = Some(self.current_epoch().await?); + // A stale epoch is a settled refusal before host/native dispatch. Keep + // the captured target unchanged if refreshing only that control stamp. + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentNativeName(request.clone()), + ) + .await + { + Ok(AdminResult::FreshAgentNativeName(reply)) => Ok(reply), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentNativeName(request), + ) + .await? + { + AdminResult::FreshAgentNativeName(reply) => Ok(reply), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_model_catalog( &self, soul_id: SoulId, diff --git a/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs b/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs index 0f028df4a..83def017e 100644 --- a/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs +++ b/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs @@ -1,6 +1,6 @@ //! Host-owned fresh-agent operation contracts. -use crate::{FreshAgentTurnSettings, FreshProvider, SoulId}; +use crate::{FreshAgentTurnSettings, FreshProvider, HostBootId, IncarnationId, SoulId}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -73,3 +73,55 @@ pub struct FreshAgentCapture { pub text: String, pub truncated: bool, } + +/// Metadata authority captured from the current managed runtime. A name +/// request cannot follow a replacement incarnation merely because its root +/// has the same spelling. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentNativeNameSource { + pub incarnation_id: IncarnationId, + pub host_boot_id: HostBootId, + pub execution_generation: u64, + pub intent_revision: u64, + pub native_session_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +pub enum FreshAgentNativeNameOperation { + Probe, + Read, + Write { title: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FreshAgentNativeNameError { + Unavailable { message: String }, + Rpc { code: i64, message: String }, + Timeout { timeout_ms: u64 }, + Closed, + Transport { message: String }, + InvalidResponse { detail: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum FreshAgentNativeNameReply { + Available, + Title(Option), + Renamed, + Failed(FreshAgentNativeNameError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentNativeNameRequest { + pub soul_id: SoulId, + pub source: FreshAgentNativeNameSource, + pub codex_home: String, + pub operation: FreshAgentNativeNameOperation, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index bcbecfde6..812fef4f7 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1792,6 +1792,8 @@ impl FreshProvider { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentLaunchSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub naming_handle: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, /// Stable presentation/session key used by the external Freshell wire. @@ -2993,6 +2995,7 @@ pub enum AdminCommand { FreshAgentReadHistory(FreshAgentReadHistoryRequest), FreshAgentReadSnapshot(FreshAgentReadSnapshotRequest), FreshAgentModelCatalog(FreshAgentModelCatalogRequest), + FreshAgentNativeName(FreshAgentNativeNameRequest), FreshAgentReadChild(FreshAgentReadChildRequest), RuntimeMetrics(RuntimeMetricsRequest), ProbeRecovery(RecoveryProbeRequest), @@ -3509,6 +3512,7 @@ pub enum AdminResult { FreshAgentHistory(serde_json::Value), FreshAgentSnapshot(serde_json::Value), FreshAgentModelCatalog(serde_json::Value), + FreshAgentNativeName(FreshAgentNativeNameReply), FreshAgentChildSnapshot(serde_json::Value), RuntimeMetrics(RuntimeMetrics), RecoveryProbe(RecoveryProbe), @@ -3632,6 +3636,12 @@ pub enum HostCommand { incarnation_id: IncarnationId, cwd: Option, }, + FreshAgentNativeName { + incarnation_id: IncarnationId, + native_session_id: String, + codex_home: String, + operation: FreshAgentNativeNameOperation, + }, FreshAgentReadChild { incarnation_id: IncarnationId, child_thread_id: String, @@ -3701,6 +3711,7 @@ pub enum HostResult { RuntimeMetrics(RuntimeMetrics), FreshAgentSnapshot(serde_json::Value), FreshAgentModelCatalog(serde_json::Value), + FreshAgentNativeName(FreshAgentNativeNameReply), FreshAgentChildSnapshot(serde_json::Value), Status { host_boot_id: HostBootId, @@ -4443,6 +4454,7 @@ mod tests { fn fresh_agent_launch(provider: FreshProvider, session_type: &str) -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "presentation-one".into(), provider, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 807edd7b3..ebd9d1011 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -734,6 +734,9 @@ impl HostedFreshAgentProxy { }; provider_launch_context = context_for_fresh_provider(provider_launch_context); let launch = FreshAgentLaunchSpec { + naming_handle: (provider == FreshProvider::Codex) + .then(|| message.naming_handle.clone()) + .flatten(), collaboration_mode: message.collaboration_mode.clone(), session_id: public_session_id.clone(), provider: provider.clone(), @@ -1067,17 +1070,19 @@ impl HostedFreshAgentProxy { .get(&soul) .cloned() .unwrap_or_else(|| presentation_session_id.clone()); - proxy.forward_retained( - retained, - &provider, - &presentation, - &session_type, - if batch.reset_required { - "freshAgent.host.journal.reset" - } else { - "freshAgent.host.backpressure" - }, - ); + proxy + .forward_retained( + retained, + &provider, + &presentation, + &session_type, + if batch.reset_required { + "freshAgent.host.journal.reset" + } else { + "freshAgent.host.backpressure" + }, + ) + .await; // Projection and controls are covered, but request receipts // still have to drain through every bounded journal page. retained_through = retained_through.max(batch.head); @@ -1113,12 +1118,14 @@ impl HostedFreshAgentProxy { .get(&soul) .cloned() .unwrap_or_else(|| presentation_session_id.clone()); - proxy.forward_host_event( - event.event, - &provider, - ¤t_presentation, - &session_type, - ); + proxy + .forward_host_event( + event.event, + &provider, + ¤t_presentation, + &session_type, + ) + .await; } } Err(_) => { @@ -1155,7 +1162,7 @@ impl HostedFreshAgentProxy { .find(|view| &view.soul_id == soul && view.desired_state == DesiredState::Running) } - fn forward_retained( + async fn forward_retained( &self, mut retained: serde_json::Value, provider: &str, @@ -1163,6 +1170,21 @@ impl HostedFreshAgentProxy { session_type: &str, event_type: &str, ) { + let native = retained["snapshot"]["threadId"] + .as_str() + .unwrap_or_default() + .to_string(); + if provider == "codex" { + if let Some(facts) = retained["snapshot"]["extensions"]["codex"] + .as_object_mut() + .and_then(|extension| extension.remove("hostedNamingFacts")) + { + for fact in facts.as_array().into_iter().flatten() { + self.fold_hosted_codex_naming(presentation, &native, fact.clone()) + .await; + } + } + } retained["source"]["nativeSessionId"] = retained["snapshot"]["threadId"].clone(); if let Some(ownership) = self.ownership.get() { let canonical = ownership.resolve_canonical(provider, presentation); @@ -1197,7 +1219,8 @@ impl HostedFreshAgentProxy { provider, presentation, session_type, - ); + ) + .await; } } @@ -1244,7 +1267,8 @@ impl HostedFreshAgentProxy { presentation_session_id, session_type, "freshAgent.host.backpressure", - ); + ) + .await; retained_through = retained_through.max(batch.head); } if batch.events.is_empty() { @@ -1261,7 +1285,8 @@ impl HostedFreshAgentProxy { provider, presentation_session_id, session_type, - ); + ) + .await; } } if cursor >= replay_through { @@ -1270,7 +1295,7 @@ impl HostedFreshAgentProxy { } } - fn forward_host_event( + async fn forward_host_event( &self, event: AgentEvent, provider: &str, @@ -1279,6 +1304,20 @@ impl HostedFreshAgentProxy { ) { match event { AgentEvent::Provider { mut payload } => { + if payload["event"]["type"] + == freshell_freshagent::naming::HOSTED_CODEX_NAMING_EVENT + { + if provider == "codex" { + let native = payload["sessionId"].as_str().unwrap_or_default(); + self.fold_hosted_codex_naming( + presentation_session_id, + native, + payload["event"]["fact"].clone(), + ) + .await; + } + return; + } if payload.get("type").and_then(serde_json::Value::as_str) == Some("freshAgent.created") { @@ -1306,6 +1345,91 @@ impl HostedFreshAgentProxy { } } + async fn fold_hosted_codex_naming( + &self, + presentation: &str, + native_id: &str, + value: serde_json::Value, + ) { + use freshell_freshagent::naming::{HostedCodexNamingFact, NameTransitionReason}; + use freshell_protocol::native_location::{NativeLocation, NativePersistence}; + use freshell_protocol::session_names::{NamedProvider, SessionNameRef}; + if native_id.is_empty() { + return; + } + let Some(soul) = self + .resolve_soul(&AgentProvider::Codex, SessionType::Freshcodex, presentation) + .await + else { + return; + }; + let current = self.current_source(&soul).await; + if current + .as_ref() + .and_then(|view| view.native_session_id.as_deref()) + != Some(native_id) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + return; + } + let fact = match serde_json::from_value::(value) { + Ok(fact) => fact, + Err(error) => { + tracing::warn!(provider="codex", native_session_id=native_id, %error, "freshagent.gateway.invalid_naming_fact"); + return; + } + }; + let sink = self.naming.get().cloned(); + match fact { + HostedCodexNamingFact::Activity(input) => { + let valid_target = match &input.target { + SessionNameRef::Pending { id } => !id.is_empty(), + SessionNameRef::Session { + provider, + session_id, + } => *provider == NamedProvider::Codex && session_id == native_id, + }; + if valid_target && input.mode == "freshcodex" { + if let Some(sink) = sink { + if let Err(error) = sink.activity(input.clone()).await { + freshell_freshagent::naming::log_name_error( + "activity", + &input.target, + &error, + ); + } + } + } + } + HostedCodexNamingFact::Transition { target, transition } => { + let expected = SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: native_id.into(), + }; + let location_matches = matches!(&transition.acquisition.location, NativeLocation::Codex { native_thread_id:Some(id), .. } if id == native_id); + if target != expected + || !location_matches + || transition.reason != NameTransitionReason::InitialMaterialization + || transition.acquisition.persistence != NativePersistence::Verified + { + tracing::warn!( + provider = "codex", + native_session_id = native_id, + "freshagent.gateway.naming_source_mismatch" + ); + return; + } + freshell_freshagent::naming::fold_identity_transition(&sink, &transition, target) + .await; + } + } + } + fn error_event( &self, provider: &AgentProvider, @@ -1438,6 +1562,8 @@ impl HostedFreshAgentGateway for HostedFreshAgentProxy { } } +#[path = "fresh_agent_proxy_naming.rs"] +mod native_naming; #[path = "fresh_agent_proxy_rest.rs"] mod rest; #[cfg(test)] diff --git a/crates/freshell-server/src/fresh_agent_proxy_naming.rs b/crates/freshell-server/src/fresh_agent_proxy_naming.rs new file mode 100644 index 000000000..67a7ede22 --- /dev/null +++ b/crates/freshell-server/src/fresh_agent_proxy_naming.rs @@ -0,0 +1,192 @@ +//! Native naming uses the current host's existing client and the shared worker. +use super::*; +use crate::session_name_native::{ + classify_codex_error, NativeCallResult, NativeClassified, NativeFuture, NativeNameAttempt, + NativeNameBackend, NativeNameReadback, NativeNameTarget, NativeProbeFuture, +}; +use freshell_protocol::native_location::NativeLocation; +use freshell_runtime_protocol::{ + FreshAgentNativeNameError as NameError, FreshAgentNativeNameOperation as Operation, + FreshAgentNativeNameReply as Reply, FreshAgentNativeNameRequest, FreshAgentNativeNameSource, +}; + +#[derive(Clone)] +struct HostedCodexNameBackend { + client: RuntimeClient, + ownership: Option>, +} + +impl HostedFreshAgentProxy { + pub(crate) fn native_name_backend(&self) -> Arc { + Arc::new(HostedCodexNameBackend { + client: self.client.clone(), + ownership: self.ownership.get().cloned(), + }) + } +} + +impl HostedCodexNameBackend { + async fn invoke( + &self, + target: NativeNameTarget, + operation: Operation, + ) -> Result { + let NativeLocation::Codex { + codex_home, + native_thread_id: Some(native_id), + .. + } = &target.location + else { + return Err(NativeClassified::Unsupported( + "no exact Codex native naming target".into(), + )); + }; + if codex_home.is_empty() || native_id.is_empty() { + return Err(NativeClassified::Unsupported( + "no initialized Codex naming root or thread".into(), + )); + } + let owner = || { + self.ownership.as_ref().map(|registry| { + let canonical = registry.resolve_canonical("codex", native_id); + let observed = registry.observe("codex", &canonical); + (observed.epoch, observed.generation, observed.state) + }) + }; + let before_owner = owner(); + let inventory = self + .client + .inventory() + .await + .map_err(|error| NativeClassified::Undelivered(error.to_string()))?; + let view = inventory + .iter() + .rev() + .find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && view.native_session_id.as_deref() == Some(native_id) + && view.launch_state == freshell_runtime_protocol::LaunchState::Running + && view.recovery_state == freshell_runtime_protocol::RecoveryState::Live + && view.desired_state == DesiredState::Running + }) + .ok_or_else(|| { + NativeClassified::Undelivered("no live managed Codex naming authority".into()) + })?; + let source = FreshAgentNativeNameSource { + incarnation_id: view.incarnation_id.clone(), + host_boot_id: view.host_boot_id.clone().ok_or_else(|| { + NativeClassified::Undelivered("Codex host has no authenticated boot".into()) + })?, + execution_generation: view.execution_generation, + intent_revision: view.intent_revision, + native_session_id: native_id.clone(), + }; + if owner() != before_owner { + return Err(NativeClassified::Undelivered( + "Codex naming owner changed before dispatch".into(), + )); + } + let reply = self + .client + .fresh_agent_native_name(FreshAgentNativeNameRequest { + soul_id: view.soul_id.clone(), + source, + codex_home: codex_home.clone(), + operation, + expected_control_epoch: None, + }) + .await + .map_err(|error| { + NativeClassified::Ambiguous(format!( + "managed Codex naming request did not settle: {error}" + )) + })?; + let inventory = self + .client + .inventory() + .await + .map_err(|error| NativeClassified::Ambiguous(error.to_string()))?; + let current = inventory + .iter() + .rev() + .find(|row| row.soul_id == view.soul_id); + if owner() != before_owner + || !current.is_some_and(|current| rest::same_snapshot_source(view, current)) + { + return Err(NativeClassified::Ambiguous( + "Codex naming source changed during dispatch".into(), + )); + } + Ok(reply) + } +} + +fn classify_name_error(error: NameError, method: &str) -> NativeClassified { + use freshell_codex::app_server::CodexAppServerError as Error; + let method = method.to_string(); + let error = match error { + NameError::Unavailable { message } => return NativeClassified::Undelivered(message), + NameError::Rpc { code, message } => Error::Rpc { + method, + error: freshell_codex::protocol::RpcError { + code, + message, + data: None, + }, + }, + NameError::Timeout { timeout_ms } => Error::Timeout { method, timeout_ms }, + NameError::Closed => Error::Closed { method }, + NameError::Transport { message } => Error::Transport { method, message }, + NameError::InvalidResponse { detail } => Error::InvalidResponse { method, detail }, + }; + classify_codex_error(error, "native metadata") +} + +impl NativeNameBackend for HostedCodexNameBackend { + fn read(&self, target: NativeNameTarget) -> NativeFuture { + let backend = self.clone(); + Box::pin(async move { + match backend.invoke(target, Operation::Read).await { + Ok(Reply::Title(title)) => { + NativeCallResult::Confirmed(NativeNameReadback { title }) + } + Ok(Reply::Failed(error)) => classify_name_error(error, "thread/read").into_call(), + Err(error) => error.into_call(), + _ => NativeCallResult::Ambiguous("unexpected native name read reply".into()), + } + }) + } + + fn write(&self, attempt: NativeNameAttempt) -> NativeFuture<()> { + let backend = self.clone(); + Box::pin(async move { + match backend + .invoke( + attempt.target, + Operation::Write { + title: attempt.title, + }, + ) + .await + { + Ok(Reply::Renamed) => NativeCallResult::Confirmed(()), + Ok(Reply::Failed(error)) => { + classify_name_error(error, "thread/name/set").into_call() + } + Err(error) => error.into_call(), + _ => NativeCallResult::Ambiguous("unexpected native name write reply".into()), + } + }) + } + + fn route_available(&self, target: NativeNameTarget) -> NativeProbeFuture { + let backend = self.clone(); + Box::pin(async move { + matches!( + backend.invoke(target, Operation::Probe).await, + Ok(Reply::Available) + ) + }) + } +} diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 53fcecce9..65d69e5ce 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -493,7 +493,7 @@ impl freshell_freshagent::model_capabilities::ModelCatalogProbe for HostedCodexC } } -fn same_snapshot_source( +pub(super) fn same_snapshot_source( before: &freshell_runtime_protocol::RuntimeView, after: &freshell_runtime_protocol::RuntimeView, ) -> bool { diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 33524e234..c33508c53 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -6,6 +6,141 @@ use freshell_runtime_protocol::{ }; use tokio::net::UnixListener; +#[tokio::test] +async fn hosted_native_name_reads_pin_the_source_and_preserve_typed_delivery_failures() { + use crate::session_name_native::{NativeCallResult, NativeNameTarget}; + use freshell_runtime_protocol::{ + FreshAgentNativeNameError as Error, FreshAgentNativeNameReply as Reply, + }; + let dir = tempfile::tempdir().unwrap(); + for scenario in [ + "ok", + "missing", + "source-changes", + "unavailable", + "unsupported", + "refused", + "timeout", + "closed", + "transport", + "invalid-response", + ] { + let socket = dir.path().join(format!("native-name-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = calls.clone(); + let server = tokio::spawn(async move { + let mut inventories = 0; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + inventories += 1; + AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"name-soul","incarnationId":"name-incarnation","hostBootId":if scenario=="source-changes"&&inventories>1 {"replaced-boot"}else{"name-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":3,"executionGeneration":2, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":if scenario=="missing" {"foreign-native"}else{"name-native"},"freshAgentSessionId":"public-name-session","freshAgentSessionType":"freshcodex" + })).unwrap()]) + } + AdminCommand::FreshAgentNativeName(request) => { + assert_eq!(request.soul_id.as_str(), "name-soul"); + assert_eq!(request.codex_home, "/owning/codex"); + assert_eq!(request.source.native_session_id, "name-native"); + assert_eq!(request.source.incarnation_id.as_str(), "name-incarnation"); + assert_eq!(request.source.host_boot_id.as_str(), "name-boot"); + assert_eq!( + ( + request.source.execution_generation, + request.source.intent_revision + ), + (2, 3) + ); + assert_eq!(request.expected_control_epoch, Some(7)); + assert_eq!( + request.operation, + freshell_runtime_protocol::FreshAgentNativeNameOperation::Read + ); + seen.lock().await.push(request); + AdminResult::FreshAgentNativeName(match scenario { + "ok" | "source-changes" => Reply::Title(Some("Native title".into())), + "unavailable" => Reply::Failed(Error::Unavailable { + message: "current connection absent".into(), + }), + "unsupported" => Reply::Failed(Error::Rpc { + code: -32601, + message: "method unavailable".into(), + }), + "refused" => Reply::Failed(Error::Rpc { + code: -32602, + message: "invalid name input".into(), + }), + "timeout" => Reply::Failed(Error::Timeout { timeout_ms: 20_000 }), + "closed" => Reply::Failed(Error::Closed), + "transport" => Reply::Failed(Error::Transport { + message: "send refused".into(), + }), + "invalid-response" => Reply::Failed(Error::InvalidResponse { + detail: "malformed metadata".into(), + }), + _ => unreachable!(), + }) + } + other => panic!("native naming attempted unrelated operation: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let backend = snapshot_outage_proxy(&socket).native_name_backend(); + let reply = backend + .read(NativeNameTarget { + name_ref: freshell_protocol::session_names::SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: "name-native".into(), + }, + location: freshell_protocol::native_location::NativeLocation::Codex { + codex_home: "/owning/codex".into(), + native_thread_id: Some("name-native".into()), + rollout_path: None, + persistence_evidence: None, + }, + location_revision: 1, + }) + .await; + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + match scenario { + "ok" => assert!( + matches!(reply,NativeCallResult::Confirmed(value) if value.title.as_deref()==Some("Native title")) + ), + "missing" | "unavailable" | "refused" | "transport" => assert!( + matches!(reply, NativeCallResult::Undelivered(_)), + "{scenario}: {reply:?}" + ), + "unsupported" => assert!(matches!(reply, NativeCallResult::Unsupported(_))), + _ => assert!( + matches!(reply, NativeCallResult::Ambiguous(_)), + "{scenario}: {reply:?}" + ), + } + assert_eq!(calls.lock().await.len(), usize::from(scenario != "missing")); + } +} + #[tokio::test] async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_sources() { use tower::ServiceExt; @@ -277,8 +412,8 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() ); } -#[test] -fn hosted_created_frame_uses_the_public_request_identity() { +#[tokio::test] +async fn hosted_created_frame_uses_the_public_request_identity() { let (broadcast, mut receiver) = broadcast::channel(8); let proxy = HostedFreshAgentProxy { client: RuntimeClient::new("/tmp/unopened-fresh-agent-proxy.sock", "fixture"), @@ -291,16 +426,18 @@ fn hosted_created_frame_uses_the_public_request_identity() { checkpoint_barrier: OnceLock::new(), ownership: OnceLock::new(), }; - proxy.forward_host_event( - AgentEvent::Provider { - payload: serde_json::json!({ - "type":"freshAgent.created", "requestId":"host-internal", "sessionId":"native" - }), - }, - "claude", - "public", - "freshclaude", - ); + proxy + .forward_host_event( + AgentEvent::Provider { + payload: serde_json::json!({ + "type":"freshAgent.created", "requestId":"host-internal", "sessionId":"native" + }), + }, + "claude", + "public", + "freshclaude", + ) + .await; assert!( receiver.try_recv().is_err(), "internal create ID must not reach the client" @@ -440,7 +577,7 @@ async fn hosted_codex_preparation_refusal_case(rest: bool, operational: bool) { session_type: "freshcodex".into(), cwd: Some(workspace.to_str().unwrap().into()), model: Some("original-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("untrusted".into()), ..Default::default() }) diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index ee9e2476f..416a7fa9d 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1469,6 +1469,12 @@ async fn main() -> ExitCode { > }, )); + #[cfg(feature = "managed-runtime-v1")] + let codex_adapter = codex_adapter.with_hosted_backend( + hosted_fresh_gateway + .as_ref() + .map(|gateway| gateway.native_name_backend()), + ); let opencode_adapter = Some(session_name_native::OpencodeNativeNameAdapter::new( agent_state.opencode_shared_handle(), )); diff --git a/crates/freshell-server/src/session_name_native.rs b/crates/freshell-server/src/session_name_native.rs index 35b9134b2..a0d3f479a 100644 --- a/crates/freshell-server/src/session_name_native.rs +++ b/crates/freshell-server/src/session_name_native.rs @@ -128,7 +128,7 @@ pub enum NativeClassified { } impl NativeClassified { - fn into_call(self) -> NativeCallResult { + pub(crate) fn into_call(self) -> NativeCallResult { match self { Self::Undelivered(reason) => NativeCallResult::Undelivered(reason), Self::Ambiguous(reason) => NativeCallResult::Ambiguous(reason), @@ -1299,11 +1299,21 @@ pub type CodexRootClientResolver = Arc< pub struct CodexNativeNameAdapter { client_for_root: CodexRootClientResolver, + hosted: Option>, } impl CodexNativeNameAdapter { pub fn new(client_for_root: CodexRootClientResolver) -> Self { - Self { client_for_root } + Self { + client_for_root, + hosted: None, + } + } + + #[cfg(feature = "managed-runtime-v1")] + pub fn with_hosted_backend(mut self, hosted: Option>) -> Self { + self.hosted = hosted; + self } } @@ -1383,7 +1393,7 @@ fn rpc_diagnoses_missing_target(error: &freshell_codex::protocol::RpcError) -> b /// failure (retryable) UNLESS it diagnoses the target as missing or the API /// as unsupported (a settled capability failure). Never reduced to a /// generic timeout or a blanket unsupported. -fn classify_codex_error( +pub(crate) fn classify_codex_error( error: freshell_codex::app_server::CodexAppServerError, what: &str, ) -> NativeClassified { @@ -1421,9 +1431,13 @@ fn classify_codex_error( impl NativeNameBackend for CodexNativeNameAdapter { fn read(&self, target: NativeNameTarget) -> NativeFuture { let resolver = Arc::clone(&self.client_for_root); + let hosted = self.hosted.clone(); Box::pin(async move { let (client, thread_id) = match codex_root_matched_thread(&resolver, &target).await { Ok(pair) => pair, + Err(_) if hosted.is_some() => { + return hosted.expect("checked hosted route").read(target).await + } Err(classified) => return classified.into_call(), }; // The metadata read rides its OWN 20-second native request bound @@ -1440,10 +1454,14 @@ impl NativeNameBackend for CodexNativeNameAdapter { fn write(&self, attempt: NativeNameAttempt) -> NativeFuture<()> { let resolver = Arc::clone(&self.client_for_root); + let hosted = self.hosted.clone(); Box::pin(async move { let (client, thread_id) = match codex_root_matched_thread(&resolver, &attempt.target).await { Ok(pair) => pair, + Err(_) if hosted.is_some() => { + return hosted.expect("checked hosted route").write(attempt).await + } Err(classified) => return classified.into_call(), }; match client.set_thread_name(&thread_id, &attempt.title).await { @@ -1459,7 +1477,16 @@ impl NativeNameBackend for CodexNativeNameAdapter { // LIVE root-matched app-server connection — the series pauses until // one exists, consuming nothing. let resolver = Arc::clone(&self.client_for_root); - Box::pin(async move { codex_root_matched_thread(&resolver, &target).await.is_ok() }) + let hosted = self.hosted.clone(); + Box::pin(async move { + if codex_root_matched_thread(&resolver, &target).await.is_ok() { + return true; + } + match hosted { + Some(hosted) => hosted.route_available(target).await, + None => false, + } + }) } } diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 621550089..ba818696c 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -178,6 +178,7 @@ async fn run() -> Result<(), String> { Some("fresh-agent-fixture-state-worker") => providers::run_fresh_agent_fixture_state_worker(&args[2..]), Some("opencode-identity-worker") => pty::run_opencode_identity_worker(&args[2..]), Some("provider-probe-worker") => providers::run_probe_worker(&args[2..]), + Some("codex-naming-proof") => providers::run_naming_proof(&args[2..]), Some("refresh-provider-config") => refresh_provider_config_worker(&args[2..]), Some("prepare-provider-features") => providers::run_provider_features_worker(&args[2..]), Some("prepare-amplifier-state") => providers::run_amplifier_state_worker(&args[2..]), @@ -744,6 +745,25 @@ async fn dispatch( .map_err(map_actor_error)?, )) } + HostCommand::FreshAgentNativeName { + incarnation_id, + native_session_id, + codex_home, + operation, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentNativeName( + actor + .native_name(&native_session_id, &codex_home, &operation) + .await, + )) + } HostCommand::FreshAgentReadSnapshot { incarnation_id } => { ensure_incarnation(&incarnation_id, state)?; let actor = state @@ -10367,6 +10387,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-configure-agent"), FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), @@ -10528,6 +10549,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("child-observer"), FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "freshcodex".into(), @@ -10606,6 +10628,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("catalog-observer"), FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "freshcodex".into(), @@ -10678,6 +10701,7 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-fixture-agent"), FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), diff --git a/crates/freshell-session-host/src/providers/cli.rs b/crates/freshell-session-host/src/providers/cli.rs index 0188d6ea5..e64e613c8 100644 --- a/crates/freshell-session-host/src/providers/cli.rs +++ b/crates/freshell-session-host/src/providers/cli.rs @@ -9,27 +9,7 @@ pub async fn probe_as_provider( run_as_gid: u32, ) -> Result { let executable = std::env::current_exe().map_err(|error| error.to_string())?; - // Fixture hosts already run as their intended workload uid/gid and have no - // SETGID capability. Calling `setpriv --clear-groups` in that case fails - // before the read-only probe starts. Real managed terminals target the - // provider uid and therefore take the capability-scoped setpriv path. - let current_uid = unsafe { libc::geteuid() }; - let current_gid = unsafe { libc::getegid() }; - let mut child = if needs_privilege_drop(run_as_uid, run_as_gid, current_uid, current_gid) { - let mut command = tokio::process::Command::new("/usr/bin/setpriv"); - command - .arg("--reuid") - .arg(run_as_uid.to_string()) - .arg("--regid") - .arg(run_as_gid.to_string()) - .arg("--clear-groups") - .arg("--no-new-privs") - .arg("--") - .arg(&executable); - command - } else { - tokio::process::Command::new(&executable) - }; + let mut child = provider_command(&executable, run_as_uid, run_as_gid); child .arg("provider-probe-worker") .arg("--provider-home") @@ -65,6 +45,34 @@ pub async fn probe_as_provider( .map_err(|error| format!("decode provider recovery probe: {error}")) } +pub(super) fn provider_command( + executable: &Path, + run_as_uid: u32, + run_as_gid: u32, +) -> tokio::process::Command { + // Fixture hosts already run as their intended workload uid/gid and have no + // SETGID capability. Calling `setpriv --clear-groups` in that case fails + // before the read-only probe starts. Real managed terminals target the + // provider uid and therefore take the capability-scoped setpriv path. + let current_uid = unsafe { libc::geteuid() }; + let current_gid = unsafe { libc::getegid() }; + if needs_privilege_drop(run_as_uid, run_as_gid, current_uid, current_gid) { + let mut command = tokio::process::Command::new("/usr/bin/setpriv"); + command + .arg("--reuid") + .arg(run_as_uid.to_string()) + .arg("--regid") + .arg(run_as_gid.to_string()) + .arg("--clear-groups") + .arg("--no-new-privs") + .arg("--") + .arg(executable); + command + } else { + tokio::process::Command::new(executable) + } +} + pub fn run_probe_worker(args: &[String]) -> Result<(), String> { let provider_home = required_arg(args, "--provider-home")?; let resume_spec: ResumeSpec = serde_json::from_reader(std::io::stdin()) diff --git a/crates/freshell-session-host/src/providers/codex.rs b/crates/freshell-session-host/src/providers/codex.rs index 2acaac4ff..50e71aea6 100644 --- a/crates/freshell-session-host/src/providers/codex.rs +++ b/crates/freshell-session-host/src/providers/codex.rs @@ -12,6 +12,54 @@ use freshell_runtime_protocol::{ResumeSpec, TerminalLaunchSpec}; use std::sync::Arc; use tokio::sync::mpsc; +/// Inspect the native recorder under its owner, without starting or resuming +/// Codex and without giving the host access to the private provider HOME. +pub async fn naming_rollout( + home: String, + thread_id: String, + uid: u32, + gid: u32, +) -> Result, String> { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let output = cli::provider_command(std::path::Path::new("/usr/bin/timeout"), uid, gid) + .args(["--kill-after=1s", "5s"]) + .arg(executable) + .args([ + "codex-naming-proof", + "--codex-home", + &home, + "--thread-id", + &thread_id, + ]) + .stdin(std::process::Stdio::null()) + .output() + .await + .map_err(|error| format!("run Codex naming proof: {error}"))?; + if !output.status.success() { + return Err(format!( + "Codex naming proof exited {:?}", + output.status.code() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode Codex naming proof: {error}")) +} + +pub fn run_naming_proof(args: &[String]) -> Result<(), String> { + let argument = |key: &str| { + args.windows(2) + .find(|pair| pair[0] == key) + .map(|pair| pair[1].as_str()) + .ok_or_else(|| format!("missing {key}")) + }; + let rollout = freshell_codex::durability::locate_thread_rollout_in_home( + std::path::Path::new(argument("--codex-home")?), + argument("--thread-id")?, + ); + serde_json::to_writer(std::io::stdout(), &rollout) + .map_err(|error| format!("encode Codex naming proof: {error}")) +} + pub struct PreparedCodexLaunch { pub sidecar: Arc, pub events: mpsc::UnboundedReceiver, diff --git a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs index 5d7e66173..ee228ab89 100644 --- a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs @@ -644,6 +644,7 @@ mod tests { #[test] fn fixture_native_identity_is_store_and_provider_specific() { let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "codex-app-server".into(), @@ -679,6 +680,7 @@ mod tests { async fn provider_native_completion_crosses_the_durable_actor_once() { let root = tempfile::tempdir().unwrap(); let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "opencode-per-soul-http".into(), diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 74076c4d7..d3628986d 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -22,6 +22,7 @@ use freshell_protocol::{ }; use freshell_runtime_protocol::{ AgentEvent, FreshAgentCapture, FreshAgentFixtureTransport, FreshAgentLaunchSpec, + FreshAgentNativeNameError, FreshAgentNativeNameOperation, FreshAgentNativeNameReply, FreshAgentRollbackDirection, FreshAgentRollbackMode, FreshProvider, ProviderLaunchContext, ProviderPreparation, RequestId, }; @@ -89,11 +90,18 @@ pub(crate) async fn open_hosted_fresh_agent( let profile = profile_from_launch(&launch); let transport: Arc = match launch.fixture_transport { None => { - HostedTransport::new_with_context( + let transport = HostedTransport::new_with_context( launch.provider, profile.provider_launch_context.as_ref(), ) - .await + .await; + if let ProviderState::Codex(state) = &transport.state { + let (uid, gid) = (launch.run_as_uid, launch.run_as_gid); + state.set_naming_rollout_probe(Arc::new(move |home, thread| { + Box::pin(super::codex::naming_rollout(home, thread, uid, gid)) + })); + } + transport } Some(FreshAgentFixtureTransport::Deterministic) => { deterministic_transport(&profile, launch.run_as_uid, launch.run_as_gid).await? @@ -109,6 +117,7 @@ pub(crate) async fn open_hosted_fresh_agent( fn profile_from_launch(launch: &FreshAgentLaunchSpec) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: launch.naming_handle.clone(), collaboration_mode: launch.collaboration_mode.clone(), provider: launch.provider.clone(), runtime_variant: launch.runtime_variant.clone(), @@ -213,7 +222,8 @@ impl HostedTransport { provider_exec_profile: true, config_args: sidecar_args, env: Default::default(), - }), + }) + .with_hosted_naming_events(), ) } FreshProvider::Opencode => { @@ -1152,6 +1162,87 @@ impl FreshAgentTransport for HostedTransport { Ok(value) } + async fn native_name( + &self, + native_session_id: &str, + codex_home: &str, + operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + let unavailable = || { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "no current root-matched native naming connection".into(), + }) + }; + if self.current_native_id().await.as_deref() != Ok(native_session_id) { + return unavailable(); + } + let ProviderState::Codex(state) = &self.state else { + return unavailable(); + }; + let Some(client) = state.management_client_for_root(codex_home).await else { + return unavailable(); + }; + // The probe inspects existing authority only; it sends no native RPC. + let result = match operation { + FreshAgentNativeNameOperation::Probe => return FreshAgentNativeNameReply::Available, + FreshAgentNativeNameOperation::Read => client + .read_thread_metadata(native_session_id) + .await + .and_then(|value| { + if value["thread"]["id"].as_str() != Some(native_session_id) { + return Err( + freshell_codex::app_server::CodexAppServerError::InvalidResponse { + method: "thread/read".into(), + detail: "native metadata names another thread".into(), + }, + ); + } + Ok(FreshAgentNativeNameReply::Title( + freshell_codex::protocol::thread_name_from_result(&value), + )) + }), + FreshAgentNativeNameOperation::Write { title } => client + .set_thread_name(native_session_id, title) + .await + .map(|()| FreshAgentNativeNameReply::Renamed), + }; + if self.current_native_id().await.as_deref() != Ok(native_session_id) { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed); + } + result.unwrap_or_else(|error| { + use freshell_codex::app_server::CodexAppServerError as Error; + let failure_kind = match &error { + Error::Rpc { .. } => "rpc", + Error::Timeout { .. } => "timeout", + Error::Closed { .. } => "closed", + Error::Transport { .. } => "transport", + Error::InvalidResponse { .. } => "invalid_response", + }; + tracing::warn!( + provider = "codex", + native_session_id, + failure_kind, + "freshagent.codex.native_name_failed" + ); + FreshAgentNativeNameReply::Failed(match error { + Error::Rpc { error, .. } => FreshAgentNativeNameError::Rpc { + code: error.code, + message: error.message, + }, + Error::Timeout { timeout_ms, .. } => { + FreshAgentNativeNameError::Timeout { timeout_ms } + } + Error::Closed { .. } => FreshAgentNativeNameError::Closed, + Error::Transport { message, .. } => { + FreshAgentNativeNameError::Transport { message } + } + Error::InvalidResponse { detail, .. } => { + FreshAgentNativeNameError::InvalidResponse { detail } + } + }) + }) + } + async fn read_child(&self, child_thread_id: &str) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -1259,7 +1350,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { request_id: format!("host-create-{}", uuid::Uuid::new_v4()), observed_epoch: None, observed_generation: None, - naming_handle: None, + naming_handle: profile.naming_handle.clone(), session_type, cwd: Some(profile.cwd.clone()), effort: if profile.provider == FreshProvider::Codex { @@ -2019,6 +2110,184 @@ mod tests { } } + #[tokio::test] + async fn task010_codex_native_name_uses_only_current_root_matched_metadata_methods() { + let log = tempfile::NamedTempFile::new().unwrap(); + let fixture = native_codex_fixture(json!({"recordTurns":true,"appendRequestLogPath":log.path(), + "overrides":{"thread/read":{"result":{"thread":{"id":"thread-new-1","name":"Native saved title"}}},"thread/name/set":{"result":{}}}})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("name-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let home = fixture + .dir + .path() + .join("codex-home") + .to_string_lossy() + .into_owned(); + let requests = || { + std::fs::read_to_string(log.path()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>() + }; + let before = requests().len(); + for (native, root) in [ + ("foreign-thread", home.as_str()), + ("thread-new-1", "/wrong/root"), + ] { + assert!(matches!( + actor + .native_name( + native, + root, + &FreshAgentNativeNameOperation::Write { + title: "Must not apply".into() + } + ) + .await, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { .. }) + )); + } + assert_eq!( + requests().len(), + before, + "stale targets perform no native work" + ); + assert_eq!( + actor + .native_name("thread-new-1", &home, &FreshAgentNativeNameOperation::Probe) + .await, + FreshAgentNativeNameReply::Available + ); + assert_eq!( + requests().len(), + before, + "capability probing is read-only and sends no native RPC" + ); + assert_eq!( + actor + .native_name("thread-new-1", &home, &FreshAgentNativeNameOperation::Read) + .await, + FreshAgentNativeNameReply::Title(Some("Native saved title".into())) + ); + assert_eq!( + actor + .native_name( + "thread-new-1", + &home, + &FreshAgentNativeNameOperation::Write { + title: "Manual title".into() + } + ) + .await, + FreshAgentNativeNameReply::Renamed + ); + let after = requests(); + assert_eq!( + after[before..] + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + vec!["thread/read", "thread/name/set"] + ); + assert_eq!( + after[before]["params"], + json!({"threadId":"thread-new-1","includeTurns":false}) + ); + assert_eq!( + after[before + 1]["params"], + json!({"threadId":"thread-new-1","name":"Manual title"}) + ); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task010_codex_verified_naming_survives_event_replay_in_retained_source() { + let mut fixture = native_codex_fixture(json!({"recordTurns":true})).await; + let native_id = uuid::Uuid::new_v4().to_string(); + let rollout = fixture + .dir + .path() + .join("codex-home/sessions") + .join(format!("rollout-{native_id}.jsonl")); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", json!({"recordTurns":true,"echoRequestedModel":true, + "threadStartThreadId":native_id,"writeRolloutOnMethods":{"turn/start":{"path":rollout, + "record":{"type":"session_meta","payload":{"id":native_id,"cwd":fixture.profile.cwd}}}} + }).to_string()); + fixture.profile.naming_handle = Some("managed-native-name".into()); + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("naming-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let observed = tokio::time::timeout(Duration::from_secs(10), async { + actor + .dispatch( + RequestId::parse("managed-naming-first").unwrap(), + "Name the managed conversation".into(), + None, + ) + .await + .unwrap(); + loop { + let snapshot = actor.snapshot().await.unwrap(); + let facts = &snapshot["extensions"]["codex"]["hostedNamingFacts"]; + if snapshot["status"] == "idle" + && facts + .as_array() + .is_some_and(|facts| facts.iter().any(|fact| fact["kind"] == "transition")) + { + break (snapshot, actor.read_events(0, 256).await); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + actor.stop().await.unwrap(); + let (snapshot, events) = + observed.expect("verified naming must remain available without another provider token"); + let facts = snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .unwrap(); + assert_eq!(facts.len(), 2); + let activity = facts + .iter() + .find(|fact| fact["kind"] == "activity") + .unwrap(); + assert_eq!( + activity["data"]["target"], + json!({"kind":"pending","id":"managed-native-name"}) + ); + assert_eq!( + activity["data"]["first_user_message"], + "Name the managed conversation" + ); + let transition = facts + .iter() + .find(|fact| fact["kind"] == "transition") + .unwrap(); + assert_eq!( + transition["data"]["target"], + json!({"kind":"session","provider":"codex","sessionId":native_id}) + ); + assert_eq!( + transition["data"]["transition"]["acquisition"]["persistence"], + "verified" + ); + assert_eq!( + transition["data"]["transition"]["pending"], + "managed-native-name" + ); + assert!(events.events.iter().any(|entry| matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["type"] == freshell_freshagent::naming::HOSTED_CODEX_NAMING_EVENT))); + } + #[tokio::test] async fn task008_settings_native_echo_survives_managed_next_send_and_reopen() { let logs = tempfile::tempdir().unwrap(); @@ -3172,6 +3441,7 @@ mod tests { fn profile(&self, resume: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), @@ -3532,6 +3802,7 @@ mod tests { .await; let transport = claude.transport.clone(); let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), @@ -3788,6 +4059,7 @@ mod tests { std::env::set_var("FAKE_OPENCODE_AUDIT_LOG", &audit); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), @@ -3991,6 +4263,7 @@ mod tests { // including the product RED and setup-error paths. let observed: Result = async { let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), model: None, effort: None, @@ -4150,6 +4423,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), @@ -4590,6 +4864,7 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), @@ -5206,6 +5481,7 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; let profile = FreshAgentProfile { + naming_handle: None, collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), diff --git a/crates/freshell-session-host/src/providers/mod.rs b/crates/freshell-session-host/src/providers/mod.rs index 7b06be80c..b1ba62b14 100644 --- a/crates/freshell-session-host/src/providers/mod.rs +++ b/crates/freshell-session-host/src/providers/mod.rs @@ -18,6 +18,7 @@ pub(crate) use deterministic_fresh_agent::run_state_worker as run_fresh_agent_fi pub(crate) use deterministic_fresh_agent::run_worker as run_fresh_agent_fixture_worker; pub(crate) use fresh_agent::open_hosted_fresh_agent; +pub(crate) use codex::run_naming_proof; pub(crate) use codex::PreparedCodexLaunch; use freshell_agent_runtime::ProviderStoreProbe; diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 9dc861038..95ad69ac0 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -1908,6 +1908,7 @@ mod tests { let binary = root.path().join("freshell-session-host"); std::fs::write(&binary, b"host").unwrap(); let agent = FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "session-one".into(), provider: FreshProvider::Codex, @@ -2257,6 +2258,7 @@ mod tests { #[test] fn fresh_agent_config_digest_survives_native_identity_and_turn_settings() { let agent = FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "presentation-one".into(), provider: FreshProvider::Codex, @@ -2399,6 +2401,7 @@ mod tests { fn fresh_agent_spec_fixture() -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "session-fixture".into(), provider: FreshProvider::Codex, diff --git a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs index f9d3dadc9..cef5d66ef 100644 --- a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs +++ b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs @@ -80,6 +80,7 @@ async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { fixture: None, terminal: None, fresh_agent: Some(FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "fresh-session".into(), provider: provider.clone(), @@ -181,11 +182,17 @@ fn fake_host( fn fake_host_with_boot_change( runtime_dir: &std::path::Path, change_boot: bool, +) -> (Arc>>, tokio::task::JoinHandle<()>) { + fake_host_with_boot(runtime_dir, change_boot, HostBootId::new()) +} +fn fake_host_with_boot( + runtime_dir: &std::path::Path, + change_boot: bool, + boot: HostBootId, ) -> (Arc>>, tokio::task::JoinHandle<()>) { let secret = b"synthetic-host-secret"; std::fs::write(runtime_dir.join("secret"), secret).unwrap(); let listener = UnixListener::bind(runtime_dir.join("host.sock")).unwrap(); - let boot = HostBootId::new(); let received = Arc::new(StdMutex::new(Vec::new())); let log = Arc::clone(&received); let task = tokio::spawn(async move { @@ -220,6 +227,27 @@ fn fake_host_with_boot_change( serde_json::json!([{"id":"provider-current","displayName":"Provider Current","supportedEffortLevels":["ultra"],"defaultEffort":"ultra"}]), ) } + HostCommand::FreshAgentNativeName { + native_session_id, + codex_home, + operation, + .. + } => { + assert_eq!(native_session_id, "name-native"); + assert_eq!(codex_home, "/owning/codex"); + HostResult::FreshAgentNativeName(match operation { + FreshAgentNativeNameOperation::Probe => { + FreshAgentNativeNameReply::Available + } + FreshAgentNativeNameOperation::Read => { + FreshAgentNativeNameReply::Title(Some("Native title".into())) + } + FreshAgentNativeNameOperation::Write { title } => { + assert_eq!(title, "Manual title"); + FreshAgentNativeNameReply::Renamed + } + }) + } HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { state: CommandState::ProviderAcked, native_session_id: Some("native-claude".into()), @@ -538,6 +566,169 @@ fn host_configure_gets_the_fresh_agent_command_budget() { })); } +#[tokio::test] +async fn admin_native_name_rejects_stale_authority_before_host_work_and_preserves_recovery() { + for scenario in [ + "read", + "write", + "probe", + "busy", + "stale-native", + "stale-boot", + "stale-incarnation", + "stale-generation", + "stale-intent", + "stale-epoch", + "wrong-provider", + "boot-changes", + ] { + let fixture = running_fresh_provider(if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "name-native".into(), + ) + .await + .unwrap(); + let view = fixture + .supervisor + .registry + .inventory() + .await + .unwrap() + .into_iter() + .find(|view| view.soul_id == fixture.soul) + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let mut source = FreshAgentNativeNameSource { + incarnation_id: view.incarnation_id.clone(), + host_boot_id: view.host_boot_id.clone().unwrap(), + execution_generation: view.execution_generation, + intent_revision: view.intent_revision, + native_session_id: "name-native".into(), + }; + let (received, host) = fake_host_with_boot( + &fixture.runtime_dir, + scenario == "boot-changes", + source.host_boot_id.clone(), + ); + match scenario { + "stale-native" => source.native_session_id = "foreign-native".into(), + "stale-boot" => source.host_boot_id = HostBootId::new(), + "stale-incarnation" => source.incarnation_id = IncarnationId::new(), + "stale-generation" => source.execution_generation += 1, + "stale-intent" => source.intent_revision += 1, + _ => {} + } + let epoch = fixture.supervisor.registry.control_epoch(); + let mut request = Envelope::new( + RequestId::new(), + ControlRole::Web, + AdminCommand::FreshAgentNativeName(FreshAgentNativeNameRequest { + soul_id: fixture.soul.clone(), + source, + codex_home: "/owning/codex".into(), + operation: match scenario { + "write" => FreshAgentNativeNameOperation::Write { + title: "Manual title".into(), + }, + "probe" => FreshAgentNativeNameOperation::Probe, + _ => FreshAgentNativeNameOperation::Read, + }, + expected_control_epoch: Some(if scenario == "stale-epoch" { + epoch + 1 + } else { + epoch + }), + }), + ); + request.auth = Some(SECRET.into()); + let lifecycle = fixture.supervisor.lifecycle_lock(&fixture.soul).await; + let _busy_guard = if scenario == "busy" { + Some(lifecycle.lock().await) + } else { + None + }; + let reply = fixture.supervisor.dispatch(request).await; + host.abort(); + let _ = host.await; + if scenario == "stale-epoch" { + assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::StaleControlEpoch + ); + } else { + let AdminResult::FreshAgentNativeName(reply) = reply.result.unwrap() else { + panic!("wrong reply") + }; + match scenario { + "read" => assert_eq!( + reply, + FreshAgentNativeNameReply::Title(Some("Native title".into())) + ), + "write" => assert_eq!(reply, FreshAgentNativeNameReply::Renamed), + "probe" => assert_eq!(reply, FreshAgentNativeNameReply::Available), + "boot-changes" => assert_eq!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed) + ), + _ => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::Unavailable { .. } + ) + )), + } + } + { + let calls = received.lock().unwrap(); + if scenario.starts_with("stale-") || scenario == "wrong-provider" || scenario == "busy" + { + assert!( + calls.is_empty(), + "{scenario}: no host/native work for stale authority" + ); + } else { + assert_eq!( + calls + .iter() + .filter(|command| matches!( + command, + HostCommand::FreshAgentNativeName { .. } + )) + .count(), + 1 + ); + } + } + assert_eq!( + fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent, + before, + "metadata does not change launch/recovery settings" + ); + } +} + #[tokio::test] async fn admin_model_catalog_uses_authenticated_current_host_and_preserves_recovery_settings() { for scenario in ["live", "stale-epoch", "replaced-boot", "wrong-provider"] { diff --git a/crates/freshell-supervisor/src/recovery.rs b/crates/freshell-supervisor/src/recovery.rs index 19d0ebbe2..50f445c3b 100644 --- a/crates/freshell-supervisor/src/recovery.rs +++ b/crates/freshell-supervisor/src/recovery.rs @@ -2424,6 +2424,7 @@ mod tests { #[test] fn queued_fresh_prompt_replays_with_the_exact_persisted_turn_profile() { let agent = FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "presentation".into(), provider: FreshProvider::Codex, diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index f1449f815..597fecfd4 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -1402,6 +1402,7 @@ impl Registry { if let Some(spec) = fresh_spec.as_mut() { spec.session_id = child_session_id.clone(); spec.native_session_id = Some(child_session_id.clone()); + spec.naming_handle = None; if let Some(reference) = spec.session_ref.as_mut() { reference.session_id = child_session_id.clone(); } @@ -3799,6 +3800,7 @@ mod tests { launch.provider = "claude".into(); launch.provider_store_id = "fresh-store".into(); launch.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "fresh-session".into(), provider: freshell_runtime_protocol::FreshProvider::Claude, @@ -4351,6 +4353,23 @@ mod tests { // soul and enclosure stay live. { let conn = open_connection(®istry.inner.db_path).unwrap(); + let encoded: String = conn + .query_row( + "SELECT fresh_agent_spec FROM incarnations WHERE incarnation_id=?1", + params![prepared.incarnation_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + let mut spec: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + spec["namingHandle"] = serde_json::json!("parent-only-pending-name"); + conn.execute( + "UPDATE incarnations SET fresh_agent_spec=?1 WHERE incarnation_id=?2", + params![ + serde_json::to_string(&spec).unwrap(), + prepared.incarnation_id.as_str() + ], + ) + .unwrap(); conn.execute( "UPDATE souls SET durability_state='checkpoint_captured', allocation_state='verified_durable', checkpoint_revision=9, resume_spec='{}' WHERE soul_id=?1", params![soul.as_str()], @@ -4367,6 +4386,17 @@ mod tests { ) .await .unwrap(); + assert!( + registry + .active_handle_for_soul(soul.clone()) + .await + .unwrap() + .fresh_agent() + .unwrap() + .naming_handle + .is_none(), + "the child launch must not reuse the parent's pending name" + ); // A retry after the transition is idempotent, but an unrelated expected parent is not. registry .transition_native_session( diff --git a/crates/freshell-supervisor/src/resume_catalog.rs b/crates/freshell-supervisor/src/resume_catalog.rs index 2c45d6890..e434b72fe 100644 --- a/crates/freshell-supervisor/src/resume_catalog.rs +++ b/crates/freshell-supervisor/src/resume_catalog.rs @@ -355,6 +355,7 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "codex-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "presentation-42".into(), provider: FreshProvider::Codex, @@ -401,6 +402,7 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "fixture-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, collaboration_mode: None, session_id: "presentation-fixture-42".into(), provider: FreshProvider::Codex, diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index a538eb2b6..27ee9cbba 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -587,6 +587,14 @@ impl Supervisor { .await?, )) } + AdminCommand::FreshAgentNativeName(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentNativeName( + self.fresh_agent_native_name(request).await?, + )) + } AdminCommand::FreshAgentReadSnapshot(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -1977,6 +1985,84 @@ impl Supervisor { } } + async fn fresh_agent_native_name( + &self, + request: freshell_runtime_protocol::FreshAgentNativeNameRequest, + ) -> Result { + use freshell_runtime_protocol::{FreshAgentNativeNameError, FreshAgentNativeNameReply}; + let unavailable = || { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "native naming source is no longer current".into(), + }) + }; + let lifecycle_lock = self.lifecycle_lock(&request.soul_id).await; + // Metadata discovery must not queue behind lifecycle work and then + // dispatch after the name worker's existing request budget expired. + let Ok(_guard) = lifecycle_lock.try_lock() else { + return Ok(unavailable()); + }; + let handle = self + .registry + .active_handle_for_soul(request.soul_id.clone()) + .await + .map_err(map_registry)?; + let before = find_view(&self.registry, handle.incarnation_id()).await?; + let matches_source = |view: &RuntimeView| { + view.incarnation_id == request.source.incarnation_id + && view.host_boot_id.as_ref() == Some(&request.source.host_boot_id) + && view.execution_generation == request.source.execution_generation + && view.intent_revision == request.source.intent_revision + && view.native_session_id.as_deref() + == Some(request.source.native_session_id.as_str()) + && view.launch_state == LaunchState::Running + && view.recovery_state == freshell_runtime_protocol::RecoveryState::Live + && view.desired_state == freshell_runtime_protocol::DesiredState::Running + }; + if !matches_source(&before) + || !handle.fresh_agent().is_some_and(|agent| { + agent.provider == freshell_runtime_protocol::FreshProvider::Codex + }) + || request.codex_home.is_empty() + { + return Ok(unavailable()); + } + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + if host.host_boot_id != request.source.host_boot_id { + return Ok(unavailable()); + } + let reply = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentNativeName { + incarnation_id: handle.incarnation_id().clone(), + native_session_id: request.source.native_session_id.clone(), + codex_home: request.codex_home.clone(), + operation: request.operation.clone(), + }, + ) + .await?; + let HostResult::FreshAgentNativeName(value) = reply else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected native naming reply", + )); + }; + let after = find_view(&self.registry, handle.incarnation_id()).await?; + let after_host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + if !matches_source(&after) || after_host.host_boot_id != host.host_boot_id { + return Ok(FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::Closed, + )); + } + Ok(value) + } + async fn fresh_agent_model_catalog( &self, soul_id: SoulId, @@ -3779,6 +3865,7 @@ pub(crate) fn uses_fresh_agent_command_budget(command: &HostCommand) -> bool { matches!( command, HostCommand::FreshAgentSend { .. } + | HostCommand::FreshAgentNativeName { .. } | HostCommand::FreshAgentCompact { .. } | HostCommand::FreshAgentConfigure { .. } | HostCommand::FreshAgentRollback { .. } diff --git a/scripts/testing/runtime-build-preflight.ts b/scripts/testing/runtime-build-preflight.ts index 11a8d5167..9d57d175a 100644 --- a/scripts/testing/runtime-build-preflight.ts +++ b/scripts/testing/runtime-build-preflight.ts @@ -57,7 +57,7 @@ export function runtimeBuildRecipesForFiles(repoRoot: string, files: string[], e return [ 'test-hosts', 'release-hosts', ...(terminal ? ['qualification-supervisor', 'qualification-server'] as const : []), - ...(fresh ? ['fresh-fixture-server'] as const : []), + ...(fresh ? ['fresh-fixture-server', 'managed-server'] as const : []), ] } diff --git a/test/integration/providers/codex-local-provider.mjs b/test/integration/providers/codex-local-provider.mjs index 94fd682b8..739ad1c89 100644 --- a/test/integration/providers/codex-local-provider.mjs +++ b/test/integration/providers/codex-local-provider.mjs @@ -5,6 +5,7 @@ import { createInterface } from 'node:readline' import { fileURLToPath } from 'node:url' import path from 'node:path' import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' export const MCP_LARGE_TEXT = 'F1_MCP_HEAD\n' + 'm'.repeat(128 * 1024) + '\nF1_MCP_TAIL' export const MCP_FORM_CONTENT = { name: 'fixture', count: 2.5, enabled: true, color: 'blue' } @@ -65,7 +66,7 @@ function serveMcpFixture(tracePath, url, holdStartup = false) { }) } -export async function createCodexLocalProvider(handler) { +export async function createCodexLocalProvider(handler, { port = 0 } = {}) { const traffic = [] let sequence = 0 const sockets = new Set() @@ -121,11 +122,67 @@ export async function createCodexLocalProvider(handler) { } }) server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)) }) - await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve) }) return { baseUrl: `http://127.0.0.1:${server.address().port}/v1`, traffic, async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)) }, } } +/** Run the model-wire fixture beside the actual managed provider, never on host localhost. */ +export async function startInManagedContext(rig, source) { + const { containerId, handlerSource, port = 51991 } = source + assert.match(containerId, /^[a-f0-9]{64}$/) + assert.equal(typeof handlerSource, 'string') + assert(Number.isSafeInteger(port) && port > 1024 && port < 65536) + const directory = `/home/freshell/provider/.freshell-fixture/codex-wire-${randomUUID()}` + const trafficPath = `${directory}/traffic.jsonl` + const readyPath = `${directory}/ready.json` + // Import the original ESM bytes: Vitest's transformed function.toString() + // references its host-only module loader and cannot run inside the provider. + const moduleUrl = `data:text/javascript;base64,${fs.readFileSync(fileURLToPath(import.meta.url)).toString('base64')}` + const code = `import fs from 'node:fs'; + const {createCodexLocalProvider} = await import(${JSON.stringify(moduleUrl)}); + const handle = (${handlerSource}); + const fixture = await createCodexLocalProvider(async (record, context) => { + fs.appendFileSync(${JSON.stringify(trafficPath)}, JSON.stringify(record) + '\\n'); + return handle(record, context); + }, {port:${port}}); + fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({baseUrl:fixture.baseUrl,uid:process.getuid(),gid:process.getgid(),pid:process.pid,node:process.version})); + process.once('SIGTERM', async () => { await fixture.close(); fs.writeFileSync(${JSON.stringify(`${directory}/closed`)},'closed');process.exit(0); });` + rig.ownedProviderExec(containerId, ['node', '-e', ` + const fs=require('node:fs'),{spawn}=require('node:child_process'); + fs.mkdirSync(${JSON.stringify(directory)}, {recursive:true,mode:0o700}); + const log=fs.openSync(${JSON.stringify(`${directory}/stderr`)},'a',0o600); + const child=spawn(process.execPath,['--input-type=module','-e',${JSON.stringify(code)}],{detached:true,stdio:['ignore','ignore',log]}); + fs.writeFileSync(${JSON.stringify(`${directory}/pid`)},String(child.pid));child.unref();`]) + let ready + const deadline = Date.now() + 30_000 + while (!ready && Date.now() < deadline) { + ready = JSON.parse(rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');process.stdout.write(fs.existsSync(${JSON.stringify(readyPath)})?fs.readFileSync(${JSON.stringify(readyPath)},'utf8'):'null')`])) + if (!ready) await new Promise(resolve => setTimeout(resolve, 50)) + } + assert(ready, `managed Codex loopback fixture did not start: ${rig.ownedProviderExec(containerId, ['cat', `${directory}/stderr`])}`) + assert.equal(ready.uid, 65534) + assert.equal(ready.gid, 0) + return { + baseUrl: ready.baseUrl, + context: ready, + get traffic() { + return JSON.parse(rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');const p=${JSON.stringify(trafficPath)};process.stdout.write(JSON.stringify(fs.existsSync(p)?fs.readFileSync(p,'utf8').trim().split('\\n').filter(Boolean).map(JSON.parse):[]))`])) + }, + async stop() { + if (!rig.runtime.isContainerRunning(containerId)) return + // This PID was created and recorded by this invocation in the owned namespace. + rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');const p=Number(fs.readFileSync(${JSON.stringify(`${directory}/pid`)},'utf8'));if(p!==${ready.pid})throw Error('fixture PID changed');try{process.kill(p,'SIGTERM')}catch(e){if(e.code!=='ESRCH')throw e}`]) + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (rig.ownedProviderExec(containerId, ['node', '-e', `process.stdout.write(String(require('node:fs').existsSync(${JSON.stringify(`${directory}/closed`)})))`]) === 'true') return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`managed Codex loopback fixture did not close: ${containerId}/${ready.pid}`) + }, + } +} + if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && ['--mcp-fixture', '--mcp-startup-fixture'].includes(process.argv[2])) serveMcpFixture(process.argv[3], process.argv[4], process.argv[2] === '--mcp-startup-fixture') diff --git a/test/integration/server/fresh-agent-parity.test.ts b/test/integration/server/fresh-agent-parity.test.ts index 6df1d9599..f6be4fde1 100644 --- a/test/integration/server/fresh-agent-parity.test.ts +++ b/test/integration/server/fresh-agent-parity.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' +import http from 'node:http' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' import { pathToFileURL } from 'node:url' @@ -13,6 +14,8 @@ import { authorizeFreshAgentParityRequest, captureProviderParityVolumes, cleanup import { makeProviderParityResourcesReadable, prepareProviderParityUserRoots, supervisorRegistrySecretScan } from '../../helpers/provider-parity-environment.js' import { exposeGrantsToHarness, fakeOnecliGrants, withMissingGrant } from '../../helpers/provider-parity-onecli.js' import type { InitialProviderVolumeAuthorization } from '../../../scripts/testing/runtime-test-broker.js' +// @ts-expect-error The native model-wire fixture is also used by the offline Node runner. +import { startInManagedContext } from '../providers/codex-local-provider.mjs' const root = path.resolve(import.meta.dirname, '../../..') const providers = [ @@ -32,7 +35,7 @@ async function waitFor(label: string, probe: () => T | undefined | Promise { @@ -352,3 +355,261 @@ describe('hosted fresh-agent provider inputs', () => { } }, 240_000) }) + +describe('installed Codex through the managed gateway and durable actor', () => { + let rig: ManagedRuntimeBrowserRig + let home: string + let workspace: string + let grants: ReturnType + let restoreGrants: (() => void) | undefined + let source: ProviderParitySource + let wire: FreshWire + let model: Awaited> | undefined + let naming: http.Server + const namingRequests: string[] = [] + const owners: InitialProviderVolumeAuthorization[] = [] + + beforeAll(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'freshcodex-managed-native-')) + workspace = path.join(home, 'workspace') + fs.mkdirSync(workspace) + execFileSync('git', ['init', '--quiet', workspace]) + const xdg = path.join(home, 'xdg') + prepareProviderParityUserRoots(home, xdg) + grants = fakeOnecliGrants(['codex']) + restoreGrants = exposeGrantsToHarness(grants.serverEnv) + naming = http.createServer(async (request, response) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + namingRequests.push(Buffer.concat(chunks).toString('utf8')) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ candidates: [{ content: { parts: [{ text: 'Managed native suggested name' }] } }] })) + }) + await new Promise(resolve => naming.listen(0, '127.0.0.1', resolve)) + const namingAddress = naming.address() + if (!namingAddress || typeof namingAddress === 'string') throw new Error('Owned naming fixture has no TCP port') + rig = new ManagedRuntimeBrowserRig(root, 2, + { XDG_CONFIG_HOME: xdg, ...grants.serverEnv, GOOGLE_GENERATIVE_AI_API_KEY: 'managed-naming-fixture', + FRESHELL_GEMINI_BASE_URL: `http://127.0.0.1:${namingAddress.port}` }, { XDG_CONFIG_HOME: xdg }, 'test', + { enabledProviders: [], freshAgentModes: ['freshcodex'], fixtureFreshAgentModes: [] }, home, + { workspaceRoots: [workspace], providerInputRoots: [], bootstrapFiles: Object.values(grants.grants), defaultCwd: workspace }) + await rig.start() + const health = await rig.runtime.adminOk(rig.supervisor, { method: 'health' }) + source = await readProviderParitySource(rig.runtime, health.data.installation_id) + const catalog = path.join(home, '.codex', 'models.json') + fs.writeFileSync(catalog, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Managed native model', + description: 'Owned native managed contract', default_reasoning_level: 'low', + supported_reasoning_levels: [{ effort: 'low', description: 'Low' }, { effort: 'xhigh', description: 'Extra high' }], + shell_type: 'unified_exec', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, + truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, + experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, + tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: 'Use the available tools to follow the user request.' } }] })) + fs.writeFileSync(path.join(home, '.codex/config.toml'), `model = "contract-model" +model_provider = "contract-loopback" +approval_policy = "untrusted" +model_catalog_json = ${JSON.stringify(catalog)} +web_search = "disabled" +[features] +apps = false +default_mode_request_user_input = true +unified_exec = true +[tools.experimental_request_user_input] +enabled = true +[model_providers.contract-loopback] +name = "Managed owned loopback" +base_url = "http://127.0.0.1:51991/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +request_max_retries = 0 +stream_max_retries = 0 +`) + makeProviderParityResourcesReadable(home) + wire = await FreshWire.connect(rig.info) + }, 900_000) + + afterAll(async () => { + try { + await wire?.close() + await model?.stop() + if (rig) { + const volumes = captureProviderParityVolumes(owners) + const cleanup = await rig.stop() + expect(cleanup.ok, cleanup.errors.join('\n')).toBe(true) + cleanupProviderParityVolumes(volumes) + } + } finally { + if (naming) { + naming.closeAllConnections() + await new Promise(resolve => naming.close(() => resolve())) + } + restoreGrants?.() + if (grants) fs.rmSync(grants.directory, { recursive: true, force: true }) + if (home) fs.rmSync(home, { recursive: true, force: true }) + } + }, 180_000) + + it('uses the installed wrapper for catalog, settings, saved output and exact native controls', async () => { + const requestId = `native-managed-${randomUUID()}` + owners.push(authorizeFreshAgentParityRequest(rig.runtime.broker, source, 'codex', requestId)) + wire.send({ type: 'freshAgent.create', requestId, provider: 'codex', sessionType: 'freshcodex', + cwd: workspace, model: 'contract-model', effort: 'xhigh', sandbox: 'workspace-write', permissionMode: 'untrusted', + namingHandle: `name-${requestId}`, tabId: `tab-${requestId}` }) + const created = await wire.wait(frame => frame.requestId === requestId + && ['freshAgent.created', 'freshAgent.create.failed'].includes(frame.type)) + expect(created.type, JSON.stringify(created)).toBe('freshAgent.created') + const view = await waitFor('native managed provider', async () => { + const row = await rig.runningViewForFreshSession(created.sessionId) + return row?.containerId && row.nativeSessionId ? row : undefined + }) + expect(rig.ownedProviderExec(view.containerId!, ['/opt/freshell-codex/launch', '--version'])).toContain('0.164.0') + const nativeContext = JSON.parse(rig.ownedProviderExec(view.containerId!, ['node', '-e', ` + const fs=require('node:fs');const layout=JSON.parse(fs.readFileSync('/opt/freshell-codex/layout.json','utf8')); + const found=fs.readdirSync('/proc').filter(p=>Number.isInteger(Number(p))).flatMap(pid=>{try{ + if(fs.readlinkSync('/proc/'+pid+'/exe')!==layout.nativeExecutable)return[]; + if(!fs.readFileSync('/proc/'+pid+'/cmdline','utf8').split(String.fromCharCode(0)).includes('app-server'))return[]; + const status=fs.readFileSync('/proc/'+pid+'/status','utf8');const field=k=>status.split(String.fromCharCode(10)).find(line=>line.startsWith(k+':'))?.slice(k.length+1).trim(); + return[{pid:Number(pid),uid:Number(field('Uid')?.split(String.fromCharCode(9))[0]),gid:Number(field('Gid')?.split(String.fromCharCode(9))[0]), + executable:layout.nativeExecutable,cwd:fs.readlinkSync('/proc/'+pid+'/cwd'),noNewPrivs:field('NoNewPrivs'),capEff:field('CapEff')}]; + }catch{return[]}});if(found.length!==1)throw Error('Expected one actual installed native Codex app-server');process.stdout.write(JSON.stringify(found[0]));`])) + expect(nativeContext).toMatchObject({ uid: 65534, gid: 0, cwd: workspace, noNewPrivs: '1', capEff: '0000000000000000' }) + model = await startInManagedContext(rig, { containerId: view.containerId!, handlerSource: `async record => { + const input=record.body.input; + const user=input.filter(item=>item.role==='user').at(-1); + const prompt=JSON.stringify(user); + const call=(name,args,id)=>{ + let namespace;let tool=record.body.tools.find(tool=>tool.type==='function'&&tool.name===name); + if(!tool){const group=record.body.tools.find(tool=>tool.type==='namespace'&&tool.tools?.some(member=>member.name===name));tool=group?.tools.find(member=>member.name===name);namespace=group?.name;} + if(!tool)throw Error('Native model did not advertise '+name); + return {items:[{id:'fc_'+id,type:'function_call',call_id:'call_'+id,...(namespace?{namespace}:{}),name,arguments:JSON.stringify(args),status:'completed'}]}; + }; + if(prompt.includes('AWAITED_PLAN')&&!input.some(item=>item.call_id==='call_awaited'))return call('request_user_input',{questions:[{id:'managed_choice',header:'Choice',question:'Which managed result?',options:[{label:'Alpha',description:'First result'},{label:'Beta',description:'Second result'}]}]},'awaited'); + if(prompt.includes('ASYNC_QUESTION')&&!input.some(item=>item.call_id==='call_async'))return call('request_user_input_async',{questions:[{title:'Which managed environment?',options:['Staging','Production']}]},'async'); + return {text:prompt.includes('ASYNC_QUESTION')?'Managed work continued before the answer.':prompt.includes('AWAITED_PLAN')?'Managed awaited answer completed.':'Managed native final answer.'}; + }` }) + expect(model.context).toMatchObject({ uid: 65534, gid: 0 }) + const headers = { 'x-auth-token': rig.info.token } + const snapshot = async () => { + const response = await fetch(`${rig.info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${created.sessionId}`, { headers }) + expect(response.ok, await response.clone().text()).toBe(true) + return response.json() + } + const catalogResponse = await fetch(`${rig.info.baseUrl}/api/fresh-agent/model-capabilities/freshcodex?${new URLSearchParams({ sessionId: created.sessionId, sessionRef: JSON.stringify(created.sessionRef), cwd: workspace })}`, { headers }) + expect(catalogResponse.ok, await catalogResponse.clone().text()).toBe(true) + expect((await catalogResponse.json()).models).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'contract-model', supportedEffortLevels: ['low', 'xhigh'] })])) + const send = async (text: string, settings?: unknown) => { + const id = `send-${randomUUID()}` + wire.send({ type: 'freshAgent.send', sessionType: 'freshcodex', provider: 'codex', sessionId: created.sessionId, requestId: id, text, ...(settings ? { settings } : {}) }) + const accepted = await wire.wait(frame => frame.requestId === id && ['freshAgent.send.accepted', 'error'].includes(frame.type)) + expect(accepted.type, JSON.stringify(accepted)).toBe('freshAgent.send.accepted') + return accepted + } + await send('MANAGED_NATIVE_FIRST') + const saved = await waitFor('managed native saved first answer', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed native final answer.') ? value : undefined + }) + expect(saved.settings).toMatchObject({ model: 'contract-model', effort: 'xhigh', sandbox: 'workspace-write', permissionMode: 'untrusted', cwd: workspace }) + expect(model.traffic[0].body.reasoning.effort).toBe('xhigh') + await send('AWAITED_PLAN', { collaborationMode: 'plan' }) + const pending = await waitFor('managed native awaited Plan control', async () => { + const value = await snapshot() + return value.pendingQuestions?.length ? value.pendingQuestions[0] : undefined + }) + expect(pending.questions[0].id).toBe('managed_choice') + wire.send({ type: 'freshAgent.question.respond', sessionType: 'freshcodex', provider: 'codex', sessionId: created.sessionId, + requestId: pending.requestId, answers: { managed_choice: 'Alpha' } }) + await waitFor('managed native awaited decision drained', async () => { + const value = await snapshot() + return value.status === 'idle' && !value.pendingQuestions.length && JSON.stringify(value.turns).includes('Managed awaited answer completed.') ? true : undefined + }) + await send('ASYNC_QUESTION', { collaborationMode: 'default' }) + const asyncSnapshot = await waitFor('managed native async continuation without answer', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed work continued before the answer.') ? value : undefined + }) + expect(asyncSnapshot.pendingQuestions).toHaveLength(0) + expect(JSON.stringify(asyncSnapshot.turns)).toContain('Which managed environment?') + await send('Question: Which managed environment? Answer: Staging.', { effort: null }) + await waitFor('managed contextual answer at loopback model', () => model!.traffic.some((row: any) => JSON.stringify(row.body.input).includes('Answer: Staging.')) ? true : undefined) + await waitFor('managed contextual answer completion', async () => (await snapshot()).status === 'idle' ? true : undefined) + const resumedResponse = await fetch(`${rig.info.baseUrl}/api/tabs`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'codex', cwd: workspace, sessionRef: { provider: 'codex', sessionId: view.nativeSessionId } }) }) + expect(resumedResponse.ok, await resumedResponse.clone().text()).toBe(true) + const resumed = await resumedResponse.json() + const paneId = resumed.data?.paneId ?? resumed.paneId + expect(paneId).toBeTruthy() + const captureResponse = await fetch(`${rig.info.baseUrl}/api/panes/${paneId}/capture`, { headers }) + expect(captureResponse.ok, await captureResponse.clone().text()).toBe(true) + expect(await captureResponse.text()).toContain('Managed native final answer.') + const durableNameRef = { kind: 'session', provider: 'codex', sessionId: view.nativeSessionId } + const readName = async () => { + const response = await fetch(`${rig.info.baseUrl}/api/session-names/read`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ refs: [durableNameRef] }) }) + expect(response.ok).toBe(true) + return (await response.json()).names[0]?.record + } + const nativeMetadata = JSON.parse(rig.ownedProviderExec(view.containerId!, ['node', '-e', ` + const fs=require('node:fs'),path=require('node:path'); + const env=Object.fromEntries(fs.readFileSync('/proc/${nativeContext.pid}/environ','utf8').split(String.fromCharCode(0)).flatMap(entry=>{ + const at=entry.indexOf('=');const key=entry.slice(0,at);return ['HOME','CODEX_HOME'].includes(key)?[[key,entry.slice(at+1)]]:[]; + })); + const errors=[],headers=[],directories=[];let visited=0; + const stat=p=>{const s=fs.statSync(p);return{uid:s.uid,gid:s.gid,mode:(s.mode&511).toString(8)}}; + const roots=[...new Set([env.CODEX_HOME,env.HOME&&path.join(env.HOME,'.codex')].filter(Boolean))]; + const walk=(dir,depth)=>{if(depth>8||visited++>1024)return;try{ + directories.push({path:dir,...stat(dir)}); + for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ + const file=path.join(dir,entry.name);if(entry.isDirectory())walk(file,depth+1); + else if(entry.isFile()&&entry.name.endsWith('.jsonl')){ + const fd=fs.openSync(file,'r');let first;try{const b=Buffer.alloc(65536);first=b.toString('utf8',0,fs.readSync(fd,b,0,b.length,0)).split(String.fromCharCode(10))[0]}finally{fs.closeSync(fd)} + let meta;try{meta=JSON.parse(first)}catch{} + headers.push({path:file,...stat(file),type:meta?.type,id:meta?.payload?.id??meta?.payload?.session_id}); + } + } + }catch(error){errors.push({path:dir,code:error.code})}}; + for(const root of roots)walk(path.join(root,'sessions'),0); + const hostStatus=fs.readFileSync('/proc/1/status','utf8').split(String.fromCharCode(10)).filter(line=>/^(Uid|Gid|CapEff|NoNewPrivs):/.test(line)); + process.stdout.write(JSON.stringify({env,roots,directories,headers,errors,hostStatus}));`])) + const hostMetadataAccess = JSON.parse(rig.ownedContainerExec(view.containerId!, ['node', '-e', ` + const fs=require('node:fs');const root='/home/freshell/provider';const s=fs.statSync(root); + let readError;try{fs.readdirSync(root+'/.codex/sessions')}catch(error){readError=error.code} + process.stdout.write(JSON.stringify({uid:process.getuid(),root:{uid:s.uid,gid:s.gid,mode:(s.mode&511).toString(8)},readError}));`])) + expect(hostMetadataAccess).toMatchObject({ uid: 0, root: { uid: 65534, mode: '700' }, readError: 'EACCES' }) + expect(nativeMetadata.headers).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'session_meta', id: view.nativeSessionId })])) + rig.runtime.writeIncident('freshcodex-managed-native-before-naming', { created, view, nativeContext, nativeMetadata, fixtureContext: model.context, saved, asyncSnapshot, + hostMetadataAccess, traffic: model.traffic, final: await snapshot() }) + const durableName = await waitFor('managed native durable shared name', async () => { + const record = await readName() + return record?.ref?.sessionId === view.nativeSessionId ? record : undefined + }, 15_000) + expect(durableName.ref).toEqual(durableNameRef) + await waitFor('managed first-message naming request', () => namingRequests.some(body => body.includes('MANAGED_NATIVE_FIRST')) ? true : undefined) + const renamed = await fetch(`${rig.info.baseUrl}/api/session-names`, { method: 'PATCH', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ target: durableNameRef, name: 'Managed native saved name', nameIntent: 'user' }) }) + expect(renamed.ok, await renamed.clone().text()).toBe(true) + const canonicalName = await readName() + expect(canonicalName).toMatchObject({ ref: durableNameRef, name: 'Managed native saved name', source: 'manual' }) + const readNativeName = () => JSON.parse(rig.ownedProviderExec(view.containerId!, ['node', '-e', ` + const fs=require('node:fs');const argv=fs.readFileSync('/proc/${nativeContext.pid}/cmdline','utf8').split(String.fromCharCode(0)); + const endpoint=argv[argv.indexOf('--listen')+1];const target=new URL(endpoint); + if(target.protocol!=='ws:'||target.hostname!=='127.0.0.1'||!target.port)throw Error('Expected the existing native loopback endpoint'); + const socket=new WebSocket(endpoint);let settled=false; + const finish=(error,value)=>{if(settled)return;settled=true;clearTimeout(timer);socket.close(); + if(error){process.stderr.write(String(error));process.exit(1)}process.stdout.write(JSON.stringify(value),()=>process.exit(0));}; + const timer=setTimeout(()=>finish('Existing native metadata observer timed out'),8000); + socket.addEventListener('error',()=>finish('Existing native metadata connection failed')); + socket.addEventListener('open',()=>socket.send(JSON.stringify({id:1,method:'initialize',params:{clientInfo:{name:'freshell_name_read_probe',version:'1'}}}))); + socket.addEventListener('message',event=>{const frame=JSON.parse(String(event.data));if(frame.error)return finish(frame.error.message); + if(frame.id===1){socket.send(JSON.stringify({method:'initialized'}));socket.send(JSON.stringify({id:2,method:'thread/read',params:{threadId:${JSON.stringify(view.nativeSessionId)},includeTurns:false}}));} + if(frame.id===2)finish(null,{id:frame.result.thread.id,title:frame.result.thread.name??null});});`])) + const nativeNameBeforeWait = readNativeName() + rig.runtime.writeIncident('freshcodex-managed-native-name-writeback', { canonicalName, nativeNameBeforeWait, view, nativeContext }) + await waitFor('managed native name writeback', () => { + const observation = readNativeName() + return observation.id === view.nativeSessionId && observation.title === canonicalName.name ? observation : undefined + }, 15_000) + rig.runtime.writeIncident('freshcodex-managed-native-journey', { created, view, nativeContext, context: model.context, + traffic: model.traffic, saved, asyncSnapshot, canonicalName, nativeName: readNativeName(), final: await snapshot() }) + }, 240_000) +}) From 5a878ef27fde7ff593f30067834c1be4e106b1fa Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:19:15 -0700 Subject: [PATCH 55/92] fix(runtime): answer retained controls before delayed admission --- .../freshell-agent-runtime/src/host_actor.rs | 58 ++++++++ .../src/host_actor_tests.rs | 137 ++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 0f4e1439b..22a09b18e 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -1374,11 +1374,69 @@ impl FreshAgentHostActor { Ok(sequence) } + async fn record_retained_permission(&self, decision_id: &str) -> Result<(), ActorError> { + // A reconnect/snapshot can expose a native control before the + // asynchronous event pump journals it. Reconcile only on the user + // command, from the existing live source; reads stay side-effect free. + let before = self.profile().await; + if before.provider != FreshProvider::Codex || !self.transport.is_live().await { + return Ok(()); + } + let Some(retained) = self + .transport + .retained_state() + .await + .map_err(ActorError::Transport)? + else { + return Ok(()); + }; + let after = self.profile().await; + if before.native_session_id.is_none() + || before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || before.provider != after.provider + || !self.transport.is_live().await + || retained["snapshot"]["threadId"].as_str() != before.native_session_id.as_deref() + || retained["snapshot"]["provider"] != "codex" + || retained["snapshot"]["sessionType"] != "freshcodex" + { + return Ok(()); + } + let Some(payload) = retained["pendingControls"].as_array().and_then(|pending| { + pending.iter().find(|payload| { + payload["requestId"].as_str() == Some(decision_id) + && payload["sessionId"].as_str() == before.native_session_id.as_deref() + && matches!( + payload["type"].as_str(), + Some( + "freshAgent.permission.request" + | "freshAgent.question.request" + | "freshAgent.elicitation.request" + ) + ) + }) + }) else { + return Ok(()); + }; + self.record_permission(decision_id.to_owned(), payload.clone()) + .await?; + tracing::info!( + provider = "codex", + request_id = decision_id, + native_session_id = before.native_session_id.as_deref(), + "freshagent.host.control_admitted_from_retained" + ); + Ok(()) + } + pub async fn resolve_permission( &self, decision_id: &str, decision: Value, ) -> Result<(), ActorError> { + if !self.state.lock().await.decisions.contains_key(decision_id) { + self.record_retained_permission(decision_id).await?; + } let response_digest = digest(&serde_json::to_vec(&decision).map_err(persist)?); { let mut state = self.state.lock().await; diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 70b885cca..7fb2c79f1 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -542,6 +542,7 @@ struct FakeTransport { dispatches: AtomicUsize, checkpoint_attempts: std::sync::Mutex>>, decisions: AtomicUsize, + retained: std::sync::Mutex>, stops: AtomicUsize, ambiguous_dispatch: bool, live: std::sync::atomic::AtomicBool, @@ -642,6 +643,7 @@ impl FakeTransport { dispatches: AtomicUsize::new(0), checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), + retained: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -657,6 +659,7 @@ impl FakeTransport { dispatches: AtomicUsize::new(0), checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), + retained: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -719,6 +722,9 @@ impl FreshAgentTransport for FakeTransport { async fn is_live(&self) -> bool { self.live.load(Ordering::SeqCst) } + async fn retained_state(&self) -> Result, String> { + Ok(self.retained.lock().unwrap().clone()) + } async fn stop(self: Arc) -> Result<(), String> { self.stops.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -1080,6 +1086,137 @@ async fn pending_permission_persists_and_is_answered_exactly_once_after_gateway_ assert_eq!(transport.decisions.load(Ordering::SeqCst), 1); } +#[tokio::test] +async fn snapshot_control_can_be_answered_before_its_delayed_journal_event() { + for (scenario, event_type) in [ + ("current", "freshAgent.permission.request"), + ("current", "freshAgent.question.request"), + ("current", "freshAgent.elicitation.request"), + ("unknown", "freshAgent.permission.request"), + ("foreign-native", "freshAgent.permission.request"), + ("foreign-provider", "freshAgent.permission.request"), + ("foreign-type", "freshAgent.permission.request"), + ("foreign-parent", "freshAgent.permission.request"), + ("invalid-kind", "freshAgent.status"), + ("dead", "freshAgent.permission.request"), + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = FakeTransport::new("parent-native"); + let payload = serde_json::json!({ + "type":event_type, "sessionId":if scenario=="foreign-parent" {"another-parent"} else {"parent-native"}, + "requestId":"child-request", "providerRequest":{"id":15,"threadId":"registered-child","turnId":"child-turn"}, + "native":{"availableDecisions":["accept","cancel"]} + }); + *transport.retained.lock().unwrap() = Some(serde_json::json!({ + "source":{"projectionId":"current-source","ownerEpoch":1,"ownerGeneration":2}, + "snapshot":{"provider":if scenario=="foreign-provider" {"claude"} else {"codex"},"sessionType":if scenario=="foreign-type" {"freshclaude"} else {"freshcodex"},"threadId":if scenario=="foreign-native" {"old-native"} else {"parent-native"},"pendingApprovals":[payload.clone()]}, + "pendingControls":[payload.clone()] + })); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "retained-control", None), + transport.clone(), + ) + .await + .unwrap(); + if scenario == "dead" { + transport.live.store(false, Ordering::SeqCst); + } + // The authoritative retained snapshot is visible while the event pump + // has not delivered PermissionRequested. A response must reconcile that + // exact current control, without turning snapshot reads into writes. + assert!(actor.state.lock().await.decisions.is_empty()); + let id = if scenario == "unknown" { + "invented-request" + } else { + "child-request" + }; + let decision = serde_json::json!({"nativeDecision":"accept"}); + let result = actor.resolve_permission(id, decision.clone()).await; + if scenario != "current" { + assert!(result.is_err(), "{scenario} must not admit a decision"); + assert!( + actor.state.lock().await.decisions.is_empty(), + "{scenario} must not persist an unproved control" + ); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0, "{scenario}"); + continue; + } + result.expect("a current snapshot control must be immediately answerable"); + // The delayed event and duplicate user click must not send twice. + actor.record_permission(id.into(), payload).await.unwrap(); + actor.resolve_permission(id, decision).await.unwrap(); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 1); + assert_eq!(transport.starts.load(Ordering::SeqCst), 1); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert!(matches!( + actor + .resolve_permission(id, serde_json::json!({"nativeDecision":"cancel"})) + .await, + Err(ActorError::DecisionAlreadyResolved) + )); + } +} + +#[tokio::test] +async fn retained_control_admission_rejects_source_change_during_read() { + for changed in ["native", "store", "dead"] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":{ + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-held"}, + "pendingControls":[{"type":"freshAgent.permission.request","sessionId":"native-held","requestId":"held-request"}] + }}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("native-held".into()), + registered: None, + pause: Some((entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "held-store", None), + transport.clone(), + ) + .await + .unwrap(); + let response = tokio::spawn({ + let actor = actor.clone(); + async move { + actor + .resolve_permission( + "held-request", + serde_json::json!({"nativeDecision":"accept"}), + ) + .await + } + }); + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + match changed { + "native" => { + actor.state.lock().await.profile.native_session_id = + Some("replacement-native".into()) + } + "store" => { + actor.state.lock().await.profile.provider_store_id = "replacement-store".into() + } + "dead" => transport.live.store(false, Ordering::SeqCst), + _ => unreachable!(), + } + release.notify_one(); + assert_eq!( + response.await.unwrap(), + Err(ActorError::UnknownDecision), + "{changed}" + ); + assert!(actor.state.lock().await.decisions.is_empty(), "{changed}"); + } +} + #[tokio::test] async fn ambiguous_accepted_input_is_never_dispatched_twice() { let dir = tempfile::tempdir().unwrap(); From ec2c891b945fb99d42bb26f99e2a47585f60f9aa Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:46:01 -0700 Subject: [PATCH 56/92] fix(runtime): verify saved Codex state after host restart --- .../src/host_restart_recovery_tests.rs | 275 ++++++++++++++++++ crates/freshell-supervisor/src/recovery.rs | 111 +++++-- 2 files changed, 359 insertions(+), 27 deletions(-) create mode 100644 crates/freshell-supervisor/src/host_restart_recovery_tests.rs diff --git a/crates/freshell-supervisor/src/host_restart_recovery_tests.rs b/crates/freshell-supervisor/src/host_restart_recovery_tests.rs new file mode 100644 index 000000000..4e79bd305 --- /dev/null +++ b/crates/freshell-supervisor/src/host_restart_recovery_tests.rs @@ -0,0 +1,275 @@ +// An authenticated restarted host can inspect its owned store without +// claiming that a worker survived. Probe/recovery uses the real registry/IPC. + +async fn unconfirmed_codex_supervisor( + root: &std::path::Path, + native: Option<&str>, +) -> (Supervisor, RecoveryContext) { + use crate::registry::{BackendCreatedRecord, LaunchPreparation, Registry}; + use freshell_runtime_protocol::*; + let registry = Registry::open(root.join("registry"), None).unwrap(); + let soul = SoulId::new(); + let launch: FreshAgentLaunchSpec = serde_json::from_value(serde_json::json!({ + "sessionId":"managed-codex-stable", "provider":"codex", "sessionType":"freshcodex", + "runtimeVariant":"codex-app-server", "providerStoreId":"restart-store", + "cwd":root, "workspacePath":root, "runAsUid":65534, "runAsGid":0, + "nativeSessionId":native, "model":"catalog-model", "effort":"xhigh", + "permissionMode":"untrusted", "sandbox":"workspace-write", "collaborationMode":"plan" + })) + .unwrap(); + let prepared = registry + .prepare_launch(LaunchPreparation { + soul_id: soul.clone(), + provider: "codex".into(), + provider_store_id: "restart-store".into(), + native_session_id: native.map(str::to_owned), + creation_seed_ref: "original-seed".into(), + request_id: RequestId::new(), + payload_digest: "restart-fixture".into(), + requested_limits: checkpoints::tests::limits(), + profile: RuntimeProfile::Custom, + project_key: "fixture".into(), + fixture: None, + terminal: None, + fresh_agent: Some(launch), + view_intent: None, + admission: crate::admission::AdmissionPolicy::default(), + }) + .await + .unwrap(); + let runtime = root.join("runtime"); + std::fs::create_dir_all(&runtime).unwrap(); + std::fs::write(runtime.join("host"), b"synthetic-host").unwrap(); + registry + .commit_created( + prepared.incarnation_id.clone(), + BackendCreatedRecord { + daemon_id: DockerDaemonId::new(), + container_id: "a".repeat(64), + image_ref: format!("sha256:{}", "b".repeat(64)), + runtime_dir: runtime.clone(), + host_binary_path: runtime.join("host"), + immutable_config_digest: format!("sha256:{}", "c".repeat(64)), + }, + ) + .await + .unwrap(); + registry + .commit_execution_grant( + prepared.incarnation_id.clone(), + HostBootId::new(), + checkpoints::tests::limits(), + ) + .await + .unwrap(); + registry + .mark_running(prepared.incarnation_id) + .await + .unwrap(); + let context = registry.recovery_context(soul).await.unwrap(); + recovery_supervisor(root, registry, context, BackendRuntimeState::Running).await +} + +#[tokio::test] +async fn authenticated_unconfirmed_worker_probes_exact_saved_codex_before_recovery() { + use freshell_runtime_protocol::*; + for outcome in [ + "ready", + "unavailable", + "negative", + "wrong-native", + "superseded", + "unknown-native", + ] { + let temp = tempfile::tempdir().unwrap(); + let (supervisor, context) = unconfirmed_codex_supervisor( + temp.path(), + (outcome != "unknown-native").then_some("native-saved"), + ) + .await; + assert!(context.resume_spec.is_none()); + let original = context.fresh_agent.clone(); + let expected = crate::resume_catalog::exact_resume_candidate(&context, None).unwrap(); + let runtime = context.prior_handle.runtime_dir(); + let secret = b"synthetic-host-secret"; + std::fs::write(runtime.join("secret"), secret).unwrap(); + let listener = tokio::net::UnixListener::bind(runtime.join("host.sock")).unwrap(); + let boot = HostBootId::new(); + let entered = std::sync::Arc::new(tokio::sync::Notify::new()); + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let (shutdown, mut stopping) = tokio::sync::oneshot::channel(); + let host = tokio::spawn({ + let expected = expected.clone(); + let entered = entered.clone(); + let release = release.clone(); + let incarnation = context.prior_handle.incarnation_id().clone(); + async move { + let mut probes = 0; + loop { + let mut socket = tokio::select! { _=&mut stopping => break, result=listener.accept()=>result.unwrap().0 }; + let envelope: Envelope = read_frame(&mut socket).await.unwrap(); + let result = match envelope.body { + HostCommand::Hello { + incarnation_id, + challenge, + } => Ok(HostResult::Hello { + host_boot_id: boot.clone(), + proof: host_proof(secret, &challenge, &boot, &incarnation_id), + capabilities: vec![], + effective_limits: checkpoints::tests::limits(), + }), + command => { + assert_eq!( + envelope.auth, + Some(host_proof(secret, "command-auth", &boot, &incarnation)) + ); + match command { + HostCommand::Status { incarnation_id } => { + assert_eq!(incarnation_id, incarnation); + Err(RuntimeError::new(RuntimeErrorCode::HostUnreachable,"initialization was recorded but worker liveness is unconfirmed")) + } + HostCommand::ProbeRecovery { + incarnation_id, + resume_spec, + run_as_uid, + run_as_gid, + .. + } => { + assert_eq!(incarnation_id, incarnation); + assert_eq!((run_as_uid, run_as_gid), (65534, 0)); + assert_eq!( + resume_spec.as_deref().map(|s| &s.provider_session), + expected.as_ref().map(|s| &s.provider_session) + ); + probes += 1; + if outcome == "superseded" { + entered.notify_one(); + release.notified().await; + } + if outcome == "unavailable" { + Ok(HostResult::RecoveryProbe(RecoveryProbe::Blocked { + path: RecoveryPath::NativeResume, + reason: RecoveryBlockReason::AmbiguousIdentity, + retry_hint: RetryHint { + automatic_after_ms: None, + manual_retry: true, + repair: None, + }, + evidence: vec!["no exact readable positive".into()], + })) + } else if outcome == "negative" { + Ok(HostResult::RecoveryProbe( + RecoveryProbe::DefinitivelyUnavailable { + path: RecoveryPath::NativeResume, + reason: "no matching file".into(), + evidence: vec!["partial store no-match".into()], + store_state: EvidenceStoreState::PresentReadable, + negative_paths: vec![], + }, + )) + } else { + let mut resume = expected.clone().expect("unknown native identity must never reach a store probe"); + if outcome == "wrong-native" { + resume.provider_session.native_session_id = + "foreign-native".into(); + } + Ok(HostResult::RecoveryProbe(RecoveryProbe::ResumeReady { + evidence_revision: 0, + resume_spec: Box::new(resume), + })) + } + } + other => panic!( + "probe must not start, send, stop or configure: {other:?}" + ), + } + } + }; + write_frame( + &mut socket, + &HostReply { + request_id: envelope.request_id, + result, + }, + ) + .await + .unwrap(); + } + probes + } + }); + let pending = tokio::spawn({ + let supervisor = supervisor.clone(); + let soul = context.soul_id.clone(); + async move { supervisor.probe_recovery(soul).await } + }); + let newer = if outcome == "superseded" { + tokio::time::timeout(Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let mut newer = expected.clone().unwrap(); + newer.durable_position.provider_cursor = Some("newer-proof".into()); + let stored = supervisor + .registry + .store_verified_resume_spec(context.soul_id.clone(), newer) + .await + .unwrap(); + release.notify_one(); + Some(stored) + } else { + None + }; + let result = tokio::time::timeout(Duration::from_secs(3), pending) + .await + .unwrap() + .unwrap(); + shutdown.send(()).unwrap(); + let probes = host.await.unwrap(); + let after = supervisor + .registry + .recovery_context(context.soul_id.clone()) + .await + .unwrap(); + assert_eq!( + after.fresh_agent, original, + "probe must preserve settings/presentation identity" + ); + assert_eq!( + after.prior_handle.incarnation_id(), + context.prior_handle.incarnation_id() + ); + assert_eq!(probes,usize::from(outcome!="unknown-native"),"{outcome}: an authenticated host must inspect the recorded exact native store despite unconfirmed worker liveness"); + match outcome { + "ready" => { + assert!( + matches!(result, Ok(RecoveryProbe::ResumeReady { .. })), + "{result:?}" + ); + assert_eq!( + after.resume_spec.unwrap().provider_session, + expected.unwrap().provider_session + ); + assert!(after.evidence_revision > 0); + } + "superseded" => { + assert!( + matches!(result, Ok(RecoveryProbe::Blocked { .. })), + "{result:?}" + ); + assert_eq!(after.resume_spec, newer); + } + _ => { + assert!( + !matches!( + result, + Ok(RecoveryProbe::ResumeReady { .. } | RecoveryProbe::ReattachReady { .. }) + ), + "{outcome}: {result:?}" + ); + assert!(after.resume_spec.is_none()); + assert_eq!(after.evidence_revision, 0); + assert!(matches!(result,Ok(RecoveryProbe::Blocked{..})|Err(_)),"an unconfirmed worker permits only a positive exact resume proof, never absence/loss authority: {result:?}"); + } + } + } +} diff --git a/crates/freshell-supervisor/src/recovery.rs b/crates/freshell-supervisor/src/recovery.rs index 50f445c3b..bda6d3dae 100644 --- a/crates/freshell-supervisor/src/recovery.rs +++ b/crates/freshell-supervisor/src/recovery.rs @@ -96,6 +96,16 @@ impl Supervisor { { Ok(status) => status, Err(_) if context.resume_spec.is_some() => return offline_probe(&context), + Err(error) + if error.code == RuntimeErrorCode::HostUnreachable + && context.fresh_agent.as_ref().is_some_and(|agent| { + agent.provider + == freshell_runtime_protocol::FreshProvider::Codex + }) + && context.native_session_id.is_some() => + { + return self.probe_unconfirmed_worker_store(&context, &host).await; + } Err(error) => { return Ok(blocked( RecoveryPath::Reattach, @@ -142,33 +152,7 @@ impl Supervisor { "probe": store_probe, }), ); - let captured = match store_probe { - RecoveryProbe::ResumeReady { resume_spec, .. } => { - let stored = self - .registry - .store_verified_resume_spec_for_live_incarnation( - context.soul_id.clone(), - context.prior_handle.incarnation_id().clone(), - context.evidence_revision, - *resume_spec, - ) - .await - .map_err(map_registry)?; - let Some(stored) = stored else { - return Ok(blocked( - RecoveryPath::NativeResume, - RecoveryBlockReason::ProviderUnavailable, - "provider-store evidence was superseded by a newer incarnation or recovery state; retry the probe", - Some(2_000), - )); - }; - RecoveryProbe::ResumeReady { - evidence_revision: stored.evidence_revision, - resume_spec: Box::new(stored), - } - } - other => other, - }; + let captured = self.persist_resume_probe(&context, store_probe).await?; return Ok(after_native_probe(&context, captured)); } @@ -202,6 +186,78 @@ impl Supervisor { )) } + async fn probe_unconfirmed_worker_store( + &self, + context: &RecoveryContext, + host: &crate::service::AuthenticatedHost, + ) -> Result { + // A restarted host cannot certify a surviving + // worker from initialization alone. It can still + // inspect its exact owned store as the provider UID. + let candidate = match resume_catalog::exact_resume_candidate(&context, None) { + Ok(Some(candidate)) => candidate, + Ok(None) => { + return Ok(blocked( + RecoveryPath::NativeResume, + RecoveryBlockReason::AmbiguousIdentity, + "native identity is unrecorded", + None, + )) + } + Err(probe) => return Ok(probe), + }; + let probe = self + .probe_host_store(&context, &host, Some(candidate)) + .await?; + crate::service::append_event( + &self.config.lifecycle_log, + "supervisor.recovery.unconfirmed_worker_store_probe", + serde_json::json!({ + "severity":"info", "soulId":context.soul_id, "incarnationId":context.prior_handle.incarnation_id(), + "nativeSessionId":context.native_session_id, "probe":probe + }), + ); + return match probe { + ready @ RecoveryProbe::ResumeReady { .. } => { + self.persist_resume_probe(&context, ready).await + } + other @ RecoveryProbe::Blocked { .. } => Ok(other), + _ => Ok(blocked( + RecoveryPath::NativeResume, + RecoveryBlockReason::ProviderUnavailable, + "unconfirmed worker requires a positive exact-session store proof", + Some(2_000), + )), + }; + } + + async fn persist_resume_probe( + &self, + context: &RecoveryContext, + probe: RecoveryProbe, + ) -> Result { + let RecoveryProbe::ResumeReady { resume_spec, .. } = probe else { + return Ok(probe); + }; + let stored = self + .registry + .store_verified_resume_spec_for_live_incarnation( + context.soul_id.clone(), + context.prior_handle.incarnation_id().clone(), + context.evidence_revision, + *resume_spec, + ) + .await + .map_err(map_registry)?; + Ok(match stored { + Some(stored) => RecoveryProbe::ResumeReady { + evidence_revision:stored.evidence_revision, resume_spec:Box::new(stored), + }, + None => blocked(RecoveryPath::NativeResume, RecoveryBlockReason::ProviderUnavailable, + "provider-store evidence was superseded by a newer incarnation or recovery state; retry the probe", Some(2_000)), + }) + } + /// Capture a verified exact-session resume proof while the provider store /// is still reachable. A live process remains reattachable even when the /// provider cannot produce a proof yet; the lifecycle event records that @@ -2418,6 +2474,7 @@ fn map_registry(error: RegistryError) -> RuntimeError { mod tests { include!("shell_recovery_tests.rs"); include!("retained_output_tests.rs"); + include!("host_restart_recovery_tests.rs"); use super::*; use freshell_runtime_protocol::{FreshAgentLaunchSpec, FreshProvider, IncarnationId}; From cba75d76e2a03a438906451a964a24aaa936372a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:59:19 -0700 Subject: [PATCH 57/92] fix(codex): recognize native missing rollout on resume --- crates/freshell-freshagent/src/codex.rs | 62 ++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 837165fb0..f553d794f 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -10470,6 +10470,16 @@ fn is_codex_include_turns_unavailable(err: &CodexAppServerError) -> bool { /// app-server's own error text says so, so a garbage/expired thread id (as opposed to a /// real spawn/RPC failure) doesn't masquerade as a server error. fn is_codex_thread_not_found(err: &CodexAppServerError) -> bool { + // The qualified native resume rejection uses "no rollout found", which + // does not contain "not found". The same fork text can mean an empty + // parent; only the actual resume RPC and its native error code qualify. + if matches!(err, CodexAppServerError::Rpc { method, error } + if method == "thread/resume" && error.code == -32600 + && (error.message == "no rollout found for thread id" + || error.message.starts_with("no rollout found for thread id "))) + { + return true; + } let message = err.to_string().to_lowercase(); message.contains("not found") || message.contains("no such thread") @@ -27620,6 +27630,56 @@ pub(crate) mod tests { /// brand-new id, conversation memory for the old thread genuinely lost. #[tokio::test(flavor = "multi_thread")] async fn send_after_crash_falls_back_to_mint_new_thread_when_resume_reports_not_found() { + assert_crash_mints_new_thread(json!({"code":-32001,"message":"Thread not found"})).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn send_after_crash_falls_back_for_native_missing_rollout_rpc() { + assert_crash_mints_new_thread( + json!({"code":-32600,"message":"no rollout found for thread id thread-original"}), + ) + .await; + } + + #[test] + fn native_missing_rollout_requires_the_resume_rpc_and_known_error_code() { + for (method, code, expected) in [ + ("thread/resume", -32600, true), + ("thread/fork", -32600, false), + ("thread/read", -32600, false), + ("thread/resume", -32000, false), + ] { + let error = CodexAppServerError::Rpc { + method: method.into(), + error: freshell_codex::protocol::RpcError { + code, + message: "no rollout found for thread id thread-original".into(), + data: None, + }, + }; + assert_eq!( + is_codex_thread_not_found(&error), + expected, + "{method}/{code}" + ); + } + assert!(!is_codex_thread_not_found( + &CodexAppServerError::Transport { + method: "thread/resume".into(), + message: "no rollout found for thread id thread-original".into() + } + )); + assert!(!is_codex_thread_not_found(&CodexAppServerError::Rpc { + method: "thread/resume".into(), + error: freshell_codex::protocol::RpcError { + code: -32600, + message: "approval_policy untrusted is not supported".into(), + data: None + } + })); + } + + async fn assert_crash_mints_new_thread(resume_error: Value) { let _guard = ENV_LOCK.lock().await; let (st, mut rx) = state_with_bus(); @@ -27637,7 +27697,7 @@ pub(crate) mod tests { "threadStartThreadId": "thread-respawned", "overrides": { "thread/resume": { - "error": { "code": -32001, "message": "Thread not found" } + "error": resume_error } } }) From cab6d0724178f4e012db64d2bb64ad32b37c197b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:42:35 -0700 Subject: [PATCH 58/92] fix(runtime): retain managed references across Codex recovery --- .../freshell-agent-runtime/src/host_actor.rs | 58 ++++ .../src/host_actor_tests.rs | 131 +++++++ crates/freshell-freshagent/src/codex.rs | 11 +- .../src/codex_rest_lane.rs | 1 + .../src/codex_rest_lane_tests.rs | 1 + crates/freshell-runtime-protocol/src/lib.rs | 14 + crates/freshell-session-host/src/main.rs | 27 +- .../providers/deterministic_fresh_agent.rs | 2 + .../src/providers/fresh_agent.rs | 248 +++++++++++++- .../src/fresh_agent_configure_tests.rs | 320 +++++++++++++++++- crates/freshell-supervisor/src/registry.rs | 97 +++++- crates/freshell-supervisor/src/service.rs | 31 +- 12 files changed, 916 insertions(+), 25 deletions(-) diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 22a09b18e..d8236117f 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; pub use freshell_runtime_protocol::{ AgentEvent, AgentEventBatch as EventBatch, AgentJournalEvent as JournalEvent, FreshProvider, + NativeDispatchIdentity, }; use freshell_runtime_protocol::{ CommandState, FreshAgentCapture, FreshAgentNativeNameError, FreshAgentNativeNameOperation, @@ -196,6 +197,9 @@ pub struct TransportStart { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DispatchAck { pub provider_ack_id: Option, + /// Direct correlated provider acceptance, never an asynchronous Started + /// frame. The previous identity fences a recovery result to its caller. + pub native_identity: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -760,6 +764,58 @@ impl FreshAgentHostActor { ) .await; let mut state = self.state.lock().await; + if let Ok(ack) = &result { + if let Some(identity) = &ack.native_identity { + if identity.native_session_id.is_empty() + || identity.native_session_id.chars().any(char::is_control) + || dispatch_profile.native_session_id.as_deref() + != Some(identity.previous_native_session_id.as_str()) + || state.profile.native_session_id != dispatch_profile.native_session_id + || state.profile.provider_store_id != dispatch_profile.provider_store_id + || state.profile.provider != dispatch_profile.provider + || identity + .pending_naming_handle + .as_ref() + .is_some_and(|handle| { + dispatch_profile.naming_handle.as_ref() != Some(handle) + }) + { + state + .commands + .get_mut(request_id.as_str()) + .expect("durable command") + .state = CommandState::Ambiguous; + push_event_bounded( + &mut state, + AgentEvent::CommandOutcome { + request_id, + state: CommandState::Ambiguous, + }, + self.event_byte_cap, + self.event_count_cap, + )?; + write_state(&self.state_dir, &state)?; + return Err(ActorError::AmbiguousDispatch); + } + let changed = state.profile.native_session_id.as_deref() + != Some(identity.native_session_id.as_str()); + state.profile.native_session_id = Some(identity.native_session_id.clone()); + state.profile.naming_handle = identity.pending_naming_handle.clone(); + if changed { + if let Some(reference) = state.profile.session_ref.as_mut() { + reference.session_id = identity.native_session_id.clone(); + } + push_event_bounded( + &mut state, + AgentEvent::Started { + native_session_id: identity.native_session_id.clone(), + }, + self.event_byte_cap, + self.event_count_cap, + )?; + } + } + } let command = state .commands .get_mut(request_id.as_str()) @@ -1511,6 +1567,8 @@ impl FreshAgentHostActor { .events .iter() .filter(|event| event.sequence > after) + .filter(|event| !matches!(&event.event, AgentEvent::Started {native_session_id} + if state.profile.native_session_id.as_deref() != Some(native_session_id.as_str()))) .take(max_events) .cloned() .collect(), diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 7fb2c79f1..a24f96a9e 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -543,6 +543,8 @@ struct FakeTransport { checkpoint_attempts: std::sync::Mutex>>, decisions: AtomicUsize, retained: std::sync::Mutex>, + dispatch_identity: std::sync::Mutex>, + dispatch_gate: std::sync::Mutex, Arc)>>, stops: AtomicUsize, ambiguous_dispatch: bool, live: std::sync::atomic::AtomicBool, @@ -575,6 +577,7 @@ impl FreshAgentTransport for SerialTransport { tokio::time::sleep(std::time::Duration::from_millis(15)).await; self.in_flight.fetch_sub(1, Ordering::SeqCst); Ok(DispatchAck { + native_identity: None, provider_ack_id: None, }) } @@ -644,6 +647,8 @@ impl FakeTransport { checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), retained: std::sync::Mutex::new(None), + dispatch_identity: std::sync::Mutex::new(None), + dispatch_gate: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -660,6 +665,8 @@ impl FakeTransport { checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), retained: std::sync::Mutex::new(None), + dispatch_identity: std::sync::Mutex::new(None), + dispatch_gate: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -684,6 +691,11 @@ impl FreshAgentTransport for FakeTransport { _profile: &FreshAgentProfile, ) -> Result { self.dispatches.fetch_add(1, Ordering::SeqCst); + let gate = self.dispatch_gate.lock().unwrap().clone(); + if let Some((entered, release)) = gate { + entered.notify_one(); + release.notified().await; + } if self.ambiguous_dispatch { Err(DispatchFailure { message: "reply lost".into(), @@ -691,6 +703,7 @@ impl FreshAgentTransport for FakeTransport { }) } else { Ok(DispatchAck { + native_identity: self.dispatch_identity.lock().unwrap().clone(), provider_ack_id: Some("ack-1".into()), }) } @@ -1043,6 +1056,123 @@ async fn transport_liveness_is_observed_without_transferring_ownership() { assert_eq!(transport.stops.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn accepted_dispatch_native_identity_requires_current_source_and_existing_pending_authority() +{ + for scenario in [ + "wrong-pending", + "initial-recovery", + "new-conversation", + "wrong-parent", + "empty-native", + "control-native", + "stale-native", + "stale-store", + "stale-provider", + "ambiguous", + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = if scenario == "ambiguous" { + FakeTransport::ambiguous("native-old") + } else { + FakeTransport::new("native-old") + }; + let mut launch = profile(FreshProvider::Codex, "native-store", None); + launch.naming_handle = Some("pending-original".into()); + *transport.dispatch_identity.lock().unwrap() = Some(NativeDispatchIdentity { + previous_native_session_id: if scenario == "wrong-parent" { + "foreign-native" + } else { + "native-old" + } + .into(), + native_session_id: match scenario { + "empty-native" => "", + "control-native" => "native\nnew", + _ => "native-new", + } + .into(), + pending_naming_handle: match scenario { + "wrong-pending" => Some("foreign-pending".into()), + "initial-recovery" => launch.naming_handle.clone(), + _ => None, + }, + }); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + if scenario.starts_with("stale-") { + *transport.dispatch_gate.lock().unwrap() = Some((entered.clone(), release.clone())); + } + let actor = FreshAgentHostActor::open(dir.path(), launch, transport.clone()) + .await + .unwrap(); + let request = RequestId::parse("native-identity-request").unwrap(); + let work = tokio::spawn({ + let actor = actor.clone(); + let request = request.clone(); + async move { + actor + .dispatch(request, "controlled native input".into(), None) + .await + } + }); + if scenario.starts_with("stale-") { + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let mut state = actor.state.lock().await; + match scenario { + "stale-native" => { + state.profile.native_session_id = Some("replacement-native".into()) + } + "stale-store" => state.profile.provider_store_id = "replacement-store".into(), + "stale-provider" => state.profile.provider = FreshProvider::Claude, + _ => unreachable!(), + } + drop(state); + release.notify_one(); + } + let result = work.await.unwrap(); + let current = actor.profile().await; + if matches!(scenario, "initial-recovery" | "new-conversation") { + assert_eq!(result, Ok(CommandState::ProviderAcked), "{scenario}"); + assert_eq!(current.native_session_id.as_deref(), Some("native-new")); + assert_eq!( + current.naming_handle, + if scenario == "initial-recovery" { + Some("pending-original".into()) + } else { + None + } + ); + } else { + assert!( + matches!(result, Err(ActorError::AmbiguousDispatch)), + "{scenario}: {result:?}" + ); + assert_ne!( + current.native_session_id.as_deref(), + Some("native-new"), + "{scenario}" + ); + assert_eq!( + current.naming_handle.as_deref(), + Some("pending-original"), + "{scenario}" + ); + assert_eq!( + actor + .dispatch(request, "controlled native input".into(), None) + .await, + Err(ActorError::AmbiguousDispatch), + "an uncertain native result cannot be replayed: {scenario}" + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 1, "{scenario}"); + } + actor.stop().await.unwrap(); + } +} + #[tokio::test] async fn pending_permission_persists_and_is_answered_exactly_once_after_gateway_restart() { let dir = tempfile::tempdir().unwrap(); @@ -1969,6 +2099,7 @@ impl FreshAgentTransport for ConfigureTransport { ) -> Result { self.sent_profiles.lock().unwrap().push(profile.clone()); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index f553d794f..394d93300 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -114,6 +114,7 @@ pub enum CodexSendOutcome { session_id: String, submitted_turn_id: String, incarnation_id: String, + pending_naming_handle: Option, }, Refused { code: String, @@ -4331,12 +4332,9 @@ impl FreshCodexState { // target is the stashed pre-durable handle (it redirects once the // rollout bind lands), else the durable thread itself. A failed feed // never blocks the turn. - let naming_target = self - .naming_handles - .lock() - .await - .get(&session_id) - .cloned() + let pending_naming_handle = self.naming_handles.lock().await.get(&session_id).cloned(); + let naming_target = pending_naming_handle + .clone() .map(|handle| freshell_protocol::session_names::SessionNameRef::Pending { id: handle }) .unwrap_or_else( || freshell_protocol::session_names::SessionNameRef::Session { @@ -4419,6 +4417,7 @@ impl FreshCodexState { session_id: session_id.clone(), submitted_turn_id: submitted_turn_id.clone(), incarnation_id, + pending_naming_handle, }; // Release both serialization locks before Stop RPC work. The consumer // must remain free to observe terminal evidence while Stop is pending. diff --git a/crates/freshell-freshagent/src/codex_rest_lane.rs b/crates/freshell-freshagent/src/codex_rest_lane.rs index bceda606d..2ec4c5967 100644 --- a/crates/freshell-freshagent/src/codex_rest_lane.rs +++ b/crates/freshell-freshagent/src/codex_rest_lane.rs @@ -240,6 +240,7 @@ impl FreshAgentRestLane for CodexRestLane { session_id, submitted_turn_id, incarnation_id, + .. } => { let settled = tokio::time::timeout_at(deadline, async { loop { diff --git a/crates/freshell-freshagent/src/codex_rest_lane_tests.rs b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs index fdcb9f921..565ac713f 100644 --- a/crates/freshell-freshagent/src/codex_rest_lane_tests.rs +++ b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs @@ -148,6 +148,7 @@ async fn typed_send_receipt(response: &str) { session_id, submitted_turn_id, incarnation_id, + .. }, ) => { assert_eq!( diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 812fef4f7..2b3c4e2cc 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -2544,6 +2544,18 @@ pub struct TerminalInputRequest { pub expected_control_epoch: Option, } +/// Correlated native acceptance inside the authenticated owning host. +/// This never grants identity authority to asynchronous provider events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeDispatchIdentity { + pub previous_native_session_id: String, + pub native_session_id: String, + /// Existing provider pending-name intent: retained for initial recovery, + /// absent for an established new conversation. + pub pending_naming_handle: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentTurnSettings { @@ -3703,6 +3715,8 @@ pub enum HostResult { /// at the same acceptance boundary as this acknowledgement. #[serde(default, skip_serializing_if = "Option::is_none")] native_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + native_identity: Option, }, FreshAgentFork(FreshAgentForkResult), FreshAgentInterrupted, diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index ba818696c..e1fa3c6ad 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -587,6 +587,7 @@ async fn dispatch( .await .clone() .ok_or_else(unsupported_fresh_agent)?; + let before = actor.profile().await; let command_state = actor .dispatch_with_checkpoint_identity( request_id, @@ -596,8 +597,24 @@ async fn dispatch( ) .await .map_err(map_actor_error)?; - let native_session_id = actor.profile().await.native_session_id; + let after = actor.profile().await; + let native_session_id = after.native_session_id.clone(); + let native_identity = + if after.provider == freshell_runtime_protocol::FreshProvider::Codex { + before.native_session_id.zip(after.native_session_id).map( + |(previous_native_session_id, native_session_id)| { + freshell_runtime_protocol::NativeDispatchIdentity { + previous_native_session_id, + native_session_id, + pending_naming_handle: after.naming_handle, + } + }, + ) + } else { + None + }; Ok(HostResult::FreshAgentCommand { + native_identity, state: command_state, native_session_id, }) @@ -651,6 +668,7 @@ async fn dispatch( .await .map_err(map_actor_error)?; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: command_state, native_session_id: actor.profile().await.native_session_id, }) @@ -673,6 +691,7 @@ async fn dispatch( .map_err(map_actor_error); emit_environment_event(configure_outcome_event(&request_id, &outcome)); Ok(HostResult::FreshAgentCommand { + native_identity: None, state: outcome?, native_session_id: actor.profile().await.native_session_id, }) @@ -705,6 +724,7 @@ async fn dispatch( .await .map_err(map_actor_error)?; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: command_state, native_session_id: actor.profile().await.native_session_id, }) @@ -812,6 +832,7 @@ async fn dispatch( .map_err(map_actor_error)?; let native_session_id = actor.profile().await.native_session_id; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: freshell_runtime_protocol::CommandState::Completed, native_session_id, }) @@ -10247,6 +10268,7 @@ mod tests { ) -> Result { self.dispatches.fetch_add(1, Ordering::SeqCst); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.to_string()), }) } @@ -10264,6 +10286,7 @@ mod tests { .unwrap() .push(logical_attempt_id.map(str::to_owned)); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.to_string()), }) } @@ -10434,6 +10457,7 @@ mod tests { HostResult::FreshAgentCommand { state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: Some(ref native), + .. } if native == "fixture-native-thread" )); } @@ -10749,6 +10773,7 @@ mod tests { HostResult::FreshAgentCommand { state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: Some(ref native), + .. } if native == "fixture-native-thread" )); diff --git a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs index ee228ab89..5f4ae8d5c 100644 --- a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs @@ -244,6 +244,7 @@ impl FreshAgentTransport for DeterministicFreshAgentTransport { .await .map_err(|_| non_ambiguous("deterministic event receiver closed"))?; return Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }); } @@ -255,6 +256,7 @@ impl FreshAgentTransport for DeterministicFreshAgentTransport { self.emit_completion(request_id).await?; } Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index d3628986d..e250917c9 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -7,8 +7,8 @@ use async_trait::async_trait; use freshell_agent_runtime::host_actor::{ DispatchAck, DispatchFailure, ForkTransition, FreshAgentHostActor, FreshAgentOperation, - FreshAgentProfile, FreshAgentTransport, OperationAck, OperationFailure, OperationFailureKind, - TransportStart, + FreshAgentProfile, FreshAgentTransport, NativeDispatchIdentity, OperationAck, OperationFailure, + OperationFailureKind, TransportStart, }; use freshell_agent_runtime::snapshot_text::render_snapshot_text; use freshell_codex::launch_plan::CodexSidecarLaunchContext; @@ -596,7 +596,7 @@ impl FreshAgentTransport for HostedTransport { observed_epoch: None, observed_generation: None, provider: self.provider_wire(), - session_id, + session_id: session_id.clone(), session_type: self.session_type(), text: text.to_string(), cwd: settings.as_ref().and_then(|settings| settings.cwd.clone()), @@ -611,12 +611,54 @@ impl FreshAgentTransport for HostedTransport { }), }; match &self.state { - // The acceptance is read from the bus below, as for every provider. + // Legacy providers expose acceptance through their existing bus. ProviderState::Claude(state) => { state.handle_send(send).await; } ProviderState::Codex(state) => { - state.handle_send(send).await; + use freshell_freshagent::codex::CodexSendOutcome; + return match state.handle_send(send).await { + CodexSendOutcome::Accepted { + session_id: native, + submitted_turn_id, + incarnation_id, + pending_naming_handle, + } => { + let mut current = self.session_id.lock().await; + if current.as_deref() != Some(session_id.as_str()) { + return Err(DispatchFailure { + message: "native send source changed after acceptance".into(), + acceptance_ambiguous: true, + }); + } + *current = Some(native.clone()); + if let Some(profile) = self.profile.lock().expect("profile lock").as_mut() { + profile.native_session_id = Some(native.clone()); + profile.naming_handle = pending_naming_handle.clone(); + if let Some(reference) = profile.session_ref.as_mut() { + reference.session_id = native.clone(); + } + } + self.native_tx.send_replace(Some(native.clone())); + tracing::info!(request_id=%request_id,native_session_id=%native,native_incarnation_id=%incarnation_id,"freshagent.host.native_dispatch_accepted"); + Ok(DispatchAck { + provider_ack_id: Some(submitted_turn_id), + native_identity: Some(NativeDispatchIdentity { + previous_native_session_id: session_id, + native_session_id: native, + pending_naming_handle, + }), + }) + } + CodexSendOutcome::Refused { code, message, .. } => Err(DispatchFailure { + message: format!("{code}: {message}"), + acceptance_ambiguous: false, + }), + CodexSendOutcome::Ambiguous { code, message, .. } => Err(DispatchFailure { + message: format!("{code}: {message}"), + acceptance_ambiguous: true, + }), + }; } ProviderState::Opencode { runtime, .. } => runtime.handle_send(send).await, } @@ -636,6 +678,7 @@ impl FreshAgentTransport for HostedTransport { .await { Ok(Ok(true)) => Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().to_string()), }), Ok(Ok(false)) => Err(DispatchFailure { @@ -2110,6 +2153,200 @@ mod tests { } } + #[tokio::test] + async fn task010_native_mint_ack_updates_actor_before_snapshot_and_second_send() { + for pending in [false, true] { + assert_native_mint_ack(pending).await; + } + } + + async fn assert_native_mint_ack(pending: bool) { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("recovery-requests.jsonl"); + let fixture = native_codex_fixture(json!({ + "uniqueThreadIds":true,"recordTurns":true,"appendRequestLogPath":log, + "exitProcessAfterMethodsOnce":[if pending {"thread/start"} else {"turn/start"}], + "exitProcessAfterMethodsOnceMarkerPath":logs.path().join("first-native-exit"), + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id original-native"}}} + })).await; + let mut profile = fixture.profile.clone(); + profile.naming_handle = pending.then(|| "pending-initial-recovery".into()); + profile.effort = Some("xhigh".into()); + profile.collaboration_mode = Some("plan".into()); + profile.sandbox = Some("read-only".into()); + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("recovery-actor"), + profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let original = actor.profile().await.native_session_id.unwrap(); + if !pending { + actor + .dispatch( + RequestId::parse("before-native-exit").unwrap(), + "first native input".into(), + None, + ) + .await + .unwrap(); + } + tokio::time::timeout(Duration::from_secs(5), async { + while fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the owned native executable must actually exit"); + actor + .dispatch( + RequestId::parse("recover-native").unwrap(), + "recover native input".into(), + None, + ) + .await + .unwrap(); + let recovered = actor.profile().await; + let native = recovered.native_session_id.clone().unwrap(); + assert_ne!(native, original,"the typed successful mint-new result must update durable actor identity before dispatch returns"); + assert_eq!(recovered.naming_handle, profile.naming_handle); + assert_eq!(recovered.model, profile.model); + assert_eq!(recovered.effort, profile.effort); + assert_eq!(recovered.collaboration_mode, profile.collaboration_mode); + assert_eq!(recovered.sandbox, profile.sandbox); + assert!(actor + .observe_native_identity("arbitrary-started".into()) + .await + .is_err()); + assert!(actor + .observe_native_identity(original.clone()) + .await + .is_err()); + assert!(actor.read_events(0, 256).await.events.iter().all(|entry| !matches!(&entry.event,AgentEvent::Started{native_session_id} if native_session_id != &native)),"historical Started must not re-key durable inventory after a trusted command transition"); + let snapshot = actor.snapshot().await.unwrap(); + assert_eq!(snapshot["threadId"], native); + actor + .dispatch( + RequestId::parse("second-current-native").unwrap(), + "second current native input".into(), + None, + ) + .await + .unwrap(); + assert_eq!( + actor.profile().await.native_session_id.as_deref(), + Some(native.as_str()) + ); + assert_eq!(actor.snapshot().await.unwrap()["threadId"], native); + actor.stop().await.unwrap(); + let rows = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 2, + "only the initial thread and explicit native missing-history fallback may create" + ); + let starts = rows + .iter() + .filter(|row| row["method"] == "turn/start") + .collect::>(); + assert_eq!( + starts.len(), + if pending { 2 } else { 3 }, + "no accepted input may be replayed" + ); + if !pending { + assert_eq!(starts[0]["params"]["threadId"], original); + } + for row in &starts[usize::from(!pending)..] { + assert_eq!(row["params"]["threadId"], native); + assert_eq!(row["params"]["effort"], "xhigh"); + assert_eq!(row["params"]["collaborationMode"]["mode"], "plan"); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + } + } + + /// Observer-boundary qualification, separate from public creation: the + /// release CLI's explicitly ephemeral parent/child contract is receipt16. + /// No application start parameter or native ephemerality is rewritten here. + #[tokio::test] + async fn task010_hosted_ephemeral_child_is_finite_and_never_becomes_a_writer() { + let log = tempfile::NamedTempFile::new().unwrap(); + let parent = json!({"id":"thread-new-1","ephemeral":true,"status":{"type":"active"},"turns":[{"id":"registered-child-turn","status":"inProgress","items":[{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"thread-new-1","receiverThreadIds":["ephemeral-child"],"agentsStates":{}}]}]}); + let child = json!({"id":"ephemeral-child","parentThreadId":"thread-new-1","ephemeral":true,"status":{"type":"active"},"turns":[]}); + let fixture = native_codex_fixture(json!({"appendRequestLogPath":log.path(),"threadReadResults":{"thread-new-1":parent,"ephemeral-child":child}})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("ephemeral-observer-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let profile = actor.profile().await; + let before = std::fs::read_to_string(log.path()).unwrap(); + assert!(actor.read_child("unregistered-child").await.is_err()); + for _ in 0..2 { + let value = actor.read_child("ephemeral-child").await.unwrap(); + assert_eq!( + value, + json!({"parentThreadId":"thread-new-1","childThreadId":"ephemeral-child","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}) + ); + } + assert_eq!(actor.profile().await, profile); + let after = std::fs::read_to_string(log.path()).unwrap(); + let rows: Vec = after + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let baseline: Vec = before + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + baseline + .iter() + .filter(|row| row["method"] == "thread/start") + .count() + ); + assert_eq!( + rows.iter() + .filter(|row| row["params"]["threadId"] == "ephemeral-child") + .count(), + 2 + ); + assert!(rows + .iter() + .filter(|row| row["params"]["threadId"] == "ephemeral-child") + .all(|row| row["method"] == "thread/read" && row["params"]["includeTurns"] == true)); + assert!(!rows + .iter() + .any(|row| row["params"]["threadId"] == "unregistered-child")); + // A final native read still reports true; observation did not persist, + // resume, configure, send to, stop or relabel the child. + let ProviderState::Codex(state) = &fixture.transport.state else { + panic!("Codex fixture") + }; + let native = state + .management_client_for_root(&fixture.dir.path().join("codex-home").to_string_lossy()) + .await + .unwrap() + .read_thread_metadata("ephemeral-child") + .await + .unwrap(); + assert_eq!(native["thread"]["ephemeral"], true); + assert_eq!(native["thread"]["parentThreadId"], "thread-new-1"); + actor.stop().await.unwrap(); + } + #[tokio::test] async fn task010_codex_native_name_uses_only_current_root_matched_metadata_methods() { let log = tempfile::NamedTempFile::new().unwrap(); @@ -4657,6 +4894,7 @@ mod tests { format!("accepted:{}", request_id.as_str()), ); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } diff --git a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs index cef5d66ef..b02ad5f1b 100644 --- a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs +++ b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs @@ -61,6 +61,13 @@ async fn running_fresh_claude() -> RunningFreshAgent { running_fresh_provider(FreshProvider::Claude).await } async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { + running_fresh_provider_with_name(provider, None).await +} + +async fn running_fresh_provider_with_name( + provider: FreshProvider, + naming_handle: Option, +) -> RunningFreshAgent { let root = tempfile::tempdir().unwrap(); let workspace = std::fs::canonicalize(root.path()).unwrap(); let registry = Registry::open(root.path().join("registry"), None).unwrap(); @@ -80,7 +87,7 @@ async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { fixture: None, terminal: None, fresh_agent: Some(FreshAgentLaunchSpec { - naming_handle: None, + naming_handle, collaboration_mode: None, session_id: "fresh-session".into(), provider: provider.clone(), @@ -173,6 +180,316 @@ async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { } } +fn native_send_reply(previous: &str, native: &str, pending: Option) -> HostResult { + HostResult::FreshAgentCommand { + state: CommandState::ProviderAcked, + native_session_id: Some(native.into()), + native_identity: Some(NativeDispatchIdentity { + previous_native_session_id: previous.into(), + native_session_id: native.into(), + pending_naming_handle: pending, + }), + } +} + +fn native_send_host( + fixture: &RunningFreshAgent, + replies: Vec, + gate: Option<(Arc, Arc)>, +) -> ( + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle, +) { + let secret = b"synthetic-host-secret"; + std::fs::write(fixture.runtime_dir.join("secret"), secret).unwrap(); + let listener = UnixListener::bind(fixture.runtime_dir.join("host.sock")).unwrap(); + let boot = HostBootId::new(); + let incarnation = fixture.incarnation.clone(); + let (shutdown, mut stop) = tokio::sync::oneshot::channel(); + let host = tokio::spawn(async move { + let mut replies = replies.into_iter(); + let mut sends = 0; + loop { + let mut socket = + tokio::select! {_=&mut stop=>break,result=listener.accept()=>result.unwrap().0}; + let envelope: Envelope = read_frame(&mut socket).await.unwrap(); + let result = match envelope.body { + HostCommand::Hello {incarnation_id,challenge} => HostResult::Hello { + host_boot_id:boot.clone(),proof:host_proof(secret,&challenge,&boot,&incarnation_id),capabilities:vec![],effective_limits:crate::checkpoints::tests::limits() + }, + HostCommand::FreshAgentSend {incarnation_id,..} => { + assert_eq!(incarnation_id,incarnation); + assert_eq!(envelope.auth,Some(host_proof(secret,"command-auth",&boot,&incarnation))); + sends+=1; + if let Some((entered,release))=&gate {entered.notify_one();release.notified().await;} + replies.next().expect("no input may be automatically resent") + }, + other=>panic!("native acknowledgement must not create/start/configure/probe a provider: {other:?}") + }; + write_frame( + &mut socket, + &HostReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + sends + }); + (shutdown, host) +} + +#[tokio::test] +async fn accepted_native_recovery_preserves_presentation_and_pending_name_policy() { + for pending in [false, true] { + let fixture = + running_fresh_provider_with_name(FreshProvider::Codex, Some("pending-original".into())) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "native-original".into(), + ) + .await + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + let before_spec = before.fresh_agent.unwrap(); + let handle = pending.then(|| "pending-original".to_string()); + let mut replies = Vec::new(); + if !pending { + replies.push(native_send_reply( + "native-original", + "native-original", + None, + )); + } + replies.push(native_send_reply( + "native-original", + "native-recovered", + handle.clone(), + )); + replies.push(native_send_reply( + "native-recovered", + "native-recovered", + handle.clone(), + )); + let expected_sends = replies.len(); + let (shutdown, host) = native_send_host(&fixture, replies, None); + let mut results = Vec::new(); + let mut same_native = None; + for index in 0..expected_sends { + let result = fixture + .supervisor + .fresh_agent_send( + RequestId::new(), + fixture.soul.clone(), + format!("controlled native input {index}"), + None, + None, + ) + .await; + let failed = result.is_err(); + results.push(result); + if !pending && index == 0 { + same_native = Some( + fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(), + ); + } + if failed { + break; + } + } + shutdown.send(()).unwrap(); + let sends = host.await.unwrap(); + for result in results { + result.expect( + "native acceptance must atomically persist current source and pending-name state", + ); + } + assert_eq!(sends, expected_sends); + if let Some(same) = same_native { + assert_eq!(same.native_session_id.as_deref(), Some("native-original")); + assert!(same.fresh_agent.unwrap().naming_handle.is_none(),"consumed pending authority must clear on ordinary same-native accepts, keeping the actor/recovery spec consistent"); + } + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + let spec = after.fresh_agent.unwrap(); + assert_eq!(after.native_session_id.as_deref(), Some("native-recovered")); + assert_eq!( + spec.session_id, before_spec.session_id, + "presentation reference must remain byte-identical" + ); + assert_eq!(spec.native_session_id.as_deref(), Some("native-recovered")); + assert_eq!(spec.naming_handle, handle); + assert_eq!(spec.model, before_spec.model); + assert_eq!(spec.effort, before_spec.effort); + assert_eq!(spec.cwd, before_spec.cwd); + assert_eq!(spec.permission_mode, before_spec.permission_mode); + assert_eq!(spec.sandbox, before_spec.sandbox); + let connection = + rusqlite::Connection::open(fixture.supervisor.registry.database_path()).unwrap(); + let claims: Vec = connection + .prepare("SELECT native_session_id FROM writer_claims WHERE soul_id=?1") + .unwrap() + .query_map([fixture.soul.as_str()], |row| row.get(0)) + .unwrap() + .map(Result::unwrap) + .collect(); + assert_eq!(claims, vec!["native-recovered".to_string()]); + } +} + +#[tokio::test] +async fn native_dispatch_rejects_stale_invalid_and_nonaccepted_identity_authority() { + for scenario in [ + "untagged", + "wrong-parent", + "wrong-reply", + "missing-reply", + "invalid-native", + "foreign-pending", + "wrong-provider", + "ambiguous", + "refused", + "stop-intent", + ] { + let fixture = running_fresh_provider_with_name( + if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }, + Some("pending-original".into()), + ) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "native-original".into(), + ) + .await + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let native = if scenario == "invalid-native" { + "native\ninvalid" + } else { + "native-recovered" + }; + let mut reply = native_send_reply( + if scenario == "wrong-parent" { + "stale-native" + } else { + "native-original" + }, + native, + if scenario == "foreign-pending" { + Some("unrelated-pending".into()) + } else { + None + }, + ); + if let HostResult::FreshAgentCommand { + state, + native_session_id, + native_identity, + } = &mut reply + { + match scenario { + "untagged" => *native_identity = None, + "wrong-reply" => *native_session_id = Some("different-reply".into()), + "missing-reply" => *native_session_id = None, + "ambiguous" => *state = CommandState::Ambiguous, + "refused" => *state = CommandState::Cancelled, + _ => {} + } + } + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let (shutdown, host) = native_send_host( + &fixture, + vec![reply], + (scenario == "stop-intent").then(|| (entered.clone(), release.clone())), + ); + let work = tokio::spawn({ + let supervisor = fixture.supervisor.clone(); + let soul = fixture.soul.clone(); + async move { + supervisor + .fresh_agent_send( + RequestId::new(), + soul, + "controlled refused identity".into(), + None, + None, + ) + .await + } + }); + if scenario == "stop-intent" { + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + fixture + .supervisor + .registry + .begin_stop(fixture.soul.clone()) + .await + .unwrap(); + release.notify_one(); + } + let result = work.await.unwrap(); + shutdown.send(()).unwrap(); + assert_eq!(host.await.unwrap(), 1); + assert!( + !matches!( + result, + Ok(CommandState::ProviderAcked | CommandState::Completed) + ), + "{scenario}: {result:?}" + ); + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + assert_eq!( + after.native_session_id.as_deref(), + Some("native-original"), + "{scenario}" + ); + assert_eq!(after.fresh_agent, before, "{scenario}"); + } +} + /// An authenticated session host that records every command it is sent. fn fake_host( runtime_dir: &std::path::Path, @@ -249,6 +566,7 @@ fn fake_host_with_boot( }) } HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { + native_identity: None, state: CommandState::ProviderAcked, native_session_id: Some("native-claude".into()), }, diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index 597fecfd4..7222b14c9 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -2,9 +2,10 @@ use crate::admission::{AdmissionPolicy, ReservationTotals}; use freshell_runtime_protocol::{ AllocationState, CleanupState, DesiredState, DockerDaemonId, DurabilityState, FixtureKind, FreshAgentLaunchSpec, FreshAgentTurnSettings, GrantId, HostBootId, IncarnationId, IncidentId, - InstallationId, LaunchNonce, LaunchState, NoticeId, RecoveryAttemptId, RecoveryBlockReason, - RecoveryPath, RecoveryState, RecoveryTrigger, RequestId, ResumeSpec, RuntimeLimits, - RuntimeProfile, RuntimeView, SoulId, StopOutcome, TerminalLaunchSpec, ViewIntentRequest, + InstallationId, LaunchNonce, LaunchState, NativeDispatchIdentity, NoticeId, RecoveryAttemptId, + RecoveryBlockReason, RecoveryPath, RecoveryState, RecoveryTrigger, RequestId, ResumeSpec, + RuntimeLimits, RuntimeProfile, RuntimeView, SoulId, StopOutcome, TerminalLaunchSpec, + ViewIntentRequest, }; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::{Deserialize, Serialize}; @@ -353,6 +354,13 @@ enum SettingsFold { Configure, } +enum NativeIdentityChange { + FollowNative, + AcceptedDispatch { + pending_naming_handle: Option, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputJournalDisposition { Dispatch, @@ -1320,11 +1328,54 @@ impl Registry { expected_parent: &str, child_session_id: &str, ) -> Result<(), RegistryError> { + self.transition_native_session_inner( + soul_id, + incarnation_id, + expected_parent, + child_session_id, + NativeIdentityChange::FollowNative, + ) + .await + } + + pub async fn record_fresh_agent_dispatch_identity( + &self, + soul_id: SoulId, + incarnation_id: IncarnationId, + identity: NativeDispatchIdentity, + ) -> Result<(), RegistryError> { + self.transition_native_session_inner( + soul_id, + incarnation_id, + &identity.previous_native_session_id, + &identity.native_session_id, + NativeIdentityChange::AcceptedDispatch { + pending_naming_handle: identity.pending_naming_handle, + }, + ) + .await + } + + async fn transition_native_session_inner( + &self, + soul_id: SoulId, + incarnation_id: IncarnationId, + expected_parent: &str, + child_session_id: &str, + change: NativeIdentityChange, + ) -> Result<(), RegistryError> { + let pending_naming_handle = match change { + NativeIdentityChange::FollowNative => None, + NativeIdentityChange::AcceptedDispatch { + pending_naming_handle, + } => Some(pending_naming_handle), + }; + let preserve_presentation = pending_naming_handle.is_some(); let expected_parent = expected_parent.to_string(); let child_session_id = child_session_id.to_string(); if expected_parent.is_empty() || child_session_id.is_empty() - || expected_parent == child_session_id + || (!preserve_presentation && expected_parent == child_session_id) || expected_parent.chars().any(char::is_control) || child_session_id.chars().any(char::is_control) { @@ -1347,9 +1398,18 @@ impl Registry { if owner != soul_id.as_str() { return Err(RegistryError::NativeIdentityConflict); } + if preserve_presentation { + let current: bool=tx.query_row( + "SELECT EXISTS(SELECT 1 FROM incarnations i JOIN souls s ON s.soul_id=i.soul_id JOIN writer_claims w ON w.soul_id=s.soul_id AND w.incarnation_id=i.incarnation_id WHERE s.soul_id=?1 AND i.incarnation_id=?2 AND i.launch_state='running' AND s.desired_state='running' AND w.provider=s.provider AND w.provider_store_id=s.provider_store_id AND w.native_session_id=?3 AND w.intent_revision=s.intent_revision)", + params![soul_id.as_str(),incarnation_id.as_str(),expected_parent],|row|row.get(0) + )?; + if provider!="codex" || existing.as_deref()!=Some(expected_parent.as_str()) || !current { + return Err(RegistryError::NativeIdentityConflict); + } + } // The response to a successful provider fork can be lost. Retrying // the same transition after durable commit is idempotent. - if existing.as_deref() == Some(child_session_id.as_str()) { + if !preserve_presentation && existing.as_deref() == Some(child_session_id.as_str()) { let claim: Option = tx.query_row( "SELECT incarnation_id FROM writer_claims WHERE provider=?1 AND provider_store_id=?2 AND native_session_id=?3", params![provider, store, child_session_id], |row| row.get(0), @@ -1376,6 +1436,20 @@ impl Registry { (None, Some(spec)) if provider == "opencode" && spec.mode == "opencode" => {}, _ => return Err(RegistryError::NativeIdentityConflict), } + if let Some(pending)=&pending_naming_handle { + let spec=fresh_spec.as_mut().ok_or(RegistryError::NativeIdentityConflict)?; + if pending.as_ref().is_some_and(|handle|spec.naming_handle.as_ref()!=Some(handle)) { + return Err(RegistryError::NativeIdentityConflict); + } + if expected_parent==child_session_id { + if spec.naming_handle!=*pending { + spec.naming_handle=pending.clone(); + tx.execute("UPDATE incarnations SET fresh_agent_spec=?1,updated_at=?2 WHERE incarnation_id=?3",params![serde_json::to_string(spec)?,now_millis(),incarnation_id.as_str()])?; + } + tx.commit()?; + return Ok(()); + } + } let conflicting: Option = tx.query_row( "SELECT incarnation_id FROM writer_claims WHERE provider=?1 AND provider_store_id=?2 AND native_session_id=?3", params![provider, store, child_session_id], |row| row.get(0), @@ -1394,15 +1468,16 @@ impl Registry { "INSERT INTO writer_claims (provider,provider_store_id,native_session_id,soul_id,incarnation_id,intent_revision) VALUES (?1,?2,?3,?4,?5,?6)", params![provider, store, child_session_id, soul_id.as_str(), incarnation_id.as_str(), intent], )?; - // An in-soul reroot changes both the provider-native writer key - // and the externally resumable presentation key. Persist them - // together so a web-server restart cannot rediscover the retired - // parent through inventory fallback. + // Fork/reroot follows the new native presentation key. A direct + // accepted recovery keeps the exact existing presentation key. + // Both persist the source and writer claim in this transaction. let now = now_millis(); if let Some(spec) = fresh_spec.as_mut() { - spec.session_id = child_session_id.clone(); + if !preserve_presentation { + spec.session_id = child_session_id.clone(); + } spec.native_session_id = Some(child_session_id.clone()); - spec.naming_handle = None; + spec.naming_handle = pending_naming_handle.clone().flatten(); if let Some(reference) = spec.session_ref.as_mut() { reference.session_id = child_session_id.clone(); } diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 27ee9cbba..cc568a6b1 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -1631,8 +1631,36 @@ impl Supervisor { Ok(HostResult::FreshAgentCommand { state: CommandState::ProviderAcked | CommandState::Completed, native_session_id, + native_identity, }) => { - if let Some(native_session_id) = native_session_id { + if let Some(identity) = native_identity { + let prior_native = active + .fresh_agent() + .and_then(|spec| spec.native_session_id.as_deref()); + if active.fresh_agent().is_none_or(|spec| { + spec.provider != freshell_runtime_protocol::FreshProvider::Codex + }) || prior_native != Some(identity.previous_native_session_id.as_str()) + || native_session_id.as_deref() != Some(identity.native_session_id.as_str()) + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "accepted native identity does not match its captured command source", + )); + } + let update = classify_fresh_agent_operation_identity( + prior_native, + native_session_id.as_deref(), + )?; + self.registry + .record_fresh_agent_dispatch_identity( + soul_id.clone(), + handle.incarnation_id().clone(), + identity, + ) + .await + .map_err(map_registry)?; + tracing::info!(soul_id=%soul_id,incarnation_id=%handle.incarnation_id(),previous_native_session_id=prior_native,native_session_id=native_session_id.as_deref(),native_identity_update=?update,"freshagent.native_dispatch_identity_committed"); + } else if let Some(native_session_id) = native_session_id { self.registry .record_native_session( soul_id.clone(), @@ -1893,6 +1921,7 @@ impl Supervisor { HostResult::FreshAgentCommand { state, native_session_id, + .. } => { let identity_update = classify_fresh_agent_operation_identity( prior_native.as_deref(), From b3f678d9fde57ce3ed624db7cda6269bd6da1de4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:57 -0700 Subject: [PATCH 59/92] fix(codex): admit verified hosted recovery names on current source --- crates/freshell-freshagent/src/codex.rs | 61 +++++- .../src/codex/projection.rs | 13 ++ crates/freshell-freshagent/src/naming.rs | 7 + .../freshell-server/src/fresh_agent_proxy.rs | 100 ++++++++- .../src/fresh_agent_proxy_tests.rs | 202 +++++++++++++++++- .../src/providers/fresh_agent.rs | 71 +++++- 6 files changed, 433 insertions(+), 21 deletions(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 394d93300..ecba7492b 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1668,7 +1668,8 @@ impl FreshCodexState { None } - /// Unified agent names (Task 2): the durability-driven pending bind. A + /// Durability-driven naming: bind a pending handle, or admit a hosted + /// session's own name when it has no handle. A /// freshcodex thread is verified ONLY once its rollout exists under the /// initialized `codexHome` sessions tree (the first line a /// `session_meta` naming the thread — the same proof @@ -1679,12 +1680,28 @@ impl FreshCodexState { /// store answers the already-bound handle with a Read, and the stash is /// consumed on success). `codex_home` comes from the app-server's own /// initialize result when known. - pub(crate) async fn try_bind_pending_naming( + pub(crate) async fn try_materialize_naming( &self, thread_id: &str, codex_home: Option<&str>, ) -> Option { - let handle = self.naming_handles.lock().await.get(thread_id).cloned()?; + let handle = self.naming_handles.lock().await.get(thread_id).cloned(); + if handle.is_none() && !self.hosted_naming_events { + return None; + } + let (source_projection, cwd) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(thread_id)?; + (session.projection.clone(), session.cwd.clone()) + }; + if handle.is_none() + && source_projection + .lock() + .expect("codex projection lock") + .has_hosted_naming_proof() + { + return None; + } let home = codex_home .map(str::to_string) .or_else(codex_home_from_env)?; @@ -1717,6 +1734,40 @@ impl FreshCodexState { evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, persistence: freshell_protocol::native_location::NativePersistence::Verified, }; + if !self + .sessions + .lock() + .await + .get(thread_id) + .is_some_and(|session| Arc::ptr_eq(&session.projection, &source_projection)) + { + return None; + } + let Some(handle) = handle else { + // Established mint-new recovery has no pending name to transfer. + // The same verified-header boundary admits its own durable name, + // and also repairs an unnamed restored session without relabeling + // the identity transition or starting another provider. + self.report_hosted_naming_fact( + thread_id, + crate::naming::HostedCodexNamingFact::Acquisition { + target, + acquisition, + cwd, + }, + ) + .await; + let activity = source_projection + .lock() + .expect("codex projection lock") + .hosted_naming_activity(); + if let Some(activity) = activity { + if let Ok(activity) = serde_json::from_value(activity) { + self.report_hosted_naming_fact(thread_id, activity).await; + } + } + return None; + }; // Unified agent names (Task 4, T2-M5): the rollout-verified // pending→durable transition is an InitialMaterialization — the // shared classification-driven fold owns the policy, and the @@ -3658,7 +3709,7 @@ impl FreshCodexState { // bind, so the classification is not yet verified at upsert time). if claim.is_some() { if let Some(transition) = self - .try_bind_pending_naming(&thread_id, codex_home.as_deref()) + .try_materialize_naming(&thread_id, codex_home.as_deref()) .await { tracing::debug!( @@ -8885,7 +8936,7 @@ impl FreshCodexState { None => None, }; if let Some(transition) = state - .try_bind_pending_naming(session_id, codex_home.as_deref()) + .try_materialize_naming(session_id, codex_home.as_deref()) .await { tracing::debug!( diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs index d70579cac..e328bdf3c 100644 --- a/crates/freshell-freshagent/src/codex/projection.rs +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -1099,6 +1099,19 @@ impl CodexProjection { true } + pub(super) fn has_hosted_naming_proof(&self) -> bool { + self.hosted_naming_facts + .iter() + .any(|fact| matches!(fact["kind"].as_str(), Some("transition" | "acquisition"))) + } + + pub(super) fn hosted_naming_activity(&self) -> Option { + self.hosted_naming_facts + .iter() + .find(|fact| fact["kind"] == "activity") + .cloned() + } + #[cfg(test)] fn snapshot_rows(&self, turn_id: &str) -> Option> { super::build_codex_turn_json(&self.turns.iter().find(|t| t.raw["id"] == turn_id)?.raw, 0) diff --git a/crates/freshell-freshagent/src/naming.rs b/crates/freshell-freshagent/src/naming.rs index 6b997188b..7ae756fd1 100644 --- a/crates/freshell-freshagent/src/naming.rs +++ b/crates/freshell-freshagent/src/naming.rs @@ -354,6 +354,13 @@ pub const HOSTED_CODEX_NAMING_EVENT: &str = "freshAgent.host.naming"; #[serde(tag = "kind", content = "data", rename_all = "snake_case")] pub enum HostedCodexNamingFact { Activity(NameActivity), + /// Exact persisted metadata for a current session without a pending + /// handle. This admits that session's own name; it never transfers one. + Acquisition { + target: SessionNameRef, + acquisition: NativeAcquisition, + cwd: Option, + }, Transition { target: SessionNameRef, transition: NameTransition, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index ebd9d1011..3a9dac918 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -81,15 +81,14 @@ impl HostedFreshAgentProxy { format!("{OPT_IN_ENV}=1 requires an available managed runtime controller") })?; let fixture_modes = configured_fixture_modes()?; - // Name reliability (Task 10, Residual 4): hosted fresh agents admit a - // pending name at create and read a resumed session's name, but no - // lane reports their first message or transfers the pending name onto - // the native session, so they never get a first-message or AI name. - // Said once at boot so enabling the flag cannot hide the gap. + // Codex reports authenticated accepted-input and verified acquisition + // facts to the shared authority. The other hosted provider lanes + // still have no first-message/materialization naming feed. tracing::warn!( target: "freshell_server::session_names", flag = OPT_IN_ENV, - "session_names.hosted_fresh_agent_naming_incomplete: hosted fresh-agent \ + providers = "claude,opencode", + "session_names.hosted_fresh_agent_naming_incomplete: hosted Claude and OpenCode \ sessions keep their create-time name only (no first-message or AI naming)" ); Ok(Some(Arc::new(Self { @@ -1159,7 +1158,12 @@ impl HostedFreshAgentProxy { .await .ok()? .into_iter() - .find(|view| &view.soul_id == soul && view.desired_state == DesiredState::Running) + .rev() + .find(|view| { + &view.soul_id == soul + && view.desired_state == DesiredState::Running + && view.launch_state == freshell_runtime_protocol::LaunchState::Running + }) } async fn forward_retained( @@ -1179,7 +1183,14 @@ impl HostedFreshAgentProxy { .as_object_mut() .and_then(|extension| extension.remove("hostedNamingFacts")) { - for fact in facts.as_array().into_iter().flatten() { + let facts = facts.as_array().into_iter().flatten(); + // A retained accepted input may precede its admission fact. + // Bind/admit first, then fold that same idempotent input. + for fact in facts + .clone() + .filter(|fact| fact["kind"] != "activity") + .chain(facts.filter(|fact| fact["kind"] == "activity")) + { self.fold_hosted_codex_naming(presentation, &native, fact.clone()) .await; } @@ -1352,7 +1363,9 @@ impl HostedFreshAgentProxy { value: serde_json::Value, ) { use freshell_freshagent::naming::{HostedCodexNamingFact, NameTransitionReason}; - use freshell_protocol::native_location::{NativeLocation, NativePersistence}; + use freshell_protocol::native_location::{ + NativeEvidenceKind, NativeLocation, NativePersistence, + }; use freshell_protocol::session_names::{NamedProvider, SessionNameRef}; if native_id.is_empty() { return; @@ -1386,6 +1399,75 @@ impl HostedFreshAgentProxy { }; let sink = self.naming.get().cloned(); match fact { + HostedCodexNamingFact::Acquisition { + target, + acquisition, + cwd, + } => { + let expected = SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: native_id.into(), + }; + let location_matches = matches!(&acquisition.location, + NativeLocation::Codex { codex_home, native_thread_id: Some(id), rollout_path: Some(path), .. } + if !codex_home.is_empty() && id == native_id && !path.is_empty()); + if target != expected + || !location_matches + || acquisition.persistence != NativePersistence::Verified + || acquisition.evidence != NativeEvidenceKind::PersistedMetadata + { + tracing::warn!( + provider = "codex", + native_session_id = native_id, + "freshagent.gateway.naming_source_mismatch" + ); + return; + } + if let Some(sink) = sink { + if let Err(error) = sink.ensure_session(target.clone(), cwd).await { + freshell_freshagent::naming::log_name_error( + "ensure_session", + &target, + &error, + ); + return; + } + let after_admission = self.current_source(&soul).await; + if !current + .as_ref() + .zip(after_admission.as_ref()) + .is_some_and(|(before, after)| rest::same_snapshot_source(before, after)) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + return; + } + if let Err(error) = sink.record_acquisition(target.clone(), acquisition).await { + freshell_freshagent::naming::log_name_error( + "record_acquisition", + &target, + &error, + ); + } + let after_acquisition = self.current_source(&soul).await; + if !current + .as_ref() + .zip(after_acquisition.as_ref()) + .is_some_and(|(before, after)| rest::same_snapshot_source(before, after)) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + } + } + } HostedCodexNamingFact::Activity(input) => { let valid_target = match &input.target { SessionNameRef::Pending { id } => !id.is_empty(), diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index c33508c53..89f1abf58 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -6,6 +6,184 @@ use freshell_runtime_protocol::{ }; use tokio::net::UnixListener; +#[tokio::test] +async fn task010_hosted_new_conversation_admits_only_its_verified_current_name() { + use freshell_freshagent::naming::{ + HostedCodexNamingFact, NameActivity, NameActivityReason, RenameNameInput, + }; + use freshell_protocol::native_location::{ + NativeAcquisition, NativeEvidenceKind, NativeLocation, NativePersistence, + }; + use freshell_protocol::session_names::{NameIntent, SessionNameRef}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("new-name.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let switch_after = Arc::new(AtomicUsize::new(0)); + let switch = switch_after.clone(); + let server = tokio::spawn(async move { + let changed = AtomicBool::new(false); + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + if switch.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) == Ok(1) + { + changed.store(true, Ordering::SeqCst); + } + AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"name-soul","incarnationId":"name-incarnation","hostBootId":if changed.load(Ordering::SeqCst) {"new-boot"} else {"name-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":3,"executionGeneration":2, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":"new-native","freshAgentSessionId":"same-opaque-ref","freshAgentSessionType":"freshcodex" + })).unwrap()]) + } + other => panic!("name admission must perform no provider work: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + let names = crate::session_names::SessionNames::open(dir.path().join("names")).unwrap(); + proxy.set_session_naming(names.clone()); + let target = |id: &str| SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: id.into(), + }; + let old = target("old-native"); + let new = target("new-native"); + names.ensure_session(old.clone(), None).await.unwrap(); + let old_record = names + .rename(RenameNameInput { + target: old.clone(), + name: "Protected old manual name".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap() + .record; + let acquisition = |id: &str, persistence| HostedCodexNamingFact::Acquisition { + target: target(id), + cwd: Some("/work/recovery".into()), + acquisition: NativeAcquisition { + location: NativeLocation::Codex { + codex_home: "/owning/codex".into(), + native_thread_id: Some(id.into()), + rollout_path: Some(format!("/owning/codex/sessions/rollout-{id}.jsonl")), + persistence_evidence: Some("verified header".into()), + }, + evidence: NativeEvidenceKind::PersistedMetadata, + persistence, + }, + }; + for (id, persistence) in [ + ("old-native", NativePersistence::Verified), + ("new-native", NativePersistence::Prospective), + ] { + proxy + .fold_hosted_codex_naming( + "same-opaque-ref", + id, + serde_json::to_value(acquisition(id, persistence)).unwrap(), + ) + .await; + } + assert!( + names.get(vec![new.clone()]).await.unwrap().is_empty(), + "stale or prospective facts cannot admit a name" + ); + let fact = + serde_json::to_value(acquisition("new-native", NativePersistence::Verified)).unwrap(); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", fact.clone()) + .await; + let admitted = names.get(vec![new.clone()]).await.unwrap(); + assert_eq!( + admitted.len(), + 1, + "a verified new conversation must have its own saved name" + ); + assert_eq!(admitted[0].record.name_ref, new); + assert_ne!(admitted[0].record.name, old_record.name); + assert!(names.current_location_revision(&new).await.unwrap() > 0); + assert_eq!(names.get(vec![old]).await.unwrap()[0].record, old_record); + // The accepted input can now upgrade this record without transferring + // the old conversation's protected name or redirecting either native ref. + names + .activity(NameActivity { + target: new.clone(), + mode: "freshcodex".into(), + event_id: "recovery-input".into(), + reason: NameActivityReason::AcceptedUserMessage, + first_user_message: Some("New recovery subject".into()), + cwd: Some("/work/recovery".into()), + }) + .await + .unwrap(); + assert_eq!( + names.get(vec![new]).await.unwrap()[0].record.name, + "New recovery subject" + ); + let manual = names + .rename(RenameNameInput { + target: target("new-native"), + name: "New protected manual name".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", fact) + .await; + assert_eq!( + names.get(vec![target("new-native")]).await.unwrap()[0].record, + manual.record, + "replayed acquisition must preserve the new conversation's own manual name" + ); + let location_revision = names + .current_location_revision(&target("new-native")) + .await + .unwrap(); + let mut stale_location = + serde_json::to_value(acquisition("new-native", NativePersistence::Verified)).unwrap(); + stale_location["data"]["acquisition"]["location"]["rolloutPath"] = + serde_json::json!("/changed-during-admission.jsonl"); + switch_after.store(2, Ordering::SeqCst); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", stale_location) + .await; + assert_eq!( + names + .current_location_revision(&target("new-native")) + .await + .unwrap(), + location_revision, + "a host source change across admission cannot authorize a new native location" + ); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + #[tokio::test] async fn hosted_native_name_reads_pin_the_source_and_preserve_typed_delivery_failures() { use crate::session_name_native::{NativeCallResult, NativeNameTarget}; @@ -2329,6 +2507,7 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() effective_limits: limits, }, HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { + native_identity: None, state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: None, }, @@ -2669,12 +2848,27 @@ async fn task6_gateway_recovery_receipts( let (mut stream, _) = listener.accept().await.unwrap(); let envelope: Envelope = read_frame(&mut stream).await.unwrap(); let result = match envelope.body { - AdminCommand::Health => AdminResult::Health {control_epoch:7,installation_id:InstallationId::new()}, - AdminCommand::Inventory => AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + let current: freshell_runtime_protocol::RuntimeView = serde_json::from_value(serde_json::json!({ "soulId":"retained-soul","incarnationId":if replace_on_continuation && !replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-incarnation"} else {"original-incarnation"},"hostBootId":"original-boot","launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex","nativeSessionId":if replace_on_continuation && replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-native"} else {"native-owned"},"freshAgentSessionId":"public-owned","freshAgentSessionType":"freshcodex" - })).unwrap()]), + })).unwrap(); + // Real inventory retains stopped historical incarnations + // before the current running row for the same soul. + let mut retired = current.clone(); + retired.launch_state = freshell_runtime_protocol::LaunchState::Stopped; + retired.incarnation_id = + freshell_runtime_protocol::IncarnationId::parse("retired-incarnation") + .unwrap(); + retired.host_boot_id = + Some(freshell_runtime_protocol::HostBootId::parse("retired-boot").unwrap()); + AdminResult::Inventory(vec![retired, current]) + } AdminCommand::FreshAgentReadEvents(request) => { - server_pages.fetch_add(1,std::sync::atomic::Ordering::SeqCst); + server_pages.fetch_add(1, std::sync::atomic::Ordering::SeqCst); AdminResult::FreshAgentEvents(freshell_runtime_protocol::AgentEventBatch { retained_state:(request.after_sequence==0).then(||serde_json::json!({ "source":{"soulId":"retained-soul","incarnationId":"original-incarnation","hostBootId":"original-boot","projectionId":"source-projection","ownerEpoch":0,"ownerGeneration":0}, diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index e250917c9..0ff4b648f 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -2163,12 +2163,30 @@ mod tests { async fn assert_native_mint_ack(pending: bool) { let logs = tempfile::tempdir().unwrap(); let log = logs.path().join("recovery-requests.jsonl"); - let fixture = native_codex_fixture(json!({ - "uniqueThreadIds":true,"recordTurns":true,"appendRequestLogPath":log, + let original_id = uuid::Uuid::new_v4().to_string(); + let replacement_id = uuid::Uuid::new_v4().to_string(); + let behavior = json!({ + "threadStartThreadId":original_id,"recordTurns":true,"echoRequestedModel":true,"appendRequestLogPath":log, "exitProcessAfterMethodsOnce":[if pending {"thread/start"} else {"turn/start"}], "exitProcessAfterMethodsOnceMarkerPath":logs.path().join("first-native-exit"), "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id original-native"}}} - })).await; + }); + let fixture = native_codex_fixture(behavior.clone()).await; + let qualified_behavior = |id: &str| { + let mut behavior = behavior.clone(); + behavior["threadStartThreadId"] = json!(id); + behavior["writeRolloutOnMethods"] = json!({"turn/start": { + "path": fixture.dir.path().join("codex-home/sessions").join(format!("rollout-{id}.jsonl")), + "record": {"type":"session_meta","payload":{"id":id,"cwd":fixture.profile.cwd}} + }}); + behavior + }; + // Qualified 0.164 writes the exact initial header before completion. + // Use the existing owned opt-in; shared fake defaults stay unchanged. + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + qualified_behavior(&original_id).to_string(), + ); let mut profile = fixture.profile.clone(); profile.naming_handle = pending.then(|| "pending-initial-recovery".into()); profile.effort = Some("xhigh".into()); @@ -2182,6 +2200,7 @@ mod tests { .await .unwrap(); let original = actor.profile().await.native_session_id.unwrap(); + assert_eq!(original, original_id); if !pending { actor .dispatch( @@ -2199,6 +2218,10 @@ mod tests { }) .await .expect("the owned native executable must actually exit"); + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + qualified_behavior(&replacement_id).to_string(), + ); actor .dispatch( RequestId::parse("recover-native").unwrap(), @@ -2239,7 +2262,49 @@ mod tests { Some(native.as_str()) ); assert_eq!(actor.snapshot().await.unwrap()["threadId"], native); + let naming_snapshot = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = actor.snapshot().await.unwrap(); + let has_proof = snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .is_some_and(|facts| { + facts.iter().any(|fact| { + fact["kind"] == if pending { "transition" } else { "acquisition" } + }) + }); + if snapshot["status"] == "idle" && has_proof { + break snapshot; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; actor.stop().await.unwrap(); + let naming_snapshot = naming_snapshot.expect("recovered native turn must settle"); + let fact = naming_snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .and_then(|facts| { + facts + .iter() + .find(|fact| fact["kind"] == if pending { "transition" } else { "acquisition" }) + }) + .expect("the verified recovered native source must retain its naming acquisition"); + assert_eq!(fact["data"]["target"]["sessionId"], native); + let acquisition = if pending { + assert_eq!( + fact["data"]["transition"]["reason"], + "InitialMaterialization" + ); + assert_eq!( + fact["data"]["transition"]["pending"], + "pending-initial-recovery" + ); + &fact["data"]["transition"]["acquisition"] + } else { + &fact["data"]["acquisition"] + }; + assert_eq!(acquisition["persistence"], "verified"); + assert_eq!(acquisition["location"]["nativeThreadId"], native); let rows = std::fs::read_to_string(log) .unwrap() .lines() From e2693a05bc8fd558587e8f78c353163a78c2b4fc Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:24:11 -0700 Subject: [PATCH 60/92] fix(codex): confirm owned provider exit before recovery --- crates/freshell-freshagent/src/codex.rs | 161 ++++++++--- crates/freshell-session-host/src/main.rs | 1 + .../src/providers/codex.rs | 196 +++++++++++++ .../src/providers/fresh_agent.rs | 261 +++++++++++++++++- .../src/providers/mod.rs | 3 +- 5 files changed, 584 insertions(+), 38 deletions(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index ecba7492b..5940d2763 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -541,6 +541,7 @@ pub struct FreshCodexState { naming: crate::naming::NamingSink, hosted_naming_events: bool, naming_rollout_probe: Arc>, + sidecar_cleanup_probe: Arc>, /// Unified agent names: thread id → the pre-durable naming handle /// admitted at create. A `thread/start` id/path is PROSPECTIVE (the /// rollout hasn't materialized), so the handle stays pending and the @@ -586,6 +587,16 @@ pub type CodexNamingRolloutProbe = Arc< + Sync, >; +/// Hosted cleanup runs under the same identity as the exact owned sidecar. +/// A positive answer must confirm the recorded incarnation and whole tree gone. +pub type CodexSidecarCleanupProbe = Arc< + dyn Fn( + crate::session_lease::CondemnedRuntimeIdentity, + ) -> futures_util::future::BoxFuture<'static, Result> + + Send + + Sync, +>; + /// The cached result of a completed codex `freshAgent.create`, keyed by `requestId` in /// [`FreshCodexState::create_dedup`]. Only `session_id` is needed: every other field of /// the `freshAgent.created` replay frame ([`FreshCodexState::handle_create`]'s replay @@ -936,6 +947,7 @@ impl FreshCodexState { naming: crate::naming::NamingSink::default(), hosted_naming_events: false, naming_rollout_probe: Arc::new(std::sync::OnceLock::new()), + sidecar_cleanup_probe: Arc::new(std::sync::OnceLock::new()), naming_handles: Arc::new(TokioMutex::new(HashMap::new())), snapshot_pause: Arc::new(StdMutex::new(None)), snapshot_pause_after_capture: Arc::new(StdMutex::new(None)), @@ -957,6 +969,10 @@ impl FreshCodexState { let _ = self.naming_rollout_probe.set(probe); } + pub fn set_sidecar_cleanup_probe(&self, probe: CodexSidecarCleanupProbe) { + let _ = self.sidecar_cleanup_probe.set(probe); + } + async fn report_hosted_naming_fact( &self, thread_id: &str, @@ -1237,11 +1253,9 @@ impl FreshCodexState { .get(session_id) .cloned(); if let Some(condemned) = condemned { - let confirmed = crate::session_lease::kill_and_confirm_recorded_tree_dead( - &condemned, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await; + let confirmed = + confirm_codex_tree_dead(&condemned, self.sidecar_cleanup_probe.get().cloned()) + .await; if confirmed { let ownership = self .ownership_watch() @@ -7766,8 +7780,53 @@ impl FreshCodexState { // and fall back to the ORIGINAL mint-new-thread recovery on a fresh sidecar. client.close().await; let mut dead_child = child; - let _ = dead_child.start_kill(); - reap_owned_codex_sidecars(&ownership_id); + let recorded = crate::session_lease::record_condemned_runtime_identity( + dead_child.id().unwrap_or(0), + CODEX_SIDECAR_OWNERSHIP_ENV, + &ownership_id, + ); + if !confirm_codex_tree_dead(&recorded, self.sidecar_cleanup_probe.get().cloned()) + .await + { + // No user input was dispatched. Retain exact cleanup + // evidence and claims until the owned tree is confirmed. + self.condemned_priors + .lock() + .expect("condemned priors lock") + .insert(session_id.to_string(), recorded.clone()); + let confirmation = codex_tree_death_escalation( + recorded, + ownership_id.clone(), + session_id.to_string(), + self.sidecar_cleanup_probe.get().cloned(), + ); + let state = self.clone(); + let key = session_id.to_string(); + tokio::spawn(async move { + if confirmation.await { + let _ = dead_child.wait().await; + crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); + state.clear_condemned_prior_if_matching(&key, &ownership_id); + if let Some(mut guard) = lease_guard { + guard.fail(); + } + drop(own_ticket); + } else { + std::future::pending::<()>().await; + drop((dead_child, lease_guard, own_ticket)); + } + }); + tracing::warn!( + provider = PROVIDER, + session_id, + "freshagent.codex.failed_resume_cleanup_unconfirmed" + ); + return Err(EnsureAliveError::RespawnFailed( + "the failed Codex resume's owned process tree is not yet confirmed stopped" + .into(), + )); + } + let _ = dead_child.wait().await; crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); // FIX (CODEX-FIRST triage Finding 2): remember this id as genuinely gone so // a later attach/create against it fails fast instead of repeating this same @@ -11346,17 +11405,43 @@ fn with_codex_turn_exit_confirmation( }) } -/// Continue an incomplete Codex tree reap after the bounded watcher window. The first -/// watcher pass has already stopped the direct child; this continuation keeps probing the -/// recorded incarnation and tagged descendants until the complete writer tree is confirmed -/// gone. An incomplete identity capture cannot be repaired safely, so it stays fail-closed. +/// Keep the same recorded process identity when a hosted provider needs its +/// own uid to inspect and terminate the tree. Local lanes use the same primitive. +async fn confirm_codex_tree_dead( + recorded: &crate::session_lease::CondemnedRuntimeIdentity, + probe: Option, +) -> bool { + match probe { + Some(probe) => match probe(recorded.clone()).await { + Ok(confirmed) => confirmed, + Err(error) => { + tracing::warn!(provider=PROVIDER, pid=recorded.pid, ownership_id=%recorded.ownership_id, %error, + "freshagent.codex.owned_cleanup_unavailable"); + false + } + }, + None => { + crate::session_lease::kill_and_confirm_recorded_tree_dead( + recorded, + CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await + } + } +} + +/// Continue the existing exact-owned teardown until confirmed. A hosted +/// identity helper may complete a capture its unprivileged host could not read. fn codex_tree_death_escalation( recorded: crate::session_lease::CondemnedRuntimeIdentity, ownership_id: String, thread_id: String, + cleanup_probe: Option, ) -> std::pin::Pin + Send + 'static>> { Box::pin(async move { - if !recorded.capture_complete { + if (!recorded.capture_complete && cleanup_probe.is_none()) + || (recorded.start_time.is_none() && cleanup_probe.is_some()) + { tracing::error!(target: "freshell_freshagent::codex", session_id = %thread_id, ownership_id = %ownership_id, @@ -11364,12 +11449,7 @@ fn codex_tree_death_escalation( return false; } loop { - if crate::session_lease::kill_and_confirm_recorded_tree_dead( - &recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await - { + if confirm_codex_tree_dead(&recorded, cleanup_probe.clone()).await { return true; } tracing::warn!(target: "freshell_freshagent::codex", @@ -11599,6 +11679,9 @@ fn spawn_exit_watcher_tracking_turns( ) }); let ownership = ownership.map(|watch| watch.for_runtime(&thread_id, child.id())); + let cleanup_probe = retained_state + .as_ref() + .and_then(|state| state.sidecar_cleanup_probe.get().cloned()); tokio::spawn(async move { // `biased` + the REQUESTED-kill arm listed FIRST: a `freshAgent.kill` signals // `kill_tx` right before `start_kill()`s the child, so `child.wait()` can become @@ -11627,21 +11710,29 @@ fn spawn_exit_watcher_tracking_turns( // Arcs at its rebuild, and a teardown void would reopen // the gate for a buffered late completion of the // already-rang turn. - let _ = child.start_kill(); - let wait_result = child.wait().await; - let confirmed = if cfg!(target_os = "linux") { - match recorded.as_ref() { - Some(recorded) => { - crate::session_lease::kill_and_confirm_recorded_tree_dead( - recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await - } + let (wait_result, confirmed) = if cleanup_probe.is_some() { + let confirmed = match recorded.as_ref() { + Some(recorded) => confirm_codex_tree_dead(recorded, cleanup_probe.clone()).await, None => false, - } + }; + let waited = if confirmed { + child.wait().await + } else { + Err(std::io::Error::other("owned provider exit remains unconfirmed")) + }; + (waited, confirmed) } else { - false + let _ = child.start_kill(); + let waited = child.wait().await; + let confirmed = if cfg!(target_os = "linux") { + match recorded.as_ref() { + Some(recorded) => confirm_codex_tree_dead(recorded, None).await, + None => false, + } + } else { + false + }; + (waited, confirmed) }; if confirmed { if let Some(turns) = &turn_state { turns.lock().expect("codex turn state").settle_native("", NativeSettlement::OwnedExit); } @@ -11689,6 +11780,7 @@ fn spawn_exit_watcher_tracking_turns( recorded, ownership_id.clone(), thread_id.clone(), + cleanup_probe.clone(), ) }) .unwrap_or_else(|| Box::pin(async { false })); @@ -11714,11 +11806,7 @@ fn spawn_exit_watcher_tracking_turns( let confirmed = if cfg!(target_os = "linux") { match recorded.as_ref() { Some(recorded) => { - crate::session_lease::kill_and_confirm_recorded_tree_dead( - recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await + confirm_codex_tree_dead(recorded, cleanup_probe.clone()).await } None => false, } @@ -11775,6 +11863,7 @@ fn spawn_exit_watcher_tracking_turns( recorded, ownership_id.clone(), thread_id.clone(), + cleanup_probe.clone(), ) }) .unwrap_or_else(|| Box::pin(async { false })); diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index e1fa3c6ad..d94e97021 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -179,6 +179,7 @@ async fn run() -> Result<(), String> { Some("opencode-identity-worker") => pty::run_opencode_identity_worker(&args[2..]), Some("provider-probe-worker") => providers::run_probe_worker(&args[2..]), Some("codex-naming-proof") => providers::run_naming_proof(&args[2..]), + Some("codex-owned-cleanup") => providers::run_owned_cleanup(&args[2..]).await, Some("refresh-provider-config") => refresh_provider_config_worker(&args[2..]), Some("prepare-provider-features") => providers::run_provider_features_worker(&args[2..]), Some("prepare-amplifier-state") => providers::run_amplifier_state_worker(&args[2..]), diff --git a/crates/freshell-session-host/src/providers/codex.rs b/crates/freshell-session-host/src/providers/codex.rs index 50e71aea6..ffd848802 100644 --- a/crates/freshell-session-host/src/providers/codex.rs +++ b/crates/freshell-session-host/src/providers/codex.rs @@ -12,6 +12,139 @@ use freshell_runtime_protocol::{ResumeSpec, TerminalLaunchSpec}; use std::sync::Arc; use tokio::sync::mpsc; +/// Retire only a sidecar identity already owned by this host invocation. +/// Run under the provider uid so private /proc evidence and signals retain +/// their normal permissions; the host gains no DAC or KILL capability. +pub async fn cleanup_sidecar( + recorded: freshell_freshagent::session_lease::CondemnedRuntimeIdentity, + uid: u32, + gid: u32, +) -> Result { + use tokio::io::AsyncWriteExt; + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut child = cli::provider_command(std::path::Path::new("/usr/bin/timeout"), uid, gid) + .args(["--kill-after=1s", "5s"]) + .arg(executable) + .args([ + "codex-owned-cleanup", + "--uid", + &uid.to_string(), + "--gid", + &gid.to_string(), + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("run owned Codex cleanup: {error}"))?; + let payload = serde_json::to_vec(&serde_json::json!({"pid":recorded.pid,"startTime":recorded.start_time,"ownershipId":recorded.ownership_id})).map_err(|error| error.to_string())?; + let mut input = child + .stdin + .take() + .ok_or("owned Codex cleanup has no input")?; + input + .write_all(&payload) + .await + .map_err(|error| error.to_string())?; + drop(input); + let output = child + .wait_with_output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(format!( + "owned Codex cleanup exited {:?}", + output.status.code() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode owned Codex cleanup: {error}")) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct OwnedCleanup { + pid: u32, + start_time: Option, + ownership_id: String, +} + +pub async fn run_owned_cleanup(args: &[String]) -> Result<(), String> { + use std::io::Read; + let identity = |key: &str| { + args.windows(2) + .find(|pair| pair[0] == key) + .and_then(|pair| pair[1].parse::().ok()) + .ok_or_else(|| format!("missing cleanup identity {key}")) + }; + if unsafe { libc::geteuid() } != identity("--uid")? + || unsafe { libc::getegid() } != identity("--gid")? + { + return Err("owned Codex cleanup identity mismatch".into()); + } + let request: OwnedCleanup = serde_json::from_reader(std::io::stdin().take(16 * 1024)) + .map_err(|_| "invalid owned Codex cleanup request")?; + serde_json::to_writer(std::io::stdout(), &confirm_owned_cleanup(request).await) + .map_err(|error| format!("encode owned Codex cleanup: {error}")) +} + +async fn confirm_owned_cleanup(request: OwnedCleanup) -> bool { + #[cfg(target_os = "linux")] + { + use freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV; + let Some(expected_start) = request.start_time else { + return false; + }; + if request.pid == 0 + || request.pid > i32::MAX as u32 + || request + .ownership_id + .strip_prefix("codex-sidecar-") + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_none() + { + return false; + } + if let Some(observed) = freshell_codex::sidecar_store::proc_starttime(request.pid as i32) { + if observed != expected_start { + return false; + } + let Ok(environment) = std::fs::read(format!("/proc/{}/environ", request.pid)) else { + return false; + }; + let needle = freshell_codex::durability::ownership_needle(&request.ownership_id); + if !environment + .split(|byte| *byte == 0) + .any(|entry| entry == needle.as_bytes()) + { + return false; + } + } + let mut recorded = freshell_freshagent::session_lease::record_condemned_runtime_identity( + request.pid, + CODEX_SIDECAR_OWNERSHIP_ENV, + &request.ownership_id, + ); + if recorded + .start_time + .is_some_and(|start| start != expected_start) + { + return false; + } + recorded.start_time = Some(expected_start); + freshell_freshagent::session_lease::kill_and_confirm_recorded_tree_dead( + &recorded, + CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await + } + #[cfg(not(target_os = "linux"))] + { + let _ = request; + false + } +} + /// Inspect the native recorder under its owner, without starting or resuming /// Codex and without giving the host access to the private provider HOME. pub async fn naming_rollout( @@ -129,3 +262,66 @@ pub async fn probe( } cli::probe_as_provider(spec, run_as_uid, run_as_gid).await } + +#[cfg(all(test, target_os = "linux"))] +mod cleanup_tests { + use super::*; + + #[tokio::test] + async fn task010_owned_cleanup_rejects_unknown_and_stale_identity_before_signaling() { + let tag = format!("codex-sidecar-{}", uuid::Uuid::new_v4()); + let mut child = tokio::process::Command::new("sleep") + .arg("60") + .env( + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = child.id().unwrap(); + let start = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let env = std::fs::read(format!("/proc/{pid}/environ")).unwrap_or_default(); + if env.split(|byte| *byte == 0).any(|entry| { + entry == freshell_codex::durability::ownership_needle(&tag).as_bytes() + }) { + break freshell_codex::sidecar_store::proc_starttime(pid as i32).unwrap(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + for (start_time, ownership_id) in [ + (None, tag.clone()), + (Some(start + 1), tag.clone()), + ( + Some(start), + format!("codex-sidecar-{}", uuid::Uuid::new_v4()), + ), + ] { + assert!( + !confirm_owned_cleanup(OwnedCleanup { + pid, + start_time, + ownership_id + }) + .await + ); + assert!( + child.try_wait().unwrap().is_none(), + "unknown or stale identity must not signal even this test's child" + ); + } + assert!( + confirm_owned_cleanup(OwnedCleanup { + pid, + start_time: Some(start), + ownership_id: tag + }) + .await + ); + assert!(!child.wait().await.unwrap().success()); + } +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 0ff4b648f..8fc11e21c 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -100,6 +100,9 @@ pub(crate) async fn open_hosted_fresh_agent( state.set_naming_rollout_probe(Arc::new(move |home, thread| { Box::pin(super::codex::naming_rollout(home, thread, uid, gid)) })); + state.set_sidecar_cleanup_probe(Arc::new(move |recorded| { + Box::pin(super::codex::cleanup_sidecar(recorded, uid, gid)) + })); } transport } @@ -2153,6 +2156,255 @@ mod tests { } } + #[tokio::test] + async fn task010_failed_resume_confirms_its_owned_tree_before_minting_again() { + let logs = tempfile::tempdir().unwrap(); + let marker = logs.path().join("original-native-exit"); + let fixture = native_codex_fixture(json!({"recordTurns":true, + "exitProcessAfterMethodsOnce":["turn/start"],"exitProcessAfterMethodsOnceMarkerPath":marker, + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id old-owned-native"}}} + })).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("owned-tree-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("first-before-exit").unwrap(), + "first native input".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the original owned executable must exit"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let ready = logs.path().join("failed-resume-descendant.ready"); + let wrapper = fixture._exec_dir.path().join("failed-resume-tree.mjs"); + let parameters = json!({"source":source,"marker":logs.path().join("resume-sidecar-seen"), + "ready":ready,"output":logs.path().join("failed-resume-descendant.log"),"port":logs.path().join("failed-resume-descendant.port"),"replacement":uuid::Uuid::new_v4().to_string()}); + let script = r#"#!/usr/bin/env node +import fs from 'node:fs' +import {spawn} from 'node:child_process' +const parameters=PARAMETERS +if(!fs.existsSync(parameters.marker)){ + fs.writeFileSync(parameters.marker,'first resume attempt') + const child=spawn(process.execPath,[parameters.source,'fake-durable-writer',parameters.output,parameters.ready,parameters.port],{ + env:{...process.env,FAKE_CODEX_WRITER_IGNORE_SIGTERM:'1'},stdio:'ignore'}) + child.unref() + const deadline=Date.now()+5000 + while(!fs.existsSync(parameters.ready)){ + if(child.exitCode!==null||Date.now()>deadline)throw Error('Owned failed-resume descendant did not become ready') + await new Promise(resolve=>setTimeout(resolve,10)) + } +} +const behavior=JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR) +behavior.threadStartThreadId=parameters.replacement +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify(behavior) +await import(parameters.source) +"#.replace("PARAMETERS", ¶meters.to_string()); + std::fs::write(&wrapper, script).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::env::set_var("CODEX_CMD", wrapper); + let recovery = tokio::time::timeout( + Duration::from_secs(10), + actor.dispatch( + RequestId::parse("recover-after-missing-rollout").unwrap(), + "recovery native input".into(), + None, + ), + ) + .await; + let pid = std::fs::read_to_string(&ready) + .unwrap() + .trim() + .parse::() + .unwrap(); + let surviving_start = freshell_codex::sidecar_store::proc_starttime(pid); + actor.stop().await.unwrap(); + // This test's marked persistent descendant has no natural exit. Clean + // only its exact captured incarnation before asserting the result. + if let Some(start) = surviving_start { + if freshell_codex::sidecar_store::proc_starttime(pid) == Some(start) { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + recovery + .expect("owned recovery must settle") + .expect("native recovery input must be accepted"); + assert!(surviving_start.is_none(), "the failed resume's owned descendant must be confirmed terminal before mint-new acceptance"); + } + + #[tokio::test] + async fn task010_unknown_owned_cleanup_retains_claim_and_never_replays_input() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + let logs = tempfile::tempdir().unwrap(); + let requests = logs.path().join("native-requests.jsonl"); + let mut behavior = json!({"recordTurns":true,"appendRequestLogPath":requests, + "exitProcessAfterMethodsOnce":["turn/start"],"exitProcessAfterMethodsOnceMarkerPath":logs.path().join("original-exit"), + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id thread-new-1"}}} + }); + let fixture = native_codex_fixture(behavior.clone()).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("unknown-cleanup-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + // The original watcher already owns its local confirmation primitive. + // Only the subsequently spawned failed-resume tree sees this outage. + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + let available = Arc::new(AtomicBool::new(false)); + let attempts = Arc::new(AtomicUsize::new(0)); + state.set_sidecar_cleanup_probe(Arc::new({ + let available = available.clone(); + let attempts = attempts.clone(); + move |recorded| { + let available = available.clone(); + let attempts = attempts.clone(); + Box::pin(async move { + attempts.fetch_add(1, Ordering::SeqCst); + if !available.load(Ordering::SeqCst) { + return Err("owned cleanup temporarily unavailable".into()); + } + Ok( + freshell_freshagent::session_lease::kill_and_confirm_recorded_tree_dead( + &recorded, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await, + ) + }) + } + })); + actor + .dispatch( + RequestId::parse("original-input").unwrap(), + "original accepted input".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + behavior["threadStartThreadId"] = json!(uuid::Uuid::new_v4().to_string()); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()); + let refused = tokio::time::timeout( + Duration::from_secs(5), + actor.dispatch( + RequestId::parse("cleanup-refused-input").unwrap(), + "must never be replayed".into(), + None, + ), + ) + .await + .unwrap(); + assert!( + refused.is_err(), + "unconfirmed cleanup must refuse recovery: {refused:?}" + ); + assert!(attempts.load(Ordering::SeqCst) > 0); + assert!( + state + .leases() + .peek_kill_handle("codex", "thread-new-1") + .is_some(), + "the failed resume must retain its exact owned claim" + ); + let read_requests = || { + std::fs::read_to_string(&requests) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>() + }; + let before = read_requests(); + assert_eq!( + before + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1, + "unknown cleanup must not mint a replacement" + ); + assert_eq!( + before + .iter() + .filter(|row| row["method"] == "turn/start") + .count(), + 1, + "refused input must never reach native work" + ); + available.store(true, Ordering::SeqCst); + tokio::time::timeout(Duration::from_secs(5), async { + while state + .leases() + .peek_kill_handle("codex", "thread-new-1") + .is_some() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("only positive exact-owned confirmation may release the claim"); + actor + .dispatch( + RequestId::parse("new-explicit-recovery-input").unwrap(), + "new explicit input after confirmation".into(), + None, + ) + .await + .unwrap(); + actor.stop().await.unwrap(); + let after = read_requests(); + assert_eq!( + after + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 2 + ); + let turns = after + .iter() + .filter(|row| row["method"] == "turn/start") + .collect::>(); + assert_eq!( + turns.len(), + 2, + "only original and new explicit inputs may be dispatched" + ); + assert!(!turns + .iter() + .any(|row| row.to_string().contains("must never be replayed"))); + assert!(turns[1] + .to_string() + .contains("new explicit input after confirmation")); + } + #[tokio::test] async fn task010_native_mint_ack_updates_actor_before_snapshot_and_second_send() { for pending in [false, true] { @@ -2279,8 +2531,15 @@ mod tests { } }) .await; + let naming_snapshot = match naming_snapshot { + Ok(snapshot) => snapshot, + Err(_) => actor.snapshot().await.unwrap(), + }; actor.stop().await.unwrap(); - let naming_snapshot = naming_snapshot.expect("recovered native turn must settle"); + assert_eq!( + naming_snapshot["status"], "idle", + "recovered native turn must actually settle" + ); let fact = naming_snapshot["extensions"]["codex"]["hostedNamingFacts"] .as_array() .and_then(|facts| { diff --git a/crates/freshell-session-host/src/providers/mod.rs b/crates/freshell-session-host/src/providers/mod.rs index b1ba62b14..3770fae1e 100644 --- a/crates/freshell-session-host/src/providers/mod.rs +++ b/crates/freshell-session-host/src/providers/mod.rs @@ -1,6 +1,6 @@ //! Provider-specific recovery probes executed inside the soul enclosure. //! -//! The trusted host delegates store reads to a short-lived helper running as +//! The trusted host delegates store reads and exact owned sidecar cleanup to helpers running as //! the same unprivileged uid/gid as the provider. This avoids granting the host //! DAC override while keeping all provider-state inspection inside the runtime. @@ -19,6 +19,7 @@ pub(crate) use deterministic_fresh_agent::run_worker as run_fresh_agent_fixture_ pub(crate) use fresh_agent::open_hosted_fresh_agent; pub(crate) use codex::run_naming_proof; +pub(crate) use codex::run_owned_cleanup; pub(crate) use codex::PreparedCodexLaunch; use freshell_agent_runtime::ProviderStoreProbe; From 27242b6a9a54950b3982937bb410fcd811cf143a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:37:40 -0700 Subject: [PATCH 61/92] test(codex): complete integrated native and browser journeys --- README.md | 14 +- .../src/fresh_agent_proxy_tests.rs | 20 +- docs/index.html | 4 +- .../managed-provider-environment-target.ts | 2 +- ...esh-agent-attention-reconnect-rust.spec.ts | 31 +- ...hagent-live-model-convergence-rust.spec.ts | 54 ++- .../freshcodex-live-transcript-rust.spec.ts | 390 ++++++++++++++- .../codex-app-server/fake-app-server.mjs | 28 +- .../providers/codex-cli-contract.mjs | 22 + .../providers/codex-cli-contract.test.ts | 9 + .../server/fresh-agent-parity.test.ts | 459 ++++++++++++++++-- 11 files changed, 976 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 36623bc02..c9445d010 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,19 @@ The service is standalone and independent of Electron. ### Fresh agents -Freshclaude, Freshcodex, and Freshopencode share a chat interface with tool output, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Changes apply to your next message; the picker remembers recent choices for each project. +Freshclaude, Freshcodex, and Freshopencode share a chat interface with live tool output, attachments, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Freshcodex lists models offered by its running Codex installation and also accepts an exact custom model ID. Changes apply to your next message; the picker remembers recent choices for each project. **Default** removes Freshell’s thinking-level override; Codex may still use its own configuration or a choice retained from earlier turns. -In Freshclaude, “Allow and don't ask again” saves the rule Claude suggests, listed on the card before you click (project rules go to `.claude/settings.local.json` in the project), and when Claude finishes planning it shows the plan with choices to approve it, approve and auto-accept edits, approve and stop asking for permission, or keep planning. In Freshcodex, “Always allow” lasts until the pane reloads. +In Freshclaude, “Allow and don't ask again” saves the rule Claude suggests, listed on the card before you click (project rules go to `.claude/settings.local.json` in the project), and when Claude finishes planning it shows the plan with choices to approve it, approve and auto-accept edits, approve and stop asking for permission, or keep planning. Freshcodex shows the decisions offered by Codex for each request, including “Allow for this session” when available. -You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. Codex permission settings control when it asks for approval; “Never ask” does not change the session’s file or network access limits. +Freshcodex settings separate **Build or Plan**, **File access**, and approval policy. “Never ask” does not expand file or network access. Incompatible model, effort, or provider settings produce a diagnostic instead of silently broadening access. + +Questions that await an answer pause the requested work. Async questions let Codex continue; replying sends an ordinary contextual message, and its status confirms delivery rather than completion of the requested work. MCP prompts offer explicit accept, decline, and cancel actions. Opening a linked page does not accept its prompt. Secret answers stay masked. + +You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. + +Codex child conversations open read-only inside their parent. Available history can include live or saved output; ephemeral child history can be unavailable. Output labels distinguish retained, saved, interrupted, and truncated content, so a reload does not imply that every streamed byte was saved. + +Use `/undo` to roll back Codex conversation turns without changing files; Codex has no `/redo`. **Rewind code to here** restores a message’s code checkpoint separately. `!command` runs a shell command in the conversation’s working directory and sends its output to the agent. ### Session switching and recovery diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 89f1abf58..7c6374c3b 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -412,7 +412,8 @@ async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_so } #[tokio::test] -async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_sources() { +async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_sources_and_ephemeral_history( +) { use tower::ServiceExt; let mut env = crate::test_env_lock::TestEnv::lock().await; let dir = tempfile::tempdir().unwrap(); @@ -426,6 +427,7 @@ async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_s for scenario in [ "saved", "live", + "ephemeral", "replaced-owner", "wrong-child", "unrelated", @@ -466,7 +468,11 @@ async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_s )) } else { Ok(AdminResult::FreshAgentChildSnapshot( - serde_json::json!({"parentThreadId":"parent-native","childThreadId":if scenario == "wrong-child" {"foreign-child"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","sessionType":"freshcodex","turns":[{"text":"Provider volume child"}],"capabilities":{"send":true,"interrupt":true,"fork":true,"undo":true},"pendingApprovals":[],"pendingQuestions":[],"extensions":{}}}), + if scenario == "ephemeral" { + serde_json::json!({"parentThreadId":"parent-native","childThreadId":"child-native","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}) + } else { + serde_json::json!({"parentThreadId":"parent-native","childThreadId":if scenario == "wrong-child" {"foreign-child"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","sessionType":"freshcodex","turns":[{"text":"Provider volume child"}],"capabilities":{"send":true,"interrupt":true,"fork":true,"undo":true},"pendingApprovals":[],"pendingQuestions":[],"extensions":{}}}) + }, )) } } @@ -527,6 +533,13 @@ async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_s assert_eq!(body["status"], scenario); assert_eq!(body["snapshot"]["capabilities"]["send"], false); assert_eq!(body["snapshot"]["capabilities"]["fork"], false); + } else if scenario == "ephemeral" { + assert_eq!(body["parentThreadId"], "parent-native"); + assert_eq!(body["childThreadId"], "child-native"); + assert_eq!(body["status"], "unavailable"); + assert_eq!(body["reason"], "ephemeral_history_unavailable"); + assert_eq!(body["retryable"], false); + assert!(body.get("snapshot").is_none()); } else { assert_eq!(body["reason"], "source_unavailable"); assert!(body.get("snapshot").is_none()); @@ -2879,7 +2892,8 @@ async fn task6_gateway_recovery_receipts( // The controller's own bound can be smaller than the requested // page. Receipts deliberately lie beyond the first page. events:payloads.iter().enumerate().filter(|(index,_)|(*index as u64)+1>request.after_sequence).take(request.max_events.min(2) as usize).map(|(index,event)|freshell_runtime_protocol::AgentJournalEvent {sequence:index as u64+1,event:event.clone()}).collect(), - })}, + }) + } other => panic!("recovery must only read events and source identity: {other:?}"), }; write_frame( diff --git a/docs/index.html b/docs/index.html index b96f539d2..3edd012b3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -976,7 +976,7 @@

Task Board

~/code/freshell
-
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Codex child conversations open as read-only observations inside their parent pane. Freshcodex settings include File access and Build or Plan work mode. Model and thinking changes apply to your next message; Default lets Codex choose its thinking level. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
+
Freshcodex lists models from its running Codex installation and accepts exact custom IDs. Model and thinking changes apply to your next message; Default removes Freshell’s effort override. Build or Plan, File access, and approval policy are separate settings. Awaited questions pause work; async questions let it continue. MCP prompts require an explicit decision, even after opening a linked page. Child conversations open read-only, with truthful saved, retained, interrupted, or unavailable output. /undo changes conversation history; Rewind code to here restores files separately. Codex has no /redo.
Diffs
src/components/fresh-agent/FreshAgentView.tsx
@@ -996,7 +996,7 @@

Task Board