Accept messages while any agent starts, and bound each startup step (#28046)

* Finish structured agent readiness before delivering held work

* Publish the Codex process before protocol startup

* Publish ACP agent processes before session startup

* Publish Pi at spawn and settle signed-out starts once

* Adapt permission fixtures to startup lifecycle

* Keep model discovery for chats before their first start

* Use one host readiness promise for provider operations

* Await provider opening in the execution host

* Await Claude initialization in host-owned open

* Observe Claude readiness through the host status

* Keep optional readback behind host readiness

* Recheck launch permission after host readiness

* Preserve the awaited child during permission revalidation

* Await Codex protocol open under host ownership

* Await ACP protocol open in the host

* Derive ACP opening from its protocol session facts

* Exercise unanswered ACP options with an explicit silent route

* Await Pi open and fail signed-out construction

* Read Pi catalog facts from awaited opening

* Keep the Pi fixture exit observer

* Record failed owned opening before the first message

* Abort a matching provider open when its child exits

* Cover provider exit during an unanswered host open

* Cover explicit failure facts on a host-stopped child

* Capture message choices per accepted send and preserve picker state

* Apply accepted Claude message choices at prompt handover

* Apply captured message choices at provider dispatch

* Bind native chat choices to accepted message dispatch

* Reset Codex turn choices for each captured message

* Resolve Codex message defaults from native facts

* Align message choice and Pi default fixtures with provider dispatch

* Align option fixtures with saved message choices

* Match Codex message choices to the active turn origin

* fix: let pending commands reach handover refusal

* Abort pre-prompt message choice writes with host-owned wait

* Verify message choices through provider input boundaries

* Keep legacy mobile test hosts explicit about message choices

* Keep startup waits cancellable and preserve operation order

* Derive Codex startup exits from host phase

* Open Codex protocol in background-task test fixtures

* Keep ACP startup ownership until handshake completes

* Run ACP opening catalog fixtures with Node SQLite

* Verify saved ACP picks without live option writes

* Keep ACP catalog bookkeeping from failing an options read

* Restore signed-out restart and fail Pi startup faults

* Run Pi opening catalog fixtures with Node SQLite

* Preserve message choices across cold clients and kept cards

* Remove the unused provider option restore ledger

* Clarify launch choices and legacy option hydration

* Remove remaining restore-ledger forwarding and assertions

* Remove unused ACP and Pi restore bookkeeping

* Keep live choices and delivery order while agents start

* Drop retired startup revision from Codex opening

* Keep live-option cancellation until message choices and clarify catalog reads

* Hold ACP option replies in live-write cancellation regressions

* Remove retired Pi startup revision and match saved-pick behavior

* Check running choices and retained rewind messages after delivery integration

* Apply startup picks and let parked messages release operations [skip ci]

* Remove unused Codex opening report field [skip ci]

* Keep ACP restoration facts local to the provider [skip ci]

* Apply Pi startup picks after readiness and remove unused open facts [skip ci]

* Cover goal changes during the held Codex message wait [skip ci]

* Save a waiting startup pick after Stop ends its opening [skip ci]

* Save choices when startup ends or a replacement is still opening

* Check saved choices separately from Claude startup reports

* Release scripted Codex startup before awaiting queued picks

* Check the saved picker selection in per-message choice fixtures

* Exercise current choices and eager client capabilities in runtime fixtures

* Reuse runtime fixture reads and type the fake connection directly

* Align startup fixtures and preserve observed Claude exits

* Honor queued send authorization in Claude runtime fixtures

* Open Codex protocol sessions before Stop fixture dispatch

* Wait for the opened Codex child before runtime notifications

* Keep repeated permission picks usable and align Codex fixtures

* Validate pending picks against the live option state

* Order startup picks before later messages and retain flow regressions

* Keep reviewed lifecycle contracts while pruning duplicate tests

* Save picks after a proven exit without requiring a child

* Type the startup reply fixture against the Claude control contract

* Keep Codex startup flow contracts and remove overlapping adapter tests

* Restore the committed dependency lockfile after test tooling

* Keep startup recovery flows and remove duplicate Pi handshake cases

* Cover both Pi startup choices in the delivery flow

* Match the Pi prompt entry recorded by the scripted wire

* Honor the before-input callback in the startup reply fixture

* Keep message choice flows and remove overlapping selector tests

* Keep startup option menus independent and show the saved choice

* Exercise startup picks across real menus and the runtime

* Match startup picker fixture to saved message choices

* Keep saved effort visible when picking a model during startup

* Include the running-turn scope in repeated Stop fixture

* Use the current attach contract in import fixtures

* Keep the Codex journal translator within its line limit

* Match ACP startup fixtures to saved picks and late Stop behavior

* Check startup effort after saved picker choices are acknowledged

* Complete Stop target types and keep the effort regression concise

* Use requested picker values in the effort regression acknowledgement

* Require owned provider spawn and one host-awaited open (from #28055)

* Restore the live checks the startup cleanup's deleted tests pinned

One assertion each, in the surviving suites: a stale-generation exit is
ignored, a failed create lets its sink go, a readback before open is saved,
an idle stop that fails is retried, a no-pick Pi start teaches its default,
an older client's pre-spawn failure and packed answer, an undrivable chat
stays readable, the resting slash menu stays on its own host, the main agent
reads apart from subagents, restore publishes status, naming runs off the
host's status feed, and a throwing logger never reaches the operation.

Also corrects the reliability gate note that claimed the send-idempotency
suite replaced the refusal-retry tests.

* Read a rewind in doubt outside the chat's serialize and apply it in the commit

The started settle and the attach's ready-child path read the provider's
history first, then apply what it proves in a short commit that re-checks
the rewind it read.

* Accept messages while the agent starts: run the start outside the chat's serialize

The chat's serialize was both where a send was admitted and where its agent
started, so every slow step of a start (closing-child join, lease
reconcile, recovery probes, resolvers, history read, spawn, sink drain,
provider dispatch) held every later send.

A start now registers in one short step before its first wait: its abort
controller (which Stop, close and quit already reach outside the lock),
its startup clock (the 10-minute ceiling now counts from here) and quit's
drain. Every wait after runs outside the lock, raced with that signal, and
every write is a commit that first re-checks the start is still the
session's. A start that loses the check is late: its spawn is closed
through the post-acquisition failure path and nothing is handed to it.

- The delivery loop's prepare returns a start handle; the loop awaits the
  start outside, then re-prepares. Handover writes the pending row and
  begins the dispatch abort handle in one commit, calls the provider
  outside after re-checking the child, and resolves in a second commit.
- Ready operations claim a start instead of running one in their step.
- A client attach runs the same steps under one serialize, as before.
- An exit reported between spawn and publish is held for the publish
  commit, which ends the child it indexes.
- An unproven close still fails only what was queued when its start was
  asked for.

* Test that a send is accepted at every wait an agent start makes

One whole-flow test holds the start at each wait in turn (closing-child
join, reconcile probe, recovery re-probe, resolver, spawn, sink drain,
open, started recovery, pending row, provider call): a second send is
accepted at once, a Stop is recorded without waiting and nothing reaches
the provider, and what was sent goes to the published child once, in
order. Also quit before spawn, concurrent sends, a lease left recovering,
and an idle sweep racing a send.

* Bound each startup step on its own and drop the startup silence rule

A start no longer ends after 60 s without output: output is not protocol
progress. Each protocol step now has its own bound where its handshake
lives, and one 10-minute ceiling from registration stays as the outer bound.

- Codex: initialize 15 s and thread start/resume on the connection's 30 s
  default again, instead of the startup ceiling.
- ACP: initialize 60 s, authenticate 120 s, session new/load/resume/fork
  300 s; turns stay unbounded and option writes keep their caller's bound.
- Claude: the initialize answer has a 60 s bound; past it the open fails
  with the ordinary startup-failure words and the host ends the child.
- The onOutput plumbing that only fed the silence rule is removed.

* Count Pi ready once it reports its state

Pi's start no longer waits for its model listing and slash commands.
The open returns after get_state and the unknown-provider check; the
listing then arrives as an options report (with the catalog listing the
start resolved) and the commands as a later publish. An empty model list
no longer fails the start as signed out: a signed-out Pi runs the unknown
provider, which still fails it, and a prompt it cannot run is refused
with the sign-in words by the existing prompt path.

get_state keeps the connection's 30 s request bound instead of the
startup ceiling.

* Test that model and effort picked while a chat is created ride its first message

A slow host's create reply arrives after the user picked a model and effort. The picker's view
layers the launch's held picks over its seed and the host's saved selection, and every send
carries what the picker shows, so the first message carries the picks whether the host's status
names its selection or not, and while the last pick is still on its way to the host.

* Start a phone's new empty chat on its first message

The phone's new chat is an agent.launch with no prompt, which always created the chat with its
agent started, while desktop chats start on the first message. The phone now advertises the
create-without-start capability, and a launch with no prompt from a caller advertising it creates
the chat at rest, keyed in the ledger by its create intent as a client's create is. Launches with
a prompt, the CLI (which advertises nothing), orchestration workers and ensure still start at once;
an older host ignores the capability and stays eager.

* Take main's lockfile; the branch changes no dependencies

* Restore main's exact text in files the merge only reformatted

The pre-commit formatter rewrote 37 of main's files staged by the merge, though this branch never changed them, pushing two past the line limit. Committed without the hook because the hook would reformat main's text again; CI lints the result.

* Fit the startup tests to the owned-spawn, host-awaited-open contract

Test doubles gain the mandatory open and an acquisition generation, the
removed started event gives way to the rig's proveStart, and two literal
types are tightened for CI's typecheck. A Stop during a held start writes
no host note on this branch: the taken-back send draws its own
stopped-before-start row, which the startup-attempt test now asserts.

* Fit the remaining CI checks to the startup limits and current releases

- Run agent-launch-at-rest.test.ts in the SQLite Node runtime project.
- Pin the send-options cross-version case to v1.4.228, the first published release with agent-session.send-options.v1; v1.4.226 was never pushed.
- Load the baseline's isStructuredTab from src/shared (moved in v1.4.228), falling back to the renderer path for older baselines.
- End the never-proven start with a short startup ceiling: the silence limit it relied on is gone, and the ceiling counts from registration.

* Read the option stamp from the chat's one host-execution selection

* fix: preserve startup authority and accepted agent choices

* Retain dispatch authority and reconcile accepted options

* fix(native-chat): preserve accepted choices and cancellation authority

* Restore established client selection flow

* Preserve provider output observation and typed startup fixtures

* fix: preserve initial Claude launch choices and effort readback

* test: complete Claude catalog session fixture

* Preserve incoming main files after merge formatting

* Preserve compact handover through startup merge

* Type instant startup fixtures before their open is supplied

* Keep browser sign-in under its existing expiry owner

* Finish the main merge: retired-model display, start rewind cut, new tests

A model the Claude CLI retired is reported again, so the picker shows what the child runs and the
next send moves the chat off it. The start reads a rewind in doubt against the handle it answered
with, the record its commit then applies to. Main's tests follow the start's open: the admission
fixture holds a real turn, and a sign-in close during setup fails the open.

* Type the start's rewind record as the store returns it

* Type the Sleep test's held open as the provider open it replaces

* Sleep counts a chat stopped once the start it aborted leaves it at rest

A start runs outside the chat's lane, so Sleep's step can run before the start it aborted has
settled; it then re-reads the chat once that start is done, instead of reporting it unverified.

* Keep Sleep's start-abort bookkeeping beside the rest of Sleep's stop
This commit is contained in:
Brennan Benson
2026-10-19 10:53:46 -07:00
committed by GitHub
parent 236eba1ecf
commit 53191250e8
348 changed files with 10422 additions and 14043 deletions
-4
View File
@@ -7,10 +7,6 @@ config/scripts/check-layout-writer-ratchets.mjs src/renderer/src/runtime/remote-
config/scripts/pr-layout-oracle-route.mjs src/main/runtime/old-session-tabs-stream/
config/scripts/pr-layout-oracle-route.mjs src/renderer/src/runtime/remote-workspace-frozen/
config/scripts/run-ssh-staged-upload-reliability.mjs src/main/ssh/ssh-relay-install-namespace.test.ts
config/scripts/vitest-sqlite-runtime-files.mjs src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts
config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts
config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts
config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts
config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts
config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts
config/tsconfig.node.json src/renderer/src/components/native-chat/structured-agent-question-projection.ts
+7 -7
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-09-11",
"updatedAt": "2026-10-13",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -1544,14 +1544,14 @@
"invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.",
"oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2"
],
"testFiles": [
"src/shared/agent-session-operation-ledger.test.ts",
"src/shared/structured-agent-session-send-evidence.test.ts",
"src/main/runtime/agent-session-operation-admission.test.ts",
"src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts",
"src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts",
"src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts",
"src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts",
"src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts",
@@ -1648,13 +1648,13 @@
"summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity."
},
{
"date": "2026-10-06",
"date": "2026-10-13",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
"result": "passed",
"durationSeconds": 14,
"summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends."
"durationSeconds": 6.47,
"summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The send-idempotency suite pins only that an unknown outcome is never resent; the removed refusal-retry matrix has no replacement here."
}
],
"runtimeBudget": {
+1 -2
View File
@@ -89,10 +89,9 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts',
'src/main/native-chat/structured-chat-naming.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts',
'src/main/native-chat/structured-chat-naming-command-first.test.ts',
'src/main/native-chat/structured-chat-naming-status-hook.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/structured-session-mail-redrive-wiring.test.ts',
'src/main/runtime/rpc/ws-transport.test.ts',
@@ -27,8 +27,7 @@ it('routes native-chat journal and record-store SQLite contracts through Node',
expect.arrayContaining([
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts',
'src/main/native-chat/structured-chat-naming-command-first.test.ts',
'src/main/native-chat/structured-chat-naming-status-hook.test.ts',
'src/main/native-chat/structured-chat-naming.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/structured-session-mail-redrive-wiring.test.ts'
])
@@ -188,7 +188,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/runtime/structured-agent-session-codex-held-sends.test.ts',
'src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts',
'src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts',
'src/main/runtime/structured-agent-session-failure-logging.test.ts',
'src/main/runtime/structured-agent-session-held-signal.test.ts',
'src/main/runtime/structured-agent-session-host-refusal.test.ts',
'src/main/runtime/structured-agent-session-install-failure.test.ts',
+17 -29
View File
@@ -2,6 +2,9 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim
// Real SQLite fixtures keep Node publication and close semantics.
export const SQLITE_RUNTIME_INCLUDE = [
'src/main/pi/rpc-session-adapter.test.ts',
'src/main/pi/rpc-session-startup.test.ts',
'src/main/acp/acp-structured-publish-at-spawn.test.ts',
'tests/e2e/native-chat-startup-option-pickers.unit.test.ts',
...SQLITE_DATABASE_RUNTIME_INCLUDE,
'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts',
@@ -12,7 +15,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/repo-backfill-wsl-move.test.ts',
'src/main/acp/acp-structured-host-live-listing.test.ts',
'src/main/acp/acp-structured-host-configured-default.test.ts',
'src/main/pi/rpc-configured-default.test.ts',
'src/main/acp/acp-structured-omp-recordings.test.ts',
'src/main/acp/acp-text-stream-identity.test.ts',
'src/main/acp/acp-text-message-ownership.test.ts',
@@ -22,6 +24,8 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/acp/acp-structured-prompt-failure.test.ts',
'src/main/acp/acp-timeline-turn-failures.test.ts',
'src/main/acp/acp-structured-session-adapter.test.ts',
'src/main/acp/acp-structured-session-adapter-resume.test.ts',
'src/main/acp/acp-structured-host-start-aborts.test.ts',
'src/main/acp/acp-conversation-copy.test.ts',
'src/main/pi/rpc-turn-races.test.ts',
'src/main/acp/acp-structured-child-session-end.test.ts',
@@ -30,6 +34,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/acp/acp-structured-child-work.test.ts',
'src/main/acp/acp-structured-host-subagent-stop.test.ts',
'src/main/acp/acp-timeline-grok-subagent-retention.test.ts',
'src/main/acp/acp-scripted-child-runtime.test.ts',
'src/main/active-view-persistence-boundary.test.ts',
'src/main/automations/automation-dispatch-host-fence.test.ts',
'src/main/automations/automation-owner-fencing.test.ts',
@@ -61,8 +66,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/codex/codex-structured-journal-subagent-readers.test.ts',
'src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts',
'src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts',
'src/main/codex/codex-structured-question-order.test.ts',
'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts',
'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts',
'src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts',
'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts',
@@ -75,8 +78,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts',
@@ -87,7 +88,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts',
@@ -100,7 +100,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts',
@@ -108,17 +107,12 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts',
@@ -129,23 +123,18 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts',
@@ -155,8 +144,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts',
'src/main/native-chat/native-chat-visuals-sweep.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts',
@@ -169,7 +156,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts',
@@ -179,18 +165,15 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts',
@@ -231,7 +214,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/agent-session-launch-directory.test.ts',
'src/main/runtime/agent-session-launch-env-admission.test.ts',
'src/main/runtime/agent-session-lease-renewal.test.ts',
'src/main/runtime/agent-session-record-options.test.ts',
'src/main/runtime/agent-session-record-store-tab-table.test.ts',
'src/main/runtime/agent-session-record-store.test.ts',
'src/main/runtime/agent-session-permission-backfill.test.ts',
@@ -245,6 +227,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/orca-runtime-terminal-surface-close.test.ts',
'src/main/runtime/orchestration/orchestration-party-location.test.ts',
'src/main/runtime/orchestration/structured-worker-journal-page.test.ts',
'src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts',
'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts',
'src/main/runtime/rpc/methods/orchestration/federation/federation-agent-death-liveness.test.ts',
'tests/e2e/agent-launch-cli.unit.test.ts',
@@ -261,7 +244,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts',
'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts',
@@ -273,6 +255,12 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts',
'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts',
'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts',
'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts',
'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts',
'src/main/runtime/structured-agent-session-pi-startup.test.ts',
'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts',
'src/main/runtime/structured-agent-session-adapter-start-failure.test.ts',
'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts',
'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts',
'src/main/runtime/structured-agent-session-integration-replay.test.ts',
'src/main/runtime/structured-agent-session-integration.test.ts',
@@ -17,6 +17,7 @@ import {
} from '../../../src/shared/agent-launch-runtime-capability'
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability'
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
@@ -42,7 +43,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie
// Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer.
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY,
// Native chat offers no Stop for a turn the host restored but could not confirm.
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY
AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY,
// A new empty chat waits at rest and its first message starts the agent, as on desktop.
AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY
])
export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD =
@@ -0,0 +1,57 @@
import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure'
import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { AcpAgentError } from './acp-errors'
import type { AcpStructuredSession } from './acp-structured-session'
import { acpPromptErrorDetail } from './acp-turn-failures'
export async function applyAcpMessageOptions(
session: AcpStructuredSession,
options: AgentSessionExecutionOptions | undefined,
setOption: (key: 'model' | 'effort', value: string) => Promise<unknown>,
isCurrent: () => boolean,
signal?: AbortSignal
): Promise<boolean> {
if (!options) {
return true
}
const initial = session.options.messageDefault()
const model = options?.model ?? initial.model
const defaultEffort =
model === initial.model
? initial.effort
: session.options.read().models.find((entry) => entry.id === model)?.defaultEffort
for (const key of ['model', 'effort'] as const) {
const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort)
if (value === undefined) {
if (session.options.picked.has(key) || session.options.reported()[key] !== undefined) {
throw new AgentSessionOptionRejectedError(
new Error(`The default ${key} for this model is unknown`)
)
}
continue
}
if (session.options.currentProof.has(key) && session.options.reported()[key] === value) {
continue
}
try {
await setOption(key, value)
} catch (error) {
signal?.throwIfAborted()
if (!isCurrent()) {
return false
}
if (error instanceof AcpAgentError) {
throw withProviderDiagnostic(
new AgentSessionOptionRejectedError(error),
providerDiagnostic(acpPromptErrorDetail(session.spec.dialect, error), 'person')
)
}
throw new AgentSessionOptionRejectedError(error)
}
if (!isCurrent()) {
return false
}
}
return true
}
+13
View File
@@ -40,3 +40,16 @@ export function requestTimeout(value: number | null | undefined): number | null
}
return value
}
/** Each start step's own bound; turns are unbounded and option writes carry their caller's. */
const ACP_START_STEP_TIMEOUT_MS: Readonly<Partial<Record<string, number>>> = {
initialize: 60_000,
'session/new': 300_000,
'session/load': 300_000,
'session/resume': 300_000,
'session/fork': 300_000
}
export function acpStepBound(method: string): { timeoutMs: number | null } {
return { timeoutMs: ACP_START_STEP_TIMEOUT_MS[method] ?? null }
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from '../../shared/node-bounded-file-reader'
import type { AcpLaunchSpec } from './acp-launch-specs'
import { resolveAcpPeerOptions } from './acp-peer-limits'
import { acpAgentName } from './acp-structured-acquire'
import { acpAgentName } from './acp-structured-agent-definitions'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { acpSessionAcceptsImages } from './acp-structured-options'
import type { ContentBlock } from './generated/acp-protocol.generated'
+2 -1
View File
@@ -1,6 +1,7 @@
import type { z } from 'zod'
import { AcpAgentError, AcpInvalidResponseError, acpAuthRequiredError } from './acp-errors'
import type { AcpJsonRpcPeer } from './acp-json-rpc-peer'
import { acpStepBound } from './acp-peer-limits'
export function callAcpSession<T>(
peer: AcpJsonRpcPeer,
@@ -10,7 +11,7 @@ export function callAcpSession<T>(
answered: ((signedIn: boolean) => void) | undefined
): Promise<T> {
return peer.request(method, params, {
timeoutMs: null,
...acpStepBound(method),
settle: (response) => {
if ('error' in response) {
const error = response.error
@@ -0,0 +1,88 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import {
HOST_TEST_SESSION,
hostTestAttachParams,
hostTestMessage
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
} from '../runtime/structured-agent-session-runtime'
import { acpScriptedChild } from './acp-scripted-child.test-fixture'
let stateDirectory: string | undefined
let operation = 0
const operationId = () => `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`
const caller = { callerKey: 'scripted-acp-runtime' }
afterEach(async () => {
await stopStructuredAgentSessionRuntime()
if (stateDirectory) {
await rm(stateDirectory, { recursive: true, force: true })
}
stateDirectory = undefined
})
describe.each(['grok', 'opencode'] as const)('%s runtime with a scripted ACP child', (agent) => {
it('attaches and accepts a send while startup is held, then delivers once the protocol session opens', async () => {
stateDirectory = await mkdtemp(join(tmpdir(), 'orca-scripted-acp-'))
const script = acpScriptedChild(agent)()
const host = await ensureStructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
stateDirectory,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => stateDirectory ?? '',
resolveLaunchArgs: () => [],
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
...script.deps
})
const attach = hostTestAttachParams(null, {
provider: agent,
agent,
providerHandle: undefined,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder-1',
workspaceKind: 'folder'
},
accountHome:
agent === 'grok'
? { variable: 'GROK_HOME', path: stateDirectory }
: { kind: 'opencode', locator: { kind: 'unmanaged' } }
})
attach.envelope.clientOperationId = operationId()
const attached = await host.attach(caller, attach)
expect(attached).toMatchObject({ ok: true })
if (!attached.ok) {
throw new Error(JSON.stringify(attached.refusal))
}
const body = hostTestMessage('message during startup')
const sent = await host.send(caller, {
body,
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: attached.value.fence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: HOST_TEST_SESSION,
fields: { body }
})
}
})
expect(sent).toMatchObject({ ok: true })
expect(script.prompts()).toEqual([])
await vi.waitFor(() => expect(script.spawns()).toBe(1))
script.releaseHandshake()
await vi.waitFor(() => expect(script.prompts()).toEqual(['message during startup']))
await host.close(HOST_TEST_SESSION, 'user-close')
expect(script.closes()).toBe(1)
})
})
@@ -0,0 +1,151 @@
import { z } from 'zod'
import type {
ScriptedAgentChild,
ScriptedAgentChildFactory
} from '../runtime/structured-agent-scripted-child.test-fixture'
import { FakeAcpChild, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support'
import type { FakeFrame } from './acp-scripted-agent.test-support'
import type { AcpLaunchSpec } from './acp-launch-specs'
const promptParams = z.object({
sessionId: z.string(),
prompt: z.array(z.object({ type: z.string(), text: z.string().optional() })),
_meta: z.object({ promptId: z.string() }).optional()
})
const loadParams = z.object({ sessionId: z.string() })
const configParams = z.object({ configId: z.string(), value: z.string() })
export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentChildFactory {
return () => {
let current: FakeAcpChild | undefined
let handshake: (() => void) | undefined
let released = false
let opened = 0
let reopened = 0
let closed = 0
const texts: string[] = []
const fixture: ScriptedAgentChild = {
holdHandshakes: true,
completeTurns: true,
deps: {
resolveEnvironment: async () => ({ PATH: '' }),
acpLaunchCommand: {
resolveCommand: () => 'scripted-acp-agent',
probeVersion: async () => true
},
readProcessStartTime: async () => 1_700_000_000_000 + opened,
openAcpConnection: (launch, options) => {
opened += 1
handshake = undefined
released = false
const child = new FakeAcpChild(launch, options)
current = child
let sessionId = `${agent}-scripted-session`
let running: FakeFrame | undefined
let configOptions = GROK_CONFIG_OPTIONS.map((option) => ({ ...option }))
const script = child.agent
script.on('initialize', (frame) =>
script.reply(frame, {
protocolVersion: 1,
agentCapabilities: { loadSession: true }
})
)
const start = (frame: FakeFrame, resumes: boolean): void => {
if (resumes) {
reopened += 1
sessionId = loadParams.parse(frame.params).sessionId
}
const answer = () => {
if (child.closed || child.exited) {
return
}
script.reply(frame, { ...(!resumes ? { sessionId } : {}), configOptions })
}
if (fixture.holdHandshakes && !released) {
handshake = answer
} else {
answer()
}
}
script.on('session/new', (frame) => start(frame, false))
script.on('session/load', (frame) => start(frame, true))
script.on('session/set_config_option', (frame) => {
const { configId, value } = configParams.parse(frame.params)
configOptions = configOptions.map((option) =>
option.id === configId ? { ...option, currentValue: value } : option
)
script.reply(frame, { configOptions })
})
script.on('session/set_model', (frame) => script.reply(frame, {}))
// Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered.
script.on('_x.ai/subagent/cancel', (frame) =>
script.fail(
frame,
-32602,
'Invalid params',
'invalid params: missing field `subagentId`'
)
)
// Its background-task stop probe, answered the same way.
script.on('_x.ai/task/kill', (frame) =>
script.fail(
frame,
-32602,
'Invalid params',
'invalid params: missing field `sessionId`'
)
)
script.on('session/prompt', (frame) => {
const params = promptParams.parse(frame.params)
texts.push(params.prompt.map((block) => block.text ?? '').join(''))
running = frame
script.notify('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Scripted response' }
},
...(params._meta ? { _meta: params._meta } : {})
})
if (fixture.completeTurns) {
script.reply(frame, { stopReason: 'end_turn' })
running = undefined
}
})
script.on('session/cancel', () => {
if (running) {
script.reply(running, { stopReason: 'cancelled' })
}
running = undefined
})
const prove = child.proveClose
child.proveClose = async () => {
if (!child.exited) {
closed += 1
}
return prove()
}
return child
}
},
releaseHandshake: () => {
released = true
handshake?.()
handshake = undefined
},
failHandshake: (message) => {
if (!current) {
throw new Error('no scripted ACP child')
}
current.stderr = message
current.exit()
handshake = undefined
},
prompts: () => texts,
spawns: () => opened,
resumes: () => reopened,
closes: () => closed
}
return fixture
}
}
@@ -0,0 +1,93 @@
import type { ProviderProcessExit } from '../provider-process/managed-provider-process'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import type { AcpAgentConnectionOptions } from './acp-agent-connection'
import { AcpConnectionClosedError } from './acp-errors'
import { AcpSessionRuntime } from './acp-session-runtime'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { AcpScriptedAgent } from './acp-scripted-agent.test-support'
export const PID = 4242
/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio,
* with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes
* the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */
export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection {
readonly agent: AcpScriptedAgent
readonly pid: number | undefined = PID
readonly spawned = Promise.resolve()
stderr = ''
processTreeUnproven = false
private exitListeners: ((exit: ProviderProcessExit) => void)[] = []
private gone = false
private closing = false
private lostWith: Error | undefined
closes = 0
constructor(
readonly launch: ProviderProcessLaunch,
private readonly connectionOptions: AcpAgentConnectionOptions,
agent = new AcpScriptedAgent()
) {
const lifecycle: { child: FakeAcpChild | null } = { child: null }
super(agent.stdout, agent.stdin, {
...connectionOptions,
peer: { ...connectionOptions.peer, closeOnInputEnd: false },
onClose: (error) => lifecycle.child?.lost(error)
})
lifecycle.child = this
this.agent = agent
}
get exited() {
return this.gone
}
onExit(listener: (exit: ProviderProcessExit) => void): void {
if (this.gone) {
listener(FAKE_EXIT)
} else {
this.exitListeners.push(listener)
}
}
stderrTail(): string {
return this.stderr
}
pauseReading(): void {
this.agent.stdout.pause()
}
resumeReading(): void {
this.agent.stdout.resume()
}
/** What a close proves once the protocol is closed; replace it to leave the exit unproven. */
proveClose = async (): Promise<boolean> => {
this.exit()
return true
}
override close(error?: Error): Promise<boolean> {
this.closes += 1
this.closing ||= !this.gone
this.drainNotifications(error)
return this.proveClose().finally(() => super.close(error))
}
/** The agent process ends on its own (or Orca's close landed). */
exit(): void {
if (this.gone) {
return
}
this.gone = true
const error =
this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`)
super.close(error)
this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT })
for (const listener of this.exitListeners.splice(0)) {
listener(FAKE_EXIT)
}
}
private lost(error: Error): void {
this.lostWith ??= error
if (this.closing || this.gone) {
return
}
this.connectionOptions.onClose?.(error)
}
}
const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false }
+10 -4
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { AcpJsonRpcPeer } from './acp-json-rpc-peer'
import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime'
import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support'
import { AcpConnectionClosedError, AcpRpcError } from './acp-errors'
import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors'
import type { RequestPermissionResponse } from './generated/acp-protocol.generated'
const opened: { close: () => void }[] = []
@@ -219,18 +219,24 @@ describe('ACP caller-owned waits', () => {
await rejected
})
it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => {
it('bounds each start step on its own, never by the peer default', async () => {
vi.useFakeTimers()
const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } })
agent.on('initialize', () => {})
const initialized = runtime.initialize()
await vi.advanceTimersByTimeAsync(120_001)
await vi.advanceTimersByTimeAsync(59_000)
agent.reply(agent.frames[0], { protocolVersion: 1 })
await initialized
const authenticated = runtime.authenticate('caller-chosen')
await vi.advanceTimersByTimeAsync(120_001)
await vi.advanceTimersByTimeAsync(119_000)
agent.reply(agent.frames[1], {})
await expect(authenticated).resolves.toEqual({})
agent.on('session/new', () => {})
const started = expect(runtime.start(startOptions)).rejects.toBeInstanceOf(
AcpRequestTimeoutError
)
await vi.advanceTimersByTimeAsync(300_000)
await started
})
it('lets request owners withdraw vendor hooks after cancel', async () => {
+95 -157
View File
@@ -1,13 +1,11 @@
// Making a reservation real for an ACP agent: open its connection (which spawns and owns the
// process), record it before any handshake, initialize with the client's file system and terminals
// off, then reattach the session this chat proved with `session/load` (`session/resume` for an
// agent that cannot load or prefers it) or start a new one. A saved session the agent cannot reopen
// is replaced by a new one, with a warning row that any later start writes if this attach never
// did. The journal already holds a reattached chat, so whatever the agent sends while it reattaches
// is not written, except context usage. The handshake has no time bound: the acquire's abort
// signal (Close, Stop, quit) stops it at any point.
// process) and record it before any handshake, so the host publishes the child at spawn. The
// handshake (`acp-structured-handshake`) runs after: initialize, then reattach the session this chat
// proved with `session/load` (`session/resume` for an agent that cannot load) or start a new one,
// and the host receives protocol facts once that session answers. The handshake has no time bound of
// its own: the host's startup limit, or a close, Stop or quit, stops it at any point.
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { initializeAcpStructuredSession } from './acp-structured-handshake'
import {
AgentSessionPreSpawnError,
type AgentSessionAcquisition,
@@ -15,52 +13,48 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity'
import { AcpAgentError } from './acp-errors'
import { acpPrivateTempConnect } from './acp-private-temp'
import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures'
import {
ACP_REOPEN_FAILED,
acpReopenTakeover,
acpSessionNotRestoredRow
} from './acp-session-reopen-failure'
assertAcpStartingSession,
isAcpReadySession,
type AcpStructuredChild
} from './acp-structured-child'
import type { AcpSessionEvent } from './acp-session-runtime'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions'
import { acpAgentName } from './acp-structured-agent-definitions'
import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane'
import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop'
import { NO_ACP_CHILD_STOPS } from './acp-structured-child-stop'
import { prepareAcpStructuredLaunch } from './acp-structured-launch-preparation'
import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options'
import { AcpStructuredOptions } from './acp-structured-options'
import { AcpStructuredPrompts } from './acp-structured-prompts'
import {
asReattachHistory,
routeAcpSessionEvent,
type AcpStructuredSession
} from './acp-structured-session'
import { asReattachHistory, routeAcpSessionEvent } from './acp-structured-session'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns'
import type { AcpStructuredTurnsDeps } from './acp-structured-turns'
import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
/** Frames an agent may send before its session exists; past this they are dropped. */
const MAX_EARLY_FRAMES = 2_048
export { acpAgentName } from './acp-structured-agent-definitions'
export async function acquireAcpStructuredSession(input: {
acquire: StructuredAgentSessionAcquireInput
deps: AcpStructuredSessionAdapterDeps
generation: string
/** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */
/** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */
abandoned: () => boolean
/** Registers the connection so a close during the acquire can stop it. */
track: (connection: AcpStructuredConnection) => void
/** The process's exit, observed while or after the session exists. */
onExit: (session: AcpStructuredSession | null) => void
onExit: (session: AcpStructuredChild | null) => void
/** The protocol broke with the process perhaps still running. Null while starting: then the
* start itself fails. */
onConnectionLost: (session: AcpStructuredSession | null, error: Error) => void
onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void
onSettled: AcpStructuredTurnsDeps['settle']
forceClose: (sessionId: string) => void
}): Promise<{ acquisition: AgentSessionAcquisition; session: AcpStructuredSession }> {
}): Promise<{
acquisition: AgentSessionAcquisition
session: AcpStructuredChild
}> {
const { acquire, deps, generation } = input
const { spec } = deps
const agentName = acpAgentName(spec.agent)
@@ -78,7 +72,7 @@ export async function acquireAcpStructuredSession(input: {
}
closedBeforeSpawn()
const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn)
let session: AcpStructuredSession | null = null
let session: AcpStructuredChild | null = null
// A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write.
const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = {
lane: null,
@@ -89,9 +83,9 @@ export async function acquireAcpStructuredSession(input: {
// has nobody waiting on it. What a settled request carries still shows in that turn.
const prompts = new AcpStructuredPrompts(
() => slot.lane,
() => session?.turns.acceptsRequests === true,
() => isAcpReadySession(session) && session.turns?.acceptsRequests === true,
() =>
session !== null &&
isAcpReadySession(session) &&
(session.turns.acceptsRequests ||
(!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null))
)
@@ -117,10 +111,9 @@ export async function acquireAcpStructuredSession(input: {
},
{
clientInfo: { name: 'orca', version: '1' },
...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}),
...(spec.signIn ? { signIn: spec.signIn } : {}),
onPermission: (request, context) => {
if (!session?.turns.acceptsRequests) {
if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) {
// No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer
// is cutting it short: nobody is there to ask.
return { outcome: { outcome: 'cancelled' } }
@@ -167,11 +160,25 @@ export async function acquireAcpStructuredSession(input: {
// unanswered fails at once and the process ends, whether or not its exit is proven yet.
input.track(connection)
connection.subscribe((event: AcpSessionEvent) =>
whenLane(
() =>
slot.lane &&
whenLane(() => {
if (slot.lane) {
routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching)
)
}
if (
isAcpReadySession(session) &&
!session.ended &&
event.kind === 'known' &&
event.notification.update.sessionUpdate === 'config_option_update'
) {
deps.onEvent?.({
type: 'options-reported',
sessionId,
fence: acquire.fence,
acquisitionGeneration: generation,
reportedOptions: options.read().current
})
}
})
)
const identity = providerSpawnedProcessIdentity(
acquire,
@@ -179,7 +186,6 @@ export async function acquireAcpStructuredSession(input: {
deps.readProcessStartTime
)
/** `attaching`: the lane opens inside the attach window, before any frame queued so far. */
let childStops = NO_ACP_CHILD_STOPS
const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => {
const lane = new AcpStructuredLane({
sink,
@@ -190,10 +196,14 @@ export async function acquireAcpStructuredSession(input: {
providerSessionId,
dialect: spec.dialect,
now,
childStops: () => childStops,
childStops: () => session?.childStops ?? NO_ACP_CHILD_STOPS,
...acpLaneChildWorkDelivery(deps, sessionId, events),
logger: deps.logger ?? createStructuredAgentSessionLogger(),
onInputAccepted: (clientMessageId) => session?.turns.accept(clientMessageId),
onInputAccepted: (clientMessageId) => {
if (isAcpReadySession(session)) {
session.turns?.accept(clientMessageId)
}
},
onFailed: () => input.forceClose(sessionId)
})
slot.lane = lane
@@ -214,125 +224,53 @@ export async function acquireAcpStructuredSession(input: {
)
}
await identity.onSpawned(pid)
try {
const initialized = await connection.initialize()
// Chosen here, on the machine Grok runs on, from the environment it was launched with.
const authMethodId = spec.authMethod?.({
advertised: (initialized.authMethods ?? []).map((method) => method.id),
env: launch.env
})
const setup = { authMethodId, resumePreference: spec.resumePreference }
const resume = launch.resume
let started: Awaited<ReturnType<AcpStructuredConnection['start']>> | null = null
let liveLane: AcpStructuredLane | null = null
let takeover: ReturnType<typeof acpReopenTakeover> | null = null
if (resume) {
const attaching = makeLane(resume.sessionId, true)
liveLane = attaching
try {
started = await connection.start({
cwd: launch.cwd,
mcpServers: [],
sessionId: resume.sessionId,
...setup
})
slot.reattaching = false
attaching.translator.finishLoad()
} catch (error) {
takeover = acpReopenTakeover(error, resume, {
over: connection.closed || acquire.signal?.aborted === true,
now: now(),
warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId })
})
// A new session takes the old one's place, with a new lane, so nothing of the failed
// attach's window outlives it.
}
}
if (!started || !liveLane) {
liveLane?.dispose()
slot.lane = null
started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...setup })
liveLane = makeLane(started.sessionId)
}
childStops = await probeAcpChildStops(connection, spec.dialect)
options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded')
liveLane.apply(liveLane.translator.contextModels(started.response.models, now()))
const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options)
const mode = spec.permissionModeConfig
if (mode) {
// Unlike a saved pick, a refusal fails the start: the session would run another posture.
// A start the host names no mode for asks, never more access than the chat chose.
const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask
const configId = options.modeConfigId()
const { configOptions } = await connection.setConfigOption(configId, requested)
if (configOptions.find((option) => option.id === configId)?.currentValue !== requested) {
throw new Error('Permission mode could not be confirmed')
}
options.adoptConfigOptions(configOptions)
}
const process = await identity.read(pid)
const link: AgentSessionProviderHandleLink = {
linkId:
deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128),
handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId },
origin: started.kind === 'new' ? 'created' : 'resumed',
mintedAtFence: acquire.fence,
observedAt: now(),
...takeover
}
session = {
sessionId,
fence: acquire.fence,
acquisitionGeneration: generation,
spec,
childStops,
connection,
lane: liveLane,
prompts,
options,
turns: new AcpStructuredTurns({
const process = await identity.read(pid)
const child: AcpStructuredChild = {
sessionId,
fence: acquire.fence,
acquisitionGeneration: generation,
spec,
connection,
lane: null,
turns: null,
options,
prompts,
childStops: undefined,
closeRequested: false,
journalClosed: null,
ended: false,
exitObservedAt: null,
open: (signal) =>
initializeAcpStructuredSession({
acquire,
deps,
generation,
launch,
connection,
withdrawRequests: () => prompts.withdrawAll(),
lane: liveLane,
agentName,
now,
settle: input.onSettled
options,
prompts,
child,
abandoned: () => signal.aborted,
lanes: {
create: makeLane,
clear: () => {
slot.lane = null
},
finishLoad: () => {
slot.reattaching = false
}
},
onSettled: input.onSettled
}),
restoreSkipped,
closeRequested: false,
journalClosed: null,
ended: false,
exitObservedAt: null
dispose: () => {
slot.lane?.dispose()
early.length = 0
}
const reading = liveLane
const unbind = events?.bindReadingControl?.({
pauseReading: () => connection.pauseReading(),
resumeReading: () => {
connection.resumeReading()
reading.retry()
}
})
if (unbind) {
session.unbindReadingControl = unbind
}
if (connection.exited || connection.closed) {
throw new Error(connection.stderrTail() || `${spec.command} exited while starting`)
}
// The chat says once per lost conversation that the agent forgot it, this start's loss included.
const lost = [
...(launch.resume?.unannouncedLosses() ?? []),
...(takeover?.replaces ? [takeover.replaces.key] : [])
]
for (const key of lost) {
liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName))
}
return { acquisition: { process, link, acquisitionGeneration: generation }, session }
} catch (error) {
session = null
slot.lane?.dispose()
if (error instanceof AcpAgentError && acpAuthenticationRequired(spec.dialect, error)) {
throw acpSignInRequiredRefusal(spec.agent, spec.dialect, error)
}
throw error
}
session = child
assertAcpStartingSession(child, input.abandoned)
return {
acquisition: { process, acquisitionGeneration: generation },
session: child
}
}
@@ -1,25 +1,23 @@
import { FakeAcpChild } from './acp-scripted-connection.test-fixture'
export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture'
// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real
// protocol runtime, translator and assembler, and a real on-disk journal to read back.
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { vi } from 'vitest'
import { z } from 'zod'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import type { ProviderProcessExit } from '../provider-process/managed-provider-process'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
import {
openProviderTimelineRig,
SESSION,
type ProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import { AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support'
import type { AcpAgentConnectionOptions } from './acp-agent-connection'
import { AcpConnectionClosedError } from './acp-errors'
import { AcpSessionRuntime } from './acp-session-runtime'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { type AcpScriptedAgent, tick, type FakeFrame } from './acp-scripted-agent.test-support'
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import { AcpStructuredSessionAdapter } from './acp-structured-session-adapter'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
@@ -27,7 +25,6 @@ import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-a
export const GROK = acpLaunchSpecFor('grok')!
const RIG_ENV = { PATH: '/usr/bin', ORCA_PANE_KEY: 'tab-1:pane-1', ORCA_AGENT_HOOK_PORT: '1234' }
export const PROVIDER_SESSION = 'acp-session-1'
export const PID = 4242
export const GROK_CONFIG_OPTIONS = [
{
@@ -54,102 +51,22 @@ export const GROK_CONFIG_OPTIONS = [
}
]
/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio,
* with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes
* the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */
export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection {
readonly agent: AcpScriptedAgent
readonly pid: number | undefined = PID
readonly spawned = Promise.resolve()
stderr = ''
processTreeUnproven = false
private exitListeners: ((exit: ProviderProcessExit) => void)[] = []
private gone = false
private closing = false
private lostWith: Error | undefined
closes = 0
constructor(
readonly launch: ProviderProcessLaunch,
private readonly connectionOptions: AcpAgentConnectionOptions,
agent = new AcpScriptedAgent()
) {
const lifecycle: { child: FakeAcpChild | null } = { child: null }
super(agent.stdout, agent.stdin, {
...connectionOptions,
peer: { ...connectionOptions.peer, closeOnInputEnd: false },
onClose: (error) => lifecycle.child?.lost(error)
})
lifecycle.child = this
this.agent = agent
}
get exited() {
return this.gone
}
onExit(listener: (exit: ProviderProcessExit) => void): void {
if (this.gone) {
listener(FAKE_EXIT)
} else {
this.exitListeners.push(listener)
}
}
stderrTail(): string {
return this.stderr
}
pauseReading(): void {
this.agent.stdout.pause()
}
resumeReading(): void {
this.agent.stdout.resume()
}
/** What a close proves once the protocol is closed; replace it to leave the exit unproven. */
proveClose = async (): Promise<boolean> => {
this.exit()
return true
}
override close(error?: Error): Promise<boolean> {
this.closes += 1
this.closing ||= !this.gone
this.drainNotifications(error)
return this.proveClose().finally(() => super.close(error))
}
/** The agent process ends on its own (or Orca's close landed). */
exit(): void {
if (this.gone) {
return
}
this.gone = true
const error =
this.lostWith ?? new AcpConnectionClosedError(this.stderr || `${this.launch.command} exited`)
super.close(error)
this.connectionOptions.onExit?.(error, { expected: this.closing, exit: FAKE_EXIT })
for (const listener of this.exitListeners.splice(0)) {
listener(FAKE_EXIT)
}
}
private lost(error: Error): void {
this.lostWith ??= error
if (this.closing || this.gone) {
return
}
this.connectionOptions.onClose?.(error)
}
}
const FAKE_EXIT: ProviderProcessExit = { code: 0, signal: null, processless: false }
export type AcpAdapterRig = {
rig: ProviderTimelineRig
adapter: AcpStructuredSessionAdapter
child: () => FakeAcpChild
spawned: string[]
lifecycle: StructuredAgentSessionLifecycleEvent[]
readonly ended: Extract<StructuredAgentSessionLifecycleEvent, { type: 'ended' }>[]
started(): StructuredAgentSessionProviderOpenResult
openFailure(): unknown
settled: Parameters<NonNullable<AcpStructuredSessionAdapterDeps['onDispatchSettledLate']>>[0][]
acquire(options?: {
fence?: number
onSpawned?: () => Promise<void>
signal?: AbortSignal
waitForStart?: boolean
options?: Readonly<Record<string, string>>
permissionMode?: AgentChatPermissionMode
}): ReturnType<AcpStructuredSessionAdapter['acquire']>
/** Frames Orca wrote to the agent with this method. */
@@ -174,6 +91,9 @@ export async function openAcpAdapterRig(
const spawned: string[] = []
const lifecycle: StructuredAgentSessionLifecycleEvent[] = []
const settled: AcpAdapterRig['settled'] = []
let openFailure: unknown
let opened: StructuredAgentSessionProviderOpenResult | null = null
const startListeners = new Set<() => void>()
const spec = options.spec ?? GROK
// A start readies the account folder its row pins; here a scratch one.
const { prepare, resolve, environment } = spec.account
@@ -217,11 +137,18 @@ export async function openAcpAdapterRig(
})
})
const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS }
let configOptions = GROK_CONFIG_OPTIONS
agent.on('session/new', (frame) => agent.reply(frame, opened))
agent.on('session/load', (frame) =>
agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS })
)
// A row that enforces its permission mode sets it at every start; the agent confirms it.
agent.on('session/set_config_option', (frame) => {
const parsed = z.object({ configId: z.string(), value: z.string() }).parse(frame.params)
configOptions = configOptions.map((option) =>
option.id === parsed.configId ? { ...option, currentValue: parsed.value } : option
)
agent.reply(frame, { configOptions })
})
if (spec.permissionModeConfig) {
agent.confirmConfigOptions(GROK_CONFIG_OPTIONS)
}
@@ -230,11 +157,17 @@ export async function openAcpAdapterRig(
},
readProcessStartTime: async () => 1_700_000_000_000,
dataDirectory: join(tmpdir(), 'orca-acp-adapter-rig'),
onEvent: (event) => lifecycle.push(event),
onDispatchSettledLate: (settlement) => settled.push(settlement),
mintGeneration: () => 'gen-acp',
now: () => 5_000,
...options.deps
...options.deps,
onEvent: (event) => {
lifecycle.push(event)
for (const listener of startListeners) {
listener()
}
options.deps?.onEvent?.(event)
}
})
const frames = () => current?.agent.frames ?? []
return {
@@ -248,9 +181,21 @@ export async function openAcpAdapterRig(
},
spawned,
lifecycle,
get ended() {
return lifecycle.filter((event) => event.type === 'ended')
},
started: () => {
if (!opened) {
throw new Error('ACP session has not started')
}
return opened
},
openFailure: () => openFailure,
settled,
acquire: (acquireOptions = {}) =>
adapter.acquire({
acquire: async (acquireOptions = {}) => {
openFailure = undefined
opened = null
const acquisition = await adapter.acquire({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
@@ -261,13 +206,29 @@ export async function openAcpAdapterRig(
fence: acquireOptions.fence ?? 1,
spawnToken: 'spawn-1',
events: rig.eventSink,
...(acquireOptions.options ? { options: acquireOptions.options } : {}),
...(acquireOptions.signal ? { signal: acquireOptions.signal } : {}),
...(acquireOptions.permissionMode ? { permissionMode: acquireOptions.permissionMode } : {}),
onSpawned: async () => {
spawned.push('onSpawned')
await acquireOptions.onSpawned?.()
}
}),
})
if (acquireOptions.waitForStart !== false) {
try {
opened = await adapter.open({
sessionId: SESSION,
fence: acquireOptions.fence ?? 1,
acquisitionGeneration: acquisition.acquisitionGeneration,
signal: acquireOptions.signal ?? new AbortController().signal
})
} catch (error) {
openFailure = error
await adapter.closeSession(SESSION)
}
}
return acquisition
},
sent: (method) => frames().filter((frame) => frame.method === method),
frame: (method, index = 0) =>
waitFor(() => {
@@ -116,7 +116,7 @@ it('launches on its own private folder with file sign-in and no other Google cre
it('runs the chat permission mode picked mid-chat as the server mode on the next send', async () => {
let resumed = false
const { rig, host, fence } = await openHostRig({
const { rig, host, fence, ready } = await openHostRig({
spec: ANTIGRAVITY,
initialize: RESUMES,
script: (agent) => {
@@ -137,6 +137,7 @@ it('runs the chat permission mode picked mid-chat as the server mode on the next
providerHandle: undefined
})
expect(await host.attach(CALLER, attach)).toMatchObject({ ok: true })
await ready()
resumed = true
const modes: unknown[] = []
let child = rig.child()
@@ -172,14 +173,16 @@ it('refuses a new start while its sign-in changes, and stops one already startin
spec: ANTIGRAVITY,
script: (agent) => agent.on('session/new', () => {})
})
const starting = rig.acquire().catch((error: unknown) => error)
const starting = rig.acquire()
await rig.frame('session/new')
const signIn = deferred<void>()
const operation = gate.run(
async () => {},
() => signIn.promise
)
expect(String(await starting)).toContain('closed while starting')
await starting
expect(rig.openFailure()).toMatchObject({ name: 'AcpConnectionClosedError' })
expect(rig.child().exited).toBe(true)
await expect(rig.acquire()).rejects.toMatchObject({ reason: 'accountSwitchInProgress' })
signIn.resolve()
await operation
+2 -4
View File
@@ -4,7 +4,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { AcpScriptedAgent } from './acp-scripted-agent.test-support'
import {
GROK_CONFIG_OPTIONS,
@@ -60,9 +59,9 @@ async function signInWith(options: {
initialize: { authMethods: options.authMethods },
script: requiresSignIn
})
const acquired = await rig.acquire().catch((error: unknown) => error)
await rig.acquire()
const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId)
return { rig, acquired, methods }
return { rig, acquired: rig.openFailure() ?? rig.started(), methods }
}
describe('Grok sign-in on its own machine', () => {
@@ -92,7 +91,6 @@ describe('Grok sign-in on its own machine', () => {
authMethods: [{ id: 'browser', name: 'Browser' }]
})
expect(methods).toEqual([])
expect(acquired).toBeInstanceOf(AgentSessionAcquisitionRefusal)
expect(acquired).toMatchObject({ reason: 'notSignedIn' })
})
@@ -122,11 +122,11 @@ describe('ACP agent that closes its stdout but keeps running', () => {
// Long enough for a closed stream to have reached the adapter.
await new Promise((resolve) => setTimeout(resolve, 500))
expect((await turns()).at(-1)).toMatchObject({ state: 'running' })
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
expect(alive(pid)).toBe(true)
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
expect(alive(pid)).toBe(false)
expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
}, 30_000)
})
@@ -162,8 +162,8 @@ describe('ACP agent that exits while a process it started holds its stdout open'
})
await rig.acquire()
await sendHello(rig, 'held')
await vi.waitFor(() => expect(rig.lifecycle).toHaveLength(1), { timeout: 10_000, interval: 20 })
expect(rig.lifecycle[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' })
await vi.waitFor(() => expect(rig.ended).toHaveLength(1), { timeout: 10_000, interval: 20 })
expect(rig.ended[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' })
expect(alive(Number(readFileSync(pidFile, 'utf8')))).toBe(false)
const turns = (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? [])
expect(turns.at(-1)).toMatchObject({ state: 'interrupted' })
@@ -0,0 +1,140 @@
import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts'
import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { closeAcpSessionJournal, endAcpStructuredSession } from './acp-structured-session'
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import {
ACP_STOP_GRACE_MS,
type AcpStructuredSessionAdapterDeps
} from './acp-structured-session-adapter-deps'
import { windDownAcpTurn } from './acp-structured-stop'
export class AcpStructuredChildren {
readonly sessions = new Map<string, AcpStructuredChild>()
readonly starts = new ProviderAcquisitionStarts<AcpStructuredConnection>()
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {}
/** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */
async close(sessionId: string): Promise<boolean> {
const connection = this.sessions.get(sessionId)?.connection
const closed = await this.stop(sessionId, true)
if (closed && connection?.processTreeUnproven) {
throw new AgentSessionAcquisitionRootExitObservedError(
new Error(
`${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone`
)
)
}
return closed
}
/** True only once every child is proven gone, or when this adapter runs none for the session:
* a failed start's child, and the session's own. */
async stop(sessionId: string, requested = true): Promise<boolean> {
const [failedStart, session] = await Promise.all([
this.starts.stopFailed(sessionId),
this.stopSession(sessionId, requested)
])
return failedStart && session
}
private async stopSession(sessionId: string, requested: boolean): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session || session.ended) {
return true
}
if (!isAcpReadySession(session)) {
session.closeRequested ||= requested && session.journalClosed === null
session.dispose()
session.closing ??= session.connection
.close()
.then((proven) => {
if (proven) {
this.finish(session, session.exitObservedAt ?? this.now())
}
return proven
})
.finally(() => {
session.closing = undefined
})
return session.closing
}
if (requested && session.journalClosed === null) {
// A close, dispose or quit ends a running turn as a Stop does before the child goes.
session.closeRequested = true
await windDownAcpTurn(session, this.stopGraceMs())
if (session.ended) {
return true
}
}
// A connection loss already decided why the child ends; a later stop does not relabel it.
if (session.journalClosed === null) {
session.closeRequested ||= requested
session.lane.flush()
}
const proven = await session.connection.close()
if (proven) {
this.finish(session, session.exitObservedAt ?? this.now())
}
return proven
}
/** The child's exit is proven: the host hears it, and nothing of the child stays here. */
finish(session: AcpStructuredChild, observedAt: number): void {
if (isAcpReadySession(session)) {
endAcpStructuredSession(session, observedAt, this.deps.onEvent)
} else if (!session.ended) {
session.ended = true
session.exitObservedAt = observedAt
session.dispose()
this.deps.onEvent?.({
type: 'ended',
sessionId: session.sessionId,
fence: session.fence,
acquisitionGeneration: session.acquisitionGeneration,
observedAt,
cause: session.closeRequested ? 'requested-close' : 'unexpected-exit',
reason:
session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`,
failure: agentSessionFailureFact(
session.closeRequested ? 'hostStopped' : 'providerExited',
{
detail: providerDiagnostic(session.connection.stderrTail(), 'person')
}
)
})
}
if (this.sessions.get(session.sessionId) === session) {
this.sessions.delete(session.sessionId)
}
}
/** The connection broke while the child may still run: nothing more it says can be journaled, so
* the journal closes now and the child is stopped; the host hears `ended` once that is proven. */
connectionLost(session: AcpStructuredChild | null, error: Error): void {
if (
!session ||
this.sessions.get(session.sessionId) !== session ||
session.journalClosed !== null
) {
return
}
if (!isAcpReadySession(session)) {
session.journalClosed = error.message || error.name
return
}
closeAcpSessionJournal(
session,
`${session.spec.agent} ACP connection closed: ${error.message || error.name}`,
error
)
void this.stop(session.sessionId, false)
}
private stopGraceMs(): number {
return this.deps.stopGraceMs ?? ACP_STOP_GRACE_MS
}
private now(): number {
return (this.deps.now ?? Date.now)()
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child'
import type { AcpStructuredSession } from './acp-structured-session'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect'
@@ -20,12 +21,13 @@ type BackgroundTaskStop = NonNullable<AcpDialect['backgroundTaskStop']>
export const NO_ACP_CHILD_STOPS: AcpChildStops = { subagents: false, backgroundTasks: false }
export function acpChildStopCapabilities(
session?: AcpStructuredSession
session?: AcpStructuredChild
): AgentSessionBackgroundTaskStops | undefined {
return session?.journalClosed === null
? {
supportsTaskStop:
session.childStops?.subagents === true || session.childStops?.backgroundTasks === true,
isAcpReadySession(session) &&
(session.childStops?.subagents === true || session.childStops?.backgroundTasks === true),
supportsStopAll: false
}
: undefined
+63
View File
@@ -0,0 +1,63 @@
import type { SubmissionRejectionFact } from '../../shared/agent-session-failure'
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
import { AcpAgentError } from './acp-errors'
import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures'
import type { AcpStructuredSession } from './acp-structured-session'
export type AcpStructuredChild = Pick<
AcpStructuredSession,
| 'sessionId'
| 'fence'
| 'acquisitionGeneration'
| 'spec'
| 'connection'
| 'closeRequested'
| 'journalClosed'
| 'ended'
| 'exitObservedAt'
> & {
lane: AcpStructuredSession['lane'] | null
turns: AcpStructuredSession['turns'] | null
options: AcpStructuredSession['options']
prompts: AcpStructuredSession['prompts']
childStops?: AcpStructuredSession['childStops']
unbindReadingControl?: () => void
open: (signal: AbortSignal) => Promise<StructuredAgentSessionProviderOpenResult>
closing?: Promise<boolean>
dispose: () => void
}
export function isAcpReadySession(
session: AcpStructuredChild | null | undefined
): session is AcpStructuredSession & AcpStructuredChild {
return session != null && session.lane !== null && session.turns !== null
}
export function acpStartupFailure(
session: AcpStructuredChild,
error: unknown
): SubmissionRejectionFact | undefined {
if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) {
// The agent's sign-in words, never log lines it wrote before refusing.
return acpSignInRequiredFailure(session.spec.dialect, error)
}
return undefined
}
export function assertAcpStartingSession(
session: AcpStructuredChild,
abandoned: () => boolean
): void {
if (
session.ended ||
session.closeRequested ||
session.journalClosed !== null ||
session.connection.exited ||
session.connection.closed ||
abandoned()
) {
throw new Error(
session.connection.stderrTail() || `${session.spec.command} closed while starting`
)
}
}
+12 -22
View File
@@ -8,7 +8,6 @@ import {
import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn'
import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child'
import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs'
import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions'
import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support'
@@ -276,27 +275,18 @@ describe('ACP compaction settlement', () => {
it.each([
{ spec: OPENCODE, code: -32000, data: undefined },
{ spec: OMP, code: -32603, data: { details: 'No model selected.\n\nUse /login to sign in.' } }
])(
'says $spec.agent is not signed in, as a send does, so the next one starts a new agent',
async ({ spec, code, data }) => {
const { rig, command, prompt } = await compact(spec)
rig.child().agent.fail(prompt, code, 'Authentication required', data)
const { turn, inside } = await ended(rig, command)
expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' })
expect(inside).toEqual([
expect.objectContaining({
tone: 'error',
failure: expect.objectContaining({ kind: 'notSignedIn' })
})
])
expect(
structuredAgentSessionChildReportedSignedOut({
child: { generation: 'generation-1', fence: 1, phase: 'ready' },
journal: rig.rig.journal
})
).toBe(true)
}
)
])('says $spec.agent is not signed in, as a send does', async ({ spec, code, data }) => {
const { rig, command, prompt } = await compact(spec)
rig.child().agent.fail(prompt, code, 'Authentication required', data)
const { turn, inside } = await ended(rig, command)
expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' })
expect(inside).toEqual([
expect.objectContaining({
tone: 'error',
failure: expect.objectContaining({ kind: 'notSignedIn' })
})
])
})
it('fails a compaction the agent ended with any other stop reason', async () => {
const { rig, command, prompt } = await compact(GROK)
+172
View File
@@ -0,0 +1,172 @@
// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's
// session or start a new one, then return its protocol facts to the host. A saved session the
// agent cannot reopen is replaced by a new one, with a warning row that any later start writes if
// this one never did; while a reopened session replays history the journal already holds, nothing
// it sends is written except context usage.
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
import {
ACP_REOPEN_FAILED,
acpReopenTakeover,
acpSessionNotRestoredRow
} from './acp-session-reopen-failure'
import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions'
import { probeAcpChildStops } from './acp-structured-child-stop'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpStructuredLane } from './acp-structured-lane'
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
import type { AcpStructuredOptions } from './acp-structured-options'
import type { AcpStructuredPrompts } from './acp-structured-prompts'
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns'
import {
assertAcpStartingSession,
isAcpReadySession,
type AcpStructuredChild
} from './acp-structured-child'
type AcpHandshakeInput = {
acquire: StructuredAgentSessionAcquireInput
deps: AcpStructuredSessionAdapterDeps
generation: string
launch: AcpStructuredLaunch
connection: AcpStructuredConnection
options: AcpStructuredOptions
prompts: AcpStructuredPrompts
child: AcpStructuredChild
abandoned: () => boolean
lanes: {
create: (providerSessionId: string, attaching?: boolean) => AcpStructuredLane
clear: () => void
finishLoad: () => void
}
onSettled: AcpStructuredTurnsDeps['settle']
}
export async function initializeAcpStructuredSession(
input: AcpHandshakeInput
): Promise<StructuredAgentSessionProviderOpenResult> {
const { acquire, deps, launch, connection, options, prompts, child, onSettled } = input
const { spec } = deps
const sessionId = acquire.identity.sessionId
const now = deps.now ?? Date.now
const assertStarting = () => assertAcpStartingSession(child, input.abandoned)
const agentName = acpAgentName(spec.agent)
assertStarting()
const initialized = await connection.initialize()
assertStarting()
// Chosen here, on the machine Grok runs on, from the environment it was launched with.
const authMethodId = spec.authMethod?.({
advertised: (initialized.authMethods ?? []).map((method) => method.id),
env: launch.env
})
const setup = { authMethodId, resumePreference: spec.resumePreference }
const resume = launch.resume
let started: Awaited<ReturnType<AcpStructuredConnection['start']>> | null = null
let liveLane: AcpStructuredLane | null = null
let takeover: ReturnType<typeof acpReopenTakeover> | null = null
if (resume) {
const attaching = input.lanes.create(resume.sessionId, true)
liveLane = attaching
try {
started = await connection.start({
cwd: launch.cwd,
mcpServers: [],
sessionId: resume.sessionId,
...setup
})
assertStarting()
input.lanes.finishLoad()
attaching.translator.finishLoad()
} catch (error) {
assertStarting()
takeover = acpReopenTakeover(error, resume, {
over: connection.closed || input.abandoned(),
now: now(),
warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId })
})
// A new session takes the old one's place, with a new lane, so nothing of the failed
// attach's window outlives it.
}
}
if (!started || !liveLane) {
liveLane?.dispose()
input.lanes.clear()
started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...setup })
assertStarting()
liveLane = input.lanes.create(started.sessionId)
}
const childStops = await probeAcpChildStops(connection, spec.dialect)
assertStarting()
options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded')
liveLane.apply(liveLane.translator.contextModels(started.response.models, now()))
const mode = spec.permissionModeConfig
if (mode) {
// A refusal fails the start: the session would otherwise run another permission mode.
const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask
const configId = options.modeConfigId()
const { configOptions } = await connection.setConfigOption(configId, requested)
assertStarting()
if (configOptions.find((option) => option.id === configId)?.currentValue !== requested) {
throw new Error('Permission mode could not be confirmed')
}
options.adoptSession(
{ ...started.response, configOptions },
started.kind === 'new' ? 'new' : 'loaded'
)
}
const link: AgentSessionProviderHandleLink = {
linkId:
deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128),
handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId },
origin: started.kind === 'new' ? 'created' : 'resumed',
mintedAtFence: acquire.fence,
observedAt: now(),
...takeover
}
const reading = liveLane
const unbind = acquire.events?.bindReadingControl?.({
pauseReading: () => connection.pauseReading(),
resumeReading: () => {
connection.resumeReading()
reading.retry()
}
})
if (unbind) {
child.unbindReadingControl = unbind
}
if (connection.exited || connection.closed) {
throw new Error(connection.stderrTail() || `${spec.command} exited while starting`)
}
// The chat says once per lost conversation that the agent forgot it, this start's loss included.
const lost = [
...(launch.resume?.unannouncedLosses() ?? []),
...(takeover?.replaces ? [takeover.replaces.key] : [])
]
for (const key of lost) {
liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName))
}
assertStarting()
Object.assign(child, {
childStops,
lane: liveLane,
turns: new AcpStructuredTurns({
connection,
withdrawRequests: () => prompts.withdrawAll(),
lane: liveLane,
agentName,
now,
settle: onSettled
})
})
if (!isAcpReadySession(child)) {
throw new Error('ACP session did not open')
}
return {
link,
reportedOptions: options.read().current,
catalogListing: options.startListing()
}
}
@@ -149,6 +149,10 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe
? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create })
: rig.host.attach(CALLER, attachParams())
expect(await opening).toMatchObject({ ok: true })
if (!create) {
// The attach answers at spawn; what the chat runs is known once its start is proven.
await rig.ready()
}
return { ...rig, catalog, listed }
}
@@ -57,18 +57,20 @@ describe('reopening a Grok chat through the host', () => {
it('loads a chat the journal holds and writes its exchange once', async () => {
const count = { loads: 0 }
let resumed = false
const { host, fence, messages, exchange } = await openHostRig({
const { host, fence, messages, exchange, ready } = await openHostRig({
initialize: RESUMES,
script: loads('hi', count),
deps: { resolveLaunch: launch(() => resumed) }
})
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
resumed = true
await exchange('hi', true)
expect(await messages()).toEqual(['hello', 'hi'])
const before = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
await host.flushStreamedEvents(SESSION)
expect(count.loads).toBe(1)
expect(await messages()).toEqual(['hello', 'hi'])
@@ -82,12 +84,13 @@ describe('reopening a Grok chat through the host', () => {
it('shows a reply a crash cut off with the existing notice, never completed from what Grok saved', async () => {
let resumed = false
const { rig, host, fence, messages, exchange } = await openHostRig({
const { rig, host, fence, messages, exchange, ready } = await openHostRig({
initialize: RESUMES,
script: loads('complete saved reply', { loads: 0 }),
deps: { resolveLaunch: launch(() => resumed) }
})
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
resumed = true
await exchange('complete', false)
// Grok dies mid-reply; the host ends its record, which moves the chat's fence.
@@ -95,6 +98,7 @@ describe('reopening a Grok chat through the host', () => {
rig.child().exit()
await waitFor(() => expect(fence()).toBeGreaterThan(cutAt))
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
await host.flushStreamedEvents(SESSION)
expect(await messages()).toEqual(['hello', 'complete'])
const rows = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items
@@ -169,29 +173,28 @@ describe('a Grok crash whose exit is not proven yet', () => {
})
describe('closing or stopping a Grok chat while it starts', () => {
it('keeps a failed start whose child is not proven gone; a close leaves it, the next start asks it again', async () => {
const { rig, host } = await openHostRig({
script: (agent) => agent.on('initialize', () => {})
})
const attaching = host.attach(CALLER, attachParams()).catch((error: unknown) => error)
it('retains a published startup child whose close is unproven and retries before another spawn', async () => {
const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) })
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await rig.frame('initialize')
const child = rig.child()
child.proveClose = vi.fn(async () => false)
await host.close(SESSION, 'user-close')
expect(await attaching).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' })
child.proveClose = vi.fn(async () => {
child.exit()
return true
})
await host.close(SESSION, 'user-close')
expect(child.proveClose).not.toHaveBeenCalled()
expect(child.exited).toBe(false)
// As Claude's: the next start asks the child again before it spawns another.
try {
await expect(host.close(SESSION, 'user-close')).rejects.toThrow('eviction failed')
expect(child.exited).toBe(false)
expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1)
} finally {
child.proveClose = vi.fn(async () => {
child.exit()
return true
})
await host.close(SESSION, 'user-close')
}
expect(child.exited).toBe(true)
await host.send(CALLER, {
envelope: envelope('agentSession.send', { body: hello }),
body: hello
})
await waitFor(() => expect(child.exited).toBe(true))
await waitFor(() => expect(rig.child()).not.toBe(child))
await host.close(SESSION, 'user-close')
})
@@ -205,7 +208,7 @@ describe('closing or stopping a Grok chat while it starts', () => {
const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) })
// With no close behind it.
await waitFor(() => expect(rig.child().exited).toBe(true))
expect((await attaching).ok).toBe(false)
expect((await attaching).ok).toBe(true)
expect(await stopping).toMatchObject({ ok: true })
expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1)
})
@@ -244,7 +247,7 @@ describe('closing or stopping a Grok chat while it starts', () => {
await host.close(SESSION, 'user-close')
})
it('leaves a start alone for a Stop that names a turn of a child already gone', async () => {
it('leaves a starting child alone when Stop names an earlier turn', async () => {
const { rig, host } = await openHostRig({
script: (agent) => agent.on('initialize', () => {})
})
@@ -254,11 +257,12 @@ describe('closing or stopping a Grok chat while it starts', () => {
envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }),
turnId: 'turn-of-an-old-child'
})
expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } })
await rig.settle()
expect(rig.child().closes).toBe(0)
expect(rig.child().exited).toBe(false)
await host.close(SESSION, 'user-close')
await attaching
await stopping
})
it('never spawns a child for a start closed while its launch was still resolving', async () => {
@@ -47,11 +47,19 @@ describe('a live Grok listing', () => {
stop: vi.fn(),
providerStarted: vi.fn()
}
// The rig's attach must succeed: bookkeeping never fails a proven start.
const { host, store } = await openAttachedHostRig({}, undefined, modelCatalog)
// The rig waits for the child to turn ready: bookkeeping never fails a proven start.
const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog)
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1)
expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull()
await expect(host.readOptions(SESSION)).resolves.toHaveProperty('models')
// The rest of the start still lands, and live options remain readable.
await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) })
expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2)
expect(
(await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current
).toMatchObject({
model: 'grok-4.7',
effort: 'high'
})
expect(store.getRecord(SESSION)?.options).toBeUndefined()
})
})
@@ -9,8 +9,9 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support'
import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support'
import { framesOf, openAttachedHostRig, send, stop } from './acp-structured-host.test-support'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { STOP_NOTE_CANCELLATION_REQUESTED } from '../native-chat/agent-session-wire/structured-agent-session-turn-stop-notes'
afterEach(async () => {
await closeProviderTimelineRigs()
@@ -40,54 +41,117 @@ async function within(promise: Promise<unknown>, ms: number) {
}
}
const failed = (outcome: Awaited<ReturnType<typeof within>>) =>
outcome !== 'pending' &&
(outcome.settled === 'rejected' ||
(typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value
? outcome.value.ok === false
: false))
describe('a Grok model pick Grok never answers', () => {
it('does not hold a Stop behind it; the pick fails', async () => {
const { rig, host } = await openAttachedHostRig()
const picking = pick(host, 'grok-4.6')
describe('a Grok model pick Grok never answers during send', () => {
it('does not hold Stop, writes one request note, and ignores a late setter reply', async () => {
const { rig, host, rows } = await openAttachedHostRig({
script: (agent) => agent.on('session/set_config_option', () => {})
})
expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true })
await send(host, 'hello')
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1))
expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' })
expect(failed(await within(picking, 1_000))).toBe(true)
const invalid = host.cancel(CALLER, {
envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' })
})
// The write runs outside the session's serialize, so even a refused Stop is answered at once.
expect(await within(invalid, 100)).toMatchObject({ settled: 'resolved' })
const request = { envelope: envelope('agentSession.cancel', {}) }
expect(await within(host.cancel(CALLER, request), 2_000)).toMatchObject({ settled: 'resolved' })
expect(await invalid).toMatchObject({ ok: false })
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
expect(
host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState
).toBe('rejected')
expect(
(await rows()).filter(
(row) => row.body.kind === 'status' && row.body.text === STOP_NOTE_CANCELLATION_REQUESTED
)
).toHaveLength(1)
const [frame] = framesOf(rig.child(), 'session/set_config_option')
rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS })
expect(framesOf(rig.child(), 'session/prompt')).toEqual([])
expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending')
})
it('does not hold a Close behind it; the pick fails', async () => {
const { rig, host } = await openAttachedHostRig()
const picking = pick(host, 'grok-4.6')
it('does not hold a Close behind its write', async () => {
const { rig, host } = await openAttachedHostRig({
script: (agent) => agent.on('session/set_config_option', () => {})
})
expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true })
await send(host, 'hello')
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1))
expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({
settled: 'resolved'
})
expect(failed(await within(picking, 1_000))).toBe(true)
const [frame] = framesOf(rig.child(), 'session/set_config_option')
rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS })
expect(framesOf(rig.child(), 'session/prompt')).toEqual([])
})
it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => {
const { rig, host, store, fence } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 })
const picking = pick(host, 'grok-4.6')
it('re-establishes opening A after uncertain B without adopting B’s late reply', async () => {
const { rig, host, store, fence } = await openAttachedHostRig({
optionWriteTimeoutMs: 100,
script: (agent) => agent.on('session/set_config_option', () => {})
})
expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true })
const savedModel = store.getRecord(SESSION)?.options?.model
expect(savedModel).toBe('grok-4.6')
await send(host, 'hello')
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1))
expect(failed(await within(picking, 2_000))).toBe(true)
await waitFor(() =>
expect(
host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState
).toBe('rejected')
)
expect(framesOf(rig.child(), 'session/prompt')).toEqual([])
// The chat's queue moved on: a later operation runs.
expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending')
const [frame] = framesOf(rig.child(), 'session/set_config_option')
const savedModel = store.getRecord(SESSION)?.options?.model
rig.child().agent.reply(frame!, {
const restore = rig.adapter.dispatch({
sessionId: SESSION,
fence: fence(),
clientMessageId: 'restore-A',
executionOptions: { model: 'grok-4.7' },
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'A again' }] }
})
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(2))
const [old, current] = framesOf(rig.child(), 'session/set_config_option')
rig.child().agent.reply(current!, { configOptions: GROK_CONFIG_OPTIONS })
await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(3))
rig.child().agent.reply(framesOf(rig.child(), 'session/set_config_option')[2]!, {
configOptions: GROK_CONFIG_OPTIONS
})
await restore
expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1)
rig.child().agent.reply(old!, {
configOptions: GROK_CONFIG_OPTIONS.map((option) =>
option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option
)
})
await waitFor(async () =>
expect(
(await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model
).toBe('grok-4.6')
)
await rig.settle()
expect(
(await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model
).toBe('grok-4.7')
expect((await host.readOptions(SESSION)).current.model).toBe(savedModel)
expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel)
await host.close(SESSION, 'user-close')
})
})
describe('a saved Grok pick', () => {
it.each(['Stop', 'Close'] as const)(
'never contacts Grok or holds %s behind a setter',
async (ending) => {
const { rig, host, store } = await openAttachedHostRig({
script: (agent) => agent.on('session/set_config_option', () => {})
})
expect(await within(pick(host, 'grok-4.6'), 1_000)).toMatchObject({
settled: 'resolved',
value: { ok: true }
})
expect(store.getRecord(SESSION)?.options?.model).toBe('grok-4.6')
expect(framesOf(rig.child(), 'session/set_config_option')).toEqual([])
const ended = ending === 'Stop' ? stop(host) : host.close(SESSION, 'user-close')
expect(await within(ended, 2_000)).toMatchObject({ settled: 'resolved' })
await host.close(SESSION, 'user-close')
}
)
})
@@ -120,14 +120,16 @@ async function openRestoreRig(code: number, message: string) {
describe('a saved Grok session Grok cannot reopen', () => {
it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => {
const { host, store, fence, loads, warnings, exchange } = await openRestoreRig(
const { host, store, fence, loads, warnings, ready, exchange } = await openRestoreRig(
-32603,
'session file is corrupt'
)
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION])
const chain = store.getRecord(SESSION)?.providerHandleChain ?? []
@@ -160,6 +162,7 @@ describe('a saved Grok session Grok cannot reopen', () => {
// The next reopen loads the fresh session, and says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3)
expect(await warnings()).toHaveLength(1)
@@ -169,14 +172,16 @@ describe('a saved Grok session Grok cannot reopen', () => {
describe('a created Grok session Grok reports missing on the first reopen', () => {
it('replaces it and says so once when the chat exchanged a turn on it', async () => {
const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig(
const { host, store, fence, loads, warnings, ready, exchange, messages } = await openRestoreRig(
-32002,
'Resource not found'
)
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await exchange('remember 42', 'noted', PROVIDER_SESSION)
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION])
const chain = store.getRecord(SESSION)?.providerHandleChain ?? []
@@ -195,19 +200,22 @@ describe('a created Grok session Grok reports missing on the first reopen', () =
// The next reopen loads the fresh session, and says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
})
it('supersedes it silently when nothing was exchanged on it', async () => {
const { host, store, fence, loads, warnings } = await openRestoreRig(
const { host, store, fence, loads, warnings, ready } = await openRestoreRig(
-32002,
'Resource not found'
)
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION])
const chain = store.getRecord(SESSION)?.providerHandleChain ?? []
@@ -224,13 +232,14 @@ describe('a created Grok session Grok reports missing on the first reopen', () =
})
})
describe('the warning row of a replacement whose attach failed', () => {
async function replaceThenFailAttach() {
describe('a replacement after its earlier attach failed to open the journal', () => {
async function failAttachBeforeReplacement() {
const opened = await openRestoreRig(-32603, 'session file is corrupt')
const { host, store, fence, warnings } = opened
const { host, store, fence, warnings, ready } = opened
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
// The journal's open fails after the fresh session's link is durable, before its row is written.
// The journal must open before a spawned child's background handshake proves a new link.
vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce(
new Error('journal path unavailable')
)
@@ -240,32 +249,37 @@ describe('the warning row of a replacement whose attach failed', () => {
expect(
agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? [])
).toMatchObject({
handle: { nativeId: FRESH },
replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' }
handle: { nativeId: PROVIDER_SESSION }
})
expect(await warnings()).toEqual([])
return opened
}
it('is written by the next start that loads the fresh session, and only once', async () => {
const { host, fence, loads, warnings } = await replaceThenFailAttach()
it('writes the warning when the next start replaces the old session, and only once', async () => {
const { host, fence, loads, warnings, ready } = await failAttachBeforeReplacement()
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
await ready()
expect(loads).toEqual([PROVIDER_SESSION])
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH])
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
})
it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => {
const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach()
const { host, store, fence, loads, lost, warnings, ready } = await failAttachBeforeReplacement()
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
lost.set(FRESH, { code: -32002, message: 'Resource not found' })
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
const chain = store.getRecord(SESSION)?.providerHandleChain ?? []
expect(chain).toHaveLength(2)
@@ -277,6 +291,7 @@ describe('the warning row of a replacement whose attach failed', () => {
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2])
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
@@ -285,23 +300,27 @@ describe('the warning row of a replacement whose attach failed', () => {
describe('a replacement Grok never saved, superseded after its row was written', () => {
it('keeps the one row: the conversation lost is still the first one', async () => {
const { host, fence, loads, lost, warnings } = await openRestoreRig(
const { host, fence, loads, lost, warnings, ready } = await openRestoreRig(
-32603,
'session file is corrupt'
)
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await ready()
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
lost.set(FRESH, { code: -32002, message: 'Resource not found' })
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH])
expect(await warnings()).toHaveLength(1)
// The newer session carries the same loss, so reopening it says nothing new.
await host.close(SESSION, 'user-close')
expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true })
await ready()
expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2])
expect(await warnings()).toHaveLength(1)
await host.close(SESSION, 'user-close')
@@ -35,7 +35,7 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void):
aborts.begin = (sessionId) => {
const started = begin(sessionId)
return {
signal: started.signal,
...started,
end: () => {
if (!fired) {
fired = true
@@ -70,7 +70,7 @@ describe('a Grok start the host aborts', () => {
await rig.frame('initialize')
// The start has no bound of its own: the quit is what ends it.
expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED)
expect((await attaching).ok).toBe(false)
expect((await attaching).ok).toBe(true)
expect(rig.child().exited).toBe(true)
await rig.adapter.closeAll()
})
@@ -180,6 +180,7 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {}
deps: { resolveLaunch: launch(() => resumed) }
})
expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await rig.ready()
resumed = true
await rig.host.close(SESSION, 'user-close')
return { ...rig, children: () => children }
@@ -0,0 +1,31 @@
import { afterEach, expect, it } from 'vitest'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { waitFor } from './acp-structured-adapter.test-support'
import { attachParams, openHostRig, send } from './acp-structured-host.test-support'
afterEach(closeProviderTimelineRigs)
it('tells queued messages the agent is signed out when its published startup fails authentication', async () => {
const { rig, host } = await openHostRig({
script: (agent) => agent.on('session/new', () => {})
})
expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
const request = await rig.frame('session/new')
const messageId = await send(host, 'held during sign-in')
expect(rig.sent('session/prompt')).toEqual([])
rig.child().agent.fail(request, -32000, 'Authentication required')
await waitFor(async () => {
const snapshot = await host.journalSnapshot(SESSION)
const submission = snapshot.submissions.find((entry) => entry.clientMessageId === messageId)
expect(submission?.handedOverAt).toBeUndefined()
expect(submission).toMatchObject({
dispatchState: 'rejected',
rejection: { kind: 'notSignedIn' },
reason: expect.stringMatching(/^Sign in to Grok/)
})
})
expect(rig.child().exited).toBe(true)
expect(rig.sent('session/prompt')).toEqual([])
})
@@ -30,6 +30,7 @@ import {
openAcpAdapterRig,
PROVIDER_SESSION,
replyChunk,
waitFor,
type FakeAcpChild
} from './acp-structured-adapter.test-support'
import {
@@ -128,12 +129,17 @@ export async function openHostRig(
hosted.host = host
replaceHostTestState({ store, host })
const fence = () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1
const ready = () =>
waitFor(() =>
expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready')
)
const messages = async () =>
(await host.history({ sessionId: SESSION, direction: 'tail' })).page.items
.filter((row) => row.body.kind === 'message')
.map((row) => messageText(row.body))
/** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */
const exchange = async (text: string, end: boolean) => {
await ready()
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
await journal.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, {
fence: fence(),
@@ -153,7 +159,7 @@ export async function openHostRig(
await rig.settle()
await host.flushStreamedEvents(SESSION)
}
return { rig, host, store, journalDatabase, fence, messages, exchange }
return { rig, host, store, journalDatabase, fence, messages, exchange, ready }
}
/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */
@@ -189,24 +195,30 @@ export const framesOf = (child: FakeAcpChild, method: string) =>
/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */
export async function openAttachedHostRig(
deps: Partial<AcpStructuredSessionAdapterDeps> = {},
deps: Partial<AcpStructuredSessionAdapterDeps> & {
script?: (agent: AcpScriptedAgent) => void
} = {},
statusSink?: StructuredAgentSessionStatusSink,
modelCatalog?: AgentModelCatalogService
) {
const { script, ...adapterDeps } = deps
const count = { loads: 0 }
let resumed = false
const rig = await openHostRig({
statusSink,
...(modelCatalog ? { modelCatalog } : {}),
initialize: RESUMES,
script: (agent) =>
script: (agent) => {
agent.on('session/load', (frame) => {
count.loads += 1
agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS })
}),
deps: { resolveLaunch: launch(() => resumed), ...deps }
})
script?.(agent)
},
deps: { resolveLaunch: launch(() => resumed), ...adapterDeps }
})
expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true })
await rig.ready()
resumed = true
const rows = async () => {
await rig.host.flushStreamedEvents(SESSION)
@@ -2,7 +2,6 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration'
import { replaceAgentSessionRecordOptions } from '../runtime/agent-session-record-options'
import { agentSessionStoreDraftRowWrites } from '../runtime/agent-session-store-draft'
import type { AgentSessionStoreState } from '../runtime/agent-session-store-state'
@@ -27,7 +26,7 @@ const modelOption: SessionConfigOption = {
options: [{ value: 'reported-model', name: 'Reported Model' }]
}
async function writtenOptions(provider: string, reader: AcpStructuredOptions) {
function writtenOptions(provider: string, reader: AcpStructuredOptions) {
const fixture = agentSessionRecordFixture()
const record = {
...fixture,
@@ -45,14 +44,7 @@ async function writtenOptions(provider: string, reader: AcpStructuredOptions) {
unreadableRecords: new Map(),
sessionTabs: null
}
const options = await readNativeSessionOptions({
adapter: { readOptions: async () => reader.read() },
sessionId: record.sessionId,
fence: record.lease.runtimeFence
})
if (!options) {
throw new Error('The live reader must report its options')
}
const options = reader.reported()
const changed = replaceAgentSessionRecordOptions(record, {
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
@@ -71,7 +63,7 @@ async function writtenOptions(provider: string, reader: AcpStructuredOptions) {
}
describe('ACP sessions without a reported model', () => {
it('writes the captured Windows OMP startup through the live reader and shared normalizer', async () => {
it('writes the captured Windows OMP startup through the provider reader', async () => {
const reader = new AcpStructuredOptions()
reader.adoptSession(response)
expect(reader.read()).toEqual({
@@ -79,14 +71,14 @@ describe('ACP sessions without a reported model', () => {
current: { effort: 'off', confirmed: ['effort'] }
})
expect(reader.reported()).toEqual({ effort: 'off' })
expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' })
expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' })
})
it.each(['grok', 'opencode'])('writes missing-model options for %s', async (provider) => {
const reader = new AcpStructuredOptions()
reader.adoptSession({})
expect(reader.read().current).not.toHaveProperty('model')
expect(await writtenOptions(provider, reader)).toEqual({})
expect(writtenOptions(provider, reader)).toEqual({})
})
it.each(['config', 'legacy'] as const)('does not expose an empty %s model value', (source) => {
@@ -103,13 +95,13 @@ describe('ACP sessions without a reported model', () => {
it('records a model reported after a startup with no model', async () => {
const reader = new AcpStructuredOptions()
reader.adoptSession(response)
expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' })
expect(writtenOptions('omp', reader)).toEqual({ effort: 'off' })
reader.adoptConfigOptions([...(response.configOptions ?? []), modelOption])
expect(reader.read().current).toEqual({
model: 'reported-model',
effort: 'off',
confirmed: ['model', 'effort']
})
expect(await writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' })
expect(writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' })
})
})
+5 -10
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options'
import { AcpStructuredOptions } from './acp-structured-options'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
const configOptions = [
@@ -121,11 +121,6 @@ describe('ACP session options', () => {
})
describe('what a new session with no pick says about the configured default', () => {
const connection = {
setConfigOption: async () => ({ configOptions }),
setModel: async () => ({})
}
it('is the model and effort the agent chose for itself', () => {
const options = new AcpStructuredOptions()
options.adoptSession({ configOptions }, 'new')
@@ -138,17 +133,17 @@ describe('what a new session with no pick says about the configured default', ()
expect(options.configuredDefault()).toBeUndefined()
})
it('says nothing once the chat holds a model pick, even one the session already runs', async () => {
it('says nothing once the chat holds a model pick, even one the session already runs', () => {
const options = new AcpStructuredOptions()
options.adoptSession({ configOptions }, 'new')
await restoreAcpSessionOptions(connection, options, { model: 'model-b' })
options.notePick('model')
expect(options.configuredDefault()).toBeUndefined()
})
it('keeps the model but not an effort the chat picked', async () => {
it('keeps the model but not an effort the chat picked', () => {
const options = new AcpStructuredOptions()
options.adoptSession({ configOptions }, 'new')
await restoreAcpSessionOptions(connection, options, { effort: 'low' })
options.notePick('effort')
expect(options.configuredDefault()).toEqual({ modelId: 'model-b' })
})
+40 -48
View File
@@ -2,14 +2,14 @@
// effort), the older model list, and its `/` commands — as Orca's option and command surfaces.
// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself.
import { abortSignalReason } from '../../shared/abort-signal-reason'
import type {
AgentSessionModelOption,
AgentSessionOptionChoice,
AgentSessionOptionsResult,
AgentSessionSlashCommand
} from '../../shared/agent-session-wire'
import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions'
import { AcpRpcError } from './acp-errors'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpLaunchSpec } from './acp-launch-specs'
import type { AcpDialect } from './acp-dialects/acp-dialect'
@@ -65,7 +65,10 @@ export class AcpStructuredOptions {
private commands: AgentSessionSlashCommand[] | undefined
// A loaded session keeps the model it ran; only a new one resolves the agent's own config.
private resolvesConfig = false
private readonly picked = new Set<string>()
readonly picked = new Set<string>()
private initialChoice: Readonly<Record<string, string>> = {}
currentProof = new Set<string>()
writeRevision = 0
/** The session state a new, load or resume response reported. */
adoptSession(
@@ -78,6 +81,13 @@ export class AcpStructuredOptions {
this.configOptions = response.configOptions ?? []
this.models = response.models ?? null
this.resolvesConfig = origin === 'new'
this.initialChoice = this.reported()
this.currentProof = new Set(Object.keys(this.initialChoice))
}
/** The native selection before any message changed this child. */
messageDefault(): Readonly<Record<string, string>> {
return this.initialChoice
}
/** Orca sent `key` to the session, or the chat's saved options hold a value for it. */
@@ -238,30 +248,50 @@ export async function readAcpLiveOptions(
return { ...withLiveCatalogListing(session.options.read()), acceptsImages }
}
/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is
* still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */
/** Only the current, still-awaited write may establish reusable option proof. */
export function writeAcpSessionOption(
connection: Pick<AcpStructuredConnection, 'setConfigOption' | 'setModel'>,
options: AcpStructuredOptions,
write: AcpOptionWrite,
bound: { agent: string; timeoutMs: number; signal?: AbortSignal }
bound: { agent: string; timeoutMs: number; signal?: AbortSignal; key: string }
): Promise<void> {
const { agent, timeoutMs, signal } = bound
if (signal?.aborted) {
return Promise.reject(new Error(`${agent} option write abandoned before it was sent`))
return Promise.reject(abortSignalReason(signal))
}
const revision = ++options.writeRevision
options.currentProof.delete(bound.key)
if (bound.key === 'model') {
options.currentProof.delete('effort')
}
const adopt = (apply: () => void): void => {
if (options.writeRevision !== revision) {
throw new Error(`${agent} option write was superseded`)
}
apply()
options.currentProof.add(bound.key)
}
const applied =
write.method === 'config'
? connection
.setConfigOption(write.configId, write.value)
.then((result) => options.adoptConfigOptions(result.configOptions))
: connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId))
.then((result) => adopt(() => options.adoptConfigOptions(result.configOptions)))
: connection
.setModel(write.modelId)
.then(() => adopt(() => options.adoptModel(write.modelId)))
return new Promise<void>((resolve, reject) => {
const fail = (error: Error): void => {
if (options.writeRevision === revision) {
options.writeRevision += 1
}
settle()
reject(error)
}
const onAbort = (): void => fail(new Error(`${agent} option write abandoned`))
const onAbort = (): void => {
if (signal) {
fail(abortSignalReason(signal))
}
}
const timer = setTimeout(
() => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)),
timeoutMs
@@ -277,41 +307,3 @@ export function writeAcpSessionOption(
}, fail)
})
}
/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and
* reported, never retried. */
export async function restoreAcpSessionOptions(
connection: Pick<AcpStructuredConnection, 'setConfigOption' | 'setModel'>,
options: AcpStructuredOptions,
saved: Readonly<Record<string, string>> | undefined
): Promise<string[]> {
const skipped: string[] = []
for (const [key, value] of Object.entries(saved ?? {})) {
if (!isAcpStructuredOptionKey(key)) {
continue
}
options.notePick(key)
if (options.reported()[key] === value) {
continue
}
const write = options.write(key, value)
try {
if (write?.method === 'config') {
options.adoptConfigOptions(
(await connection.setConfigOption(write.configId, value)).configOptions
)
} else if (write?.method === 'model') {
await connection.setModel(write.modelId)
options.adoptModel(write.modelId)
} else {
skipped.push(key)
}
} catch (error) {
if (!(error instanceof AcpRpcError)) {
throw error
}
skipped.push(key)
}
}
return skipped
}
@@ -0,0 +1,203 @@
import { z } from 'zod'
import { afterEach, describe, expect, it } from 'vitest'
import {
closeProviderTimelineRigs,
SESSION
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import {
GROK_CONFIG_OPTIONS,
openAcpAdapterRig,
PROVIDER_SESSION,
waitFor
} from './acp-structured-adapter.test-support'
import type { FakeFrame } from './acp-scripted-agent.test-support'
afterEach(closeProviderTimelineRigs)
const configParams = z.object({ configId: z.string(), value: z.string() })
describe('ACP child published before its handshake', () => {
it('opens without writing saved message choices to the provider', async () => {
let answer: (() => void) | undefined
const writes: string[] = []
let configOptions = GROK_CONFIG_OPTIONS
const rig = await openAcpAdapterRig({
script: (agent) => {
agent.on('session/new', (frame) => {
answer = () => agent.reply(frame, { sessionId: PROVIDER_SESSION, configOptions })
})
agent.on('session/set_config_option', (frame) => {
const { configId: key, value } = configParams.parse(frame.params)
writes.push(key)
configOptions = configOptions.map((option) =>
option.id === key ? { ...option, currentValue: value } : option
)
agent.reply(frame, { configOptions })
})
}
})
const acquired = await rig.acquire({
waitForStart: false,
options: { effort: 'low', model: 'grok-4.6' }
})
expect(acquired.process.pid).toBe(4242)
if (!acquired.acquisitionGeneration) {
throw new Error('missing acquisition generation')
}
expect(acquired.link).toBeUndefined()
expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe(true)
expect(rig.adapter.holdsDispatch(SESSION)).toBe(false)
expect(rig.adapter.readCommands(SESSION)).toBeUndefined()
const opening = rig.adapter.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
await rig.frame('session/new')
expect(rig.lifecycle).toEqual([])
answer?.()
expect(await opening).toMatchObject({
link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' },
reportedOptions: { model: 'grok-4.7', effort: 'high' }
})
expect(writes).toEqual([])
rig.child().agent.notify('session/update', {
sessionId: PROVIDER_SESSION,
update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS }
})
await waitFor(() =>
expect(rig.lifecycle.map((event) => event.type)).toEqual(['options-reported'])
)
})
it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)(
'lets %s stop the starting process without a late start',
async (method) => {
let frame: FakeFrame | undefined
const rig = await openAcpAdapterRig({
script: (agent) =>
agent.on('session/new', (request) => {
frame = request
})
})
const acquired = await rig.acquire({ waitForStart: false })
const opening = rig.adapter
.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
.catch((error: unknown) => error)
await rig.frame('session/new')
if (!acquired.acquisitionGeneration) {
throw new Error('missing acquisition generation')
}
const closed =
method === 'releaseAcquisition'
? rig.adapter[method]({ sessionId: SESSION })
: rig.adapter[method](SESSION)
expect(await closed).toBe(true)
expect(await opening).toBeInstanceOf(Error)
if (frame) {
rig.child().agent.reply(frame, { sessionId: PROVIDER_SESSION })
}
await rig.settle()
expect(rig.lifecycle.map((event) => event.type)).toEqual(['ended'])
expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe(
false
)
}
)
it.each([true, false])(
'reports a startup exit with initialize answered = %s',
async (answered) => {
const rig = await openAcpAdapterRig({
script: (agent) => agent.on(answered ? 'session/new' : 'initialize', () => {})
})
await rig.acquire({ waitForStart: false })
const opening = rig.adapter
.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: 'gen-acp',
signal: new AbortController().signal
})
.catch((error: unknown) => error)
await rig.frame(answered ? 'session/new' : 'initialize')
rig.child().stderr = 'provider config is invalid'
rig.child().exit()
expect(await opening).toBeInstanceOf(Error)
expect(rig.ended).toMatchObject([
{ failure: { detail: { text: 'provider config is invalid' } } }
])
expect(rig.ended[0]?.startupUnanswered).toBeUndefined()
}
)
it('reports signed-out startup as a notSignedIn fact and proves the process ended', async () => {
const rig = await openAcpAdapterRig({
script: (agent) =>
agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required'))
})
const acquired = await rig.acquire({ waitForStart: false })
await expect(
rig.adapter.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
).rejects.toMatchObject({ reason: 'notSignedIn' })
expect(rig.ended).toEqual([])
await rig.adapter.closeSession(SESSION)
expect(rig.child().exited).toBe(true)
})
it('waits until message input to try a saved choice the provider will refuse', async () => {
const rig = await openAcpAdapterRig({
script: (agent) =>
agent.on('session/set_config_option', (frame) =>
agent.fail(frame, -32602, 'model unavailable')
)
})
await rig.acquire({ options: { model: 'retired-model' } })
expect(rig.started()).toMatchObject({
reportedOptions: { model: 'grok-4.7', effort: 'high' }
})
expect(rig.sent('session/set_config_option')).toEqual([])
await expect(
rig.adapter.dispatch({
sessionId: SESSION,
fence: 1,
clientMessageId: 'refused-choice',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
executionOptions: { model: 'retired-model' }
})
).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' })
expect(rig.sent('session/set_config_option')).toHaveLength(1)
expect(rig.sent('session/prompt')).toEqual([])
expect(rig.child().exited).toBe(false)
})
it('reports the session it loaded as the resumed link', async () => {
const rig = await openAcpAdapterRig({
launch: {
resume: {
sessionId: 'saved-session',
key: 'saved-key',
mayBeUnsaved: () => false,
unannouncedLosses: () => []
}
}
})
const acquired = await rig.acquire()
expect(acquired.link).toBeUndefined()
expect(rig.started().link).toMatchObject({
handle: { nativeId: 'saved-session' },
origin: 'resumed'
})
expect(rig.sent('session/new')).toEqual([])
})
})
@@ -5,7 +5,6 @@ import {
providerTurnId,
SESSION
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue'
import { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory'
import { tick } from './acp-scripted-agent.test-support'
import {
@@ -138,7 +137,6 @@ describe('ACP Stop of a turn Grok began itself', () => {
}
})
)
expect(rig.adapter.stopEndsSession()).toBe(true)
await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({
cancelled: true
})
@@ -173,7 +171,7 @@ describe('ACP connection loss', () => {
rig.child().agent.stdin.destroy()
await rig.settle()
await waitFor(() =>
expect(rig.lifecycle).toMatchObject([
expect(rig.ended).toMatchObject([
{ type: 'ended', cause: 'unexpected-exit', acquisitionGeneration: 'gen-acp' }
])
)
@@ -195,10 +193,10 @@ describe('ACP connection loss', () => {
rig.child().stderr = 'panic: out of memory'
rig.child().agent.stdout.end()
await rig.settle()
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
rig.child().exit()
await waitFor(() => expect(rig.lifecycle).toHaveLength(1))
expect(rig.lifecycle[0]).toMatchObject({
await waitFor(() => expect(rig.ended).toHaveLength(1))
expect(rig.ended[0]).toMatchObject({
type: 'ended',
cause: 'unexpected-exit',
reason: 'grok ACP agent exited: panic: out of memory',
@@ -214,7 +212,7 @@ describe('ACP connection loss', () => {
child.stderr = 'panic: late'
child.agent.close()
await rig.settle()
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
// Never left Orca, so it is not recorded as unconfirmed.
expect(
await rig.adapter.dispatch({
@@ -233,8 +231,8 @@ describe('ACP connection loss', () => {
name: 'AgentSessionAcquisitionExitUnprovenError'
})
child.exit()
await waitFor(() => expect(rig.lifecycle).toHaveLength(1))
expect(rig.lifecycle[0]).toMatchObject({
await waitFor(() => expect(rig.ended).toHaveLength(1))
expect(rig.ended[0]).toMatchObject({
cause: 'unexpected-exit',
reason: 'grok ACP agent exited: panic: late',
failure: { kind: 'providerExited', detail: { text: 'panic: late' } }
@@ -279,7 +277,7 @@ describe('ACP connection loss', () => {
await rig.settle()
expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['garbled', 'next'])
expect(rig.child().closes).toBe(0)
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
})
it('leaves Grok running when it ends its stdout but can still be written to', async () => {
@@ -293,9 +291,9 @@ describe('ACP connection loss', () => {
await rig.settle()
expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'running' })
expect(rig.child().closes).toBe(0)
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
})
})
@@ -339,77 +337,84 @@ describe('ACP refusals', () => {
})
describe('ACP startup that never answers', () => {
it('lets a close stop the child while the handshake is unanswered', async () => {
it('lets a close stop the published child while initialize is unanswered', async () => {
const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) })
const queue = new StructuredAgentSessionTaskQueue()
const start = new AbortController()
const acquiring = queue.serialize(SESSION, () => rig.acquire({ signal: start.signal }))
const failed = acquiring.catch((error: unknown) => error)
const acquired = await rig.acquire({ waitForStart: false })
const opening = rig.adapter
.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
.catch((error: unknown) => error)
await rig.frame('initialize')
// What the host's close does outside the queue, then its queued stop.
start.abort()
const closing = queue.serialize(SESSION, () => rig.adapter.closeSession(SESSION))
expect(rig.child().closes).toBe(1)
expect(await failed).toMatchObject({ message: 'Grok was closed while starting' })
await expect(closing).resolves.toBe(true)
expect(rig.lifecycle).toEqual([])
expect(await rig.adapter.closeSession(SESSION)).toBe(true)
expect(await opening).toBeInstanceOf(Error)
expect(rig.child().exited).toBe(true)
expect(rig.ended).toMatchObject([{ cause: 'requested-close' }])
})
it('leaves the child of a start that returned alone when its signal aborts later', async () => {
it('leaves a published child alone when the acquisition signal aborts later', async () => {
const rig = await openAcpAdapterRig()
const start = new AbortController()
await rig.acquire({ signal: start.signal })
// The host's close, landing while it still commits the attach, stops the child its own way.
start.abort(new Error('closed while starting'))
await rig.settle()
expect(rig.child().closes).toBe(0)
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
})
it('lets a handshake Grok never answers run on until the start is aborted', async () => {
it('does not time out a published handshake, and closes it through the session', async () => {
const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) })
const acquired = await rig.acquire({ waitForStart: false })
const opening = rig.adapter
.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
.catch((error: unknown) => error)
await rig.frame('session/new')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const rig = await openAcpAdapterRig({
script: (agent) => agent.on('session/new', () => {})
})
const start = new AbortController()
const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error)
let settled = false
void failed.then(() => {
settled = true
})
await rig.settle()
vi.advanceTimersByTime(30 * 60_000)
await rig.settle()
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(30 * 60_000)
expect(rig.ended).toEqual([])
expect(rig.child().closes).toBe(0)
start.abort()
expect(await failed).toBeInstanceOf(Error)
expect(rig.child().closes).toBeGreaterThan(0)
expect(await rig.adapter.closeSession(SESSION)).toBe(true)
expect(await opening).toBeInstanceOf(Error)
} finally {
vi.useRealTimers()
}
})
it('keeps the child of a failed start until its exit is proven, and starts no other meanwhile', async () => {
const rig = await openAcpAdapterRig({
script: (agent) => agent.on('initialize', () => {})
})
const start = new AbortController()
const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error)
await rig.frame('initialize')
it('keeps a failed startup child until its exit is proven, and spawns no other meanwhile', async () => {
const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) })
const acquired = await rig.acquire({ waitForStart: false })
const opening = rig.adapter
.open({
sessionId: SESSION,
fence: 1,
acquisitionGeneration: acquired.acquisitionGeneration,
signal: new AbortController().signal
})
.catch((error: unknown) => error)
const frame = await rig.frame('initialize')
const child = rig.child()
child.proveClose = vi.fn(async () => false)
start.abort()
expect(await failed).toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' })
// Every later stop asks the child again, and none answers for it.
child.agent.fail(frame, -32603, 'provider failed to initialize')
expect(await opening).toBeInstanceOf(Error)
expect(child.closes).toBe(0)
expect(rig.ended).toEqual([])
expect(await rig.adapter.closeSession(SESSION)).toBe(false)
await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped')
expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({
await expect(rig.acquire()).rejects.toMatchObject({
name: 'AgentSessionAcquisitionExitUnprovenError'
})
expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1)
child.exit()
expect(rig.ended).toMatchObject([{ cause: 'requested-close' }])
expect(await rig.adapter.closeSession(SESSION)).toBe(true)
})
@@ -464,7 +469,7 @@ describe('ACP session release', () => {
const rig = await openAcpAdapterRig()
await expect(rig.adapter.releaseAcquisition({ sessionId: SESSION })).resolves.toBe(true)
expect(rig.spawned).toEqual([])
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
})
it('keeps nothing of a chat once its child is proven closed', async () => {
@@ -481,7 +486,7 @@ describe('ACP session release', () => {
const rig = await openAcpAdapterRig()
await rig.acquire()
rig.child().exit()
await waitFor(() => expect(rig.lifecycle).toHaveLength(1))
await waitFor(() => expect(rig.ended).toHaveLength(1))
expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined()
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
})
@@ -7,7 +7,6 @@ import {
messageText,
SESSION
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import type { AcpScriptedAgent } from './acp-scripted-agent.test-support'
@@ -207,7 +206,8 @@ it("starts in the chat's own permission mode, asks when none is named, and fails
})
const [chosen, unnamed] = [await open(), await open()]
await chosen.acquire({ permissionMode: 'accept-edits' })
await expect(unnamed.acquire()).rejects.toThrow('Permission mode could not be confirmed')
await unnamed.acquire()
expect(unnamed.openFailure()).toEqual(new Error('Permission mode could not be confirmed'))
const sent = [chosen, unnamed].map((rig) => rig.sent('session/set_config_option')[0]?.params)
expect(sent).toMatchObject([
{ configId: 'approval_mode', value: 'auto_edit' },
@@ -333,8 +333,8 @@ describe('reattaching a Grok chat the journal holds', () => {
script: (agent) =>
agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found'))
})
const acquired = await rig.acquire()
expect(acquired.link).toMatchObject({
await rig.acquire()
expect(rig.started().link).toMatchObject({
origin: 'created',
handle: { nativeId: PROVIDER_SESSION },
supersedesKey: 'acp:grok:never-saved'
@@ -365,13 +365,13 @@ describe('reattaching a Grok chat the journal holds', () => {
initialize: RESUMES,
script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message))
})
const acquired = await rig.acquire()
expect(acquired.link).toMatchObject({
await rig.acquire()
expect(rig.started().link).toMatchObject({
origin: 'created',
handle: { nativeId: PROVIDER_SESSION },
replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 }
})
expect(acquired.link?.supersedesKey).toBeUndefined()
expect(rig.started().link?.supersedesKey).toBeUndefined()
expect(rig.sent('session/new')).toHaveLength(1)
const rows = await notRestoredRows(rig)
expect(rows).toHaveLength(1)
@@ -396,9 +396,8 @@ describe('reattaching a Grok chat the journal holds', () => {
script: (agent) =>
agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required'))
})
const failure = await rig.acquire().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal)
expect(failure).toMatchObject({ reason: 'notSignedIn' })
await rig.acquire()
expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' })
expect(rig.sent('session/new')).toEqual([])
})
@@ -418,13 +417,12 @@ describe('reattaching a Grok chat the journal holds', () => {
deps: { logger },
script: (agent) =>
agent.on('session/load', (frame) => {
start.abort()
void rig.adapter.closeSession(SESSION)
agent.fail(frame, -32603, 'session file is corrupt')
})
})
expect(
await rig.acquire({ signal: start.signal }).catch((error: unknown) => error)
).toBeInstanceOf(Error)
await rig.acquire({ signal: start.signal })
expect(rig.ended).toMatchObject([{ cause: 'requested-close' }])
expect(rig.sent('session/new')).toEqual([])
expect(await notRestoredRows(rig)).toEqual([])
// Nothing was replaced, so nothing says it was.
@@ -62,7 +62,7 @@ describe('ACP teardown of a running turn', () => {
const atClose = watchClose(rig.child(), turn)
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
expect(atClose).toEqual({ cancels: 1, turnEnded: true })
expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
})
it('a dispose ends the child at the grace when the agent never ends its turn', async () => {
@@ -4,11 +4,7 @@ import {
closeProviderTimelineRigs,
SESSION
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import {
AgentSessionAcquisitionExitProvenError,
AgentSessionAcquisitionRefusal,
AgentSessionAcquisitionRootExitObservedError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect'
import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env'
import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys'
@@ -52,7 +48,7 @@ describe('ACP structured session adapter: acquire', () => {
const acquired = await rig.acquire()
expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize'])
expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' })
expect(acquired.link).toMatchObject({
expect(rig.started().link).toMatchObject({
handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION },
origin: 'created',
mintedAtFence: 1
@@ -113,8 +109,8 @@ describe('ACP structured session adapter: acquire', () => {
script: (agent) =>
agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found'))
})
const acquired = await rig.acquire({ fence: 2 })
expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' })
await rig.acquire({ fence: 2 })
expect(rig.started().link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' })
})
it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => {
@@ -122,9 +118,9 @@ describe('ACP structured session adapter: acquire', () => {
script: (agent) =>
agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required'))
})
const failure = await rig.acquire().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AgentSessionAcquisitionRefusal)
expect(failure).toMatchObject({ reason: 'notSignedIn' })
await rig.acquire()
expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' })
expect(rig.ended).toMatchObject([{ cause: 'requested-close' }])
expect(rig.child().closes).toBe(1)
})
})
@@ -138,10 +134,10 @@ it('reports an agent that exits while starting with its own last words', async (
child.exit()
})
})
const failure = await rig.acquire().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError)
expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' })
expect(rig.lifecycle).toEqual([])
await rig.acquire()
expect(rig.ended).toMatchObject([
{ failure: { detail: { text: 'grok: config.toml is invalid' } } }
])
})
it('reads those last words when the agent ends its stdout before its exit is seen', async () => {
@@ -155,12 +151,35 @@ it('reads those last words when the agent ends its stdout before its exit is see
setTimeout(() => child.exit(), 5)
})
})
const failure = await rig.acquire().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AgentSessionAcquisitionExitProvenError)
expect(failure).toMatchObject({ message: 'grok: config.toml is invalid' })
await rig.acquire()
expect(rig.ended).toMatchObject([
{ failure: { detail: { text: 'grok: config.toml is invalid' } } }
])
})
describe('ACP structured session adapter: turns', () => {
it('applies a sent message’s model and effort before its prompt', async () => {
const rig = await openAcpAdapterRig()
await rig.acquire()
await rig.adapter.dispatch({
sessionId: SESSION,
fence: 1,
clientMessageId: 'chosen-send',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
executionOptions: { model: 'grok-4.6', effort: 'low' }
})
await rig.frame('session/prompt')
const methods = rig.child().agent.frames.map((frame) => frame.method)
expect(methods.slice(-3)).toEqual([
'session/set_config_option',
'session/set_config_option',
'session/prompt'
])
await expect(rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })).resolves.toMatchObject({
current: { model: 'grok-4.6', effort: 'low' }
})
})
it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => {
const rig = await openAcpAdapterRig({
deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } }
@@ -434,18 +453,18 @@ describe('ACP structured session adapter: close and exit', () => {
await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf(
AgentSessionAcquisitionRootExitObservedError
)
expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }])
})
it('closes with proof and reports a requested close', async () => {
const rig = await openAcpAdapterRig()
await rig.acquire()
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
expect(rig.lifecycle).toMatchObject([
expect(rig.ended).toMatchObject([
{ type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 }
])
await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true)
expect(rig.lifecycle).toHaveLength(1)
expect(rig.ended).toHaveLength(1)
})
it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => {
@@ -457,7 +476,7 @@ describe('ACP structured session adapter: close and exit', () => {
rig.child().stderr = 'grok: crashed'
rig.child().exit()
await waitFor(() =>
expect(rig.lifecycle).toMatchObject([
expect(rig.ended).toMatchObject([
{
type: 'ended',
cause: 'unexpected-exit',
+114 -134
View File
@@ -3,12 +3,12 @@
// them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it.
import { randomUUID } from 'node:crypto'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { withProviderDiagnostic } from '../../shared/agent-session-failure'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import {
AgentSessionAcquisitionExitProvenError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRefusal,
AgentSessionPreSpawnError,
isAgentSessionPreSpawnError,
type AgentSessionAcquisition,
@@ -18,20 +18,15 @@ import {
type StructuredAgentSessionSetOptionInput
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import { acpAgentName, acquireAcpStructuredSession } from './acp-structured-acquire'
import {
closeAcpSessionJournal,
endAcpStructuredSession,
rejectedAcpDispatch,
type AcpStructuredSession
} from './acp-structured-session'
import {
ProviderAcquisitionStarts,
type ProviderStartAttempt
} from '../provider-process/provider-acquisition-starts'
import { acpAgentName } from './acp-structured-agent-definitions'
import { acquireAcpStructuredSession } from './acp-structured-acquire'
import { rejectedAcpDispatch, type AcpStructuredSession } from './acp-structured-session'
import type { ProviderStartAttempt } from '../provider-process/provider-acquisition-starts'
import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection'
import { AcpConnectionClosedError } from './acp-errors'
import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop'
import { applyAcpMessageOptions } from './acp-message-execution-options'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop'
import { acpDispatchPrompt } from './acp-prompt-content'
import {
ACP_OPTION_WRITE_TIMEOUT_MS,
@@ -41,16 +36,16 @@ import {
import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options'
import { readAcpRecoveryHistory } from './acp-recovery-history'
import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop'
import { acpStartupFailure, isAcpReadySession } from './acp-structured-child'
import { AcpStructuredChildren } from './acp-structured-child-lifecycle'
import { AcpConversationCopies } from './acp-conversation-copies'
import { supportsAcpStructuredLocation } from './acp-structured-location-support'
export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter {
/** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */
private readonly sessions = new Map<string, AcpStructuredSession>()
private readonly starts = new ProviderAcquisitionStarts<AcpStructuredConnection>()
private readonly children: AcpStructuredChildren
private readonly copies: AcpConversationCopies
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {
this.children = new AcpStructuredChildren(deps)
this.copies = new AcpConversationCopies(deps)
}
@@ -71,9 +66,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
async acquire(input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const gate = this.deps.spec.launchGate?.enter(input.signal)
const attempt = this.starts.begin(gate?.signal ?? input.signal)
const attempt = this.children.starts.begin(gate?.signal ?? input.signal)
try {
if (!(await this.stop(sessionId))) {
if (!(await this.children.stop(sessionId))) {
throw new AgentSessionAcquisitionExitUnprovenError(
new Error(
`the previous ${this.deps.spec.agent} child for ${sessionId} could not be stopped`
@@ -82,7 +77,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
}
return await this.start({ ...input, signal: attempt.signal }, attempt)
} finally {
this.starts.end(attempt)
this.children.starts.end(attempt)
gate?.leave()
}
}
@@ -100,25 +95,24 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
generation,
abandoned: () => attempt.signal.aborted,
track: (connection) => {
this.starts.track(attempt, connection)
this.children.starts.track(attempt, connection)
this.deps.spec.launchGate?.retain(connection)
},
onExit: (session) => {
if (session && this.sessions.get(sessionId) === session) {
this.finish(session, this.now())
if (session && this.children.sessions.get(sessionId) === session) {
this.children.finish(session, this.now())
}
},
onConnectionLost: (session, error) => this.connectionLost(session, error),
onConnectionLost: (session, error) => this.children.connectionLost(session, error),
onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }),
forceClose: (id) => void this.forceCloseSession(id)
})
if (attempt.signal.aborted) {
session.lane.dispose()
session.dispose()
throw new Error('closed while starting')
}
this.sessions.set(sessionId, session)
// Its saved picks restored: what it runs now is what its start resolved.
return { ...acquisition, catalogListing: session.options.startListing() }
this.children.sessions.set(sessionId, session)
return acquisition
} catch (error) {
const { connection } = attempt
if (connection && error instanceof AcpConnectionClosedError) {
@@ -130,7 +124,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted
if (connection && !(await connection.close().catch(() => false))) {
// Kept, so the next start or quit closes this same process again; no second one spawns.
this.starts.retainFailed(sessionId, connection)
this.children.starts.retainFailed(sessionId, connection)
throw new AgentSessionAcquisitionExitUnprovenError(error)
}
if (attempt.signal.aborted) {
@@ -152,15 +146,35 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
}
}
async dispatch(input: {
sessionId: string
clientMessageId: string
body: AgentJournalMessageItem
fence: number
requestedAt?: number
beforeDispatch?: () => Promise<void>
}): Promise<AgentSessionDispatchOutcome> {
const lost = this.sessions.get(input.sessionId)
open: NonNullable<StructuredAgentSessionAdapter['open']> = async (input) => {
const child = this.children.sessions.get(input.sessionId)
if (
!child ||
child.fence !== input.fence ||
child.acquisitionGeneration !== input.acquisitionGeneration ||
child.ended
) {
throw new Error('ACP child changed before it opened')
}
try {
return await child.open(input.signal)
} catch (error) {
const failure = acpStartupFailure(child, error)
if (failure) {
const refusal = new AgentSessionAcquisitionRefusal(
`${acpAgentName(child.spec.agent)} requires sign-in`,
'notSignedIn'
)
throw failure.detail ? withProviderDiagnostic(refusal, failure.detail) : refusal
}
throw error
}
}
async dispatch(
input: Parameters<StructuredAgentSessionAdapter['dispatch']>[0]
): Promise<AgentSessionDispatchOutcome> {
const lost = this.children.sessions.get(input.sessionId)
if (lost && lost.journalClosed !== null) {
// The connection broke and the exit is not yet proven: the message never left Orca.
return rejectedAcpDispatch(lost)
@@ -170,11 +184,25 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
if (!Array.isArray(prompt)) {
return { state: 'rejected', ...prompt }
}
if (this.sessions.get(input.sessionId) !== session || session.journalClosed !== null) {
if (this.children.sessions.get(input.sessionId) !== session || session.journalClosed !== null) {
// The child ended while its attachments were read: nothing left Orca.
return rejectedAcpDispatch(session)
}
const { sessionId, fence, signal } = input
const optionsApplied = await applyAcpMessageOptions(
session,
input.executionOptions,
(key, value) => this.setOption({ sessionId, fence, key, value, signal }),
() =>
this.children.sessions.get(input.sessionId) === session && session.journalClosed === null,
signal
)
if (!optionsApplied) {
return rejectedAcpDispatch(session)
}
input.signal?.throwIfAborted()
await input.beforeDispatch?.()
input.signal?.throwIfAborted()
session.turns.dispatch({
clientMessageId: input.clientMessageId,
prompt,
@@ -205,14 +233,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
return { cancelled: true }
}
// Stop is a session boundary for every ACP agent, as in the common pattern: a cancel ends only
// the running turn, and work the agent moved to the background could begin a turn of its own.
// The next send reloads the session.
// Stop is a session boundary: a cancel ends only the running turn, and background work could
// begin another turn. The next send reloads the session.
stopEndsSession = (): boolean => true
awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise<void> => {
const session = this.sessions.get(sessionId)
if (session) {
const session = this.children.sessions.get(sessionId)
if (session && isAcpReadySession(session)) {
await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs())
}
}
@@ -226,11 +253,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
const session = this.live(input.sessionId)
const write = session.options.write(input.key, input.value)
if (!write) {
throw new Error(`${session.spec.agent} offers no session option named ${input.key}`)
throw new AgentSessionOptionRejectedError(
`${session.spec.agent} offers no session option named ${input.key}`
)
}
session.options.notePick(input.key)
// Bounded, and abandoned by a close or Stop: the session's queue waits on it.
await writeAcpSessionOption(session.connection, session.options, write, {
key: input.key,
agent: session.spec.agent,
timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS,
...(input.signal ? { signal: input.signal } : {})
@@ -241,113 +271,63 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
readOptions = async (input: { sessionId: string; fence: number }) =>
readAcpLiveOptions(this.live(input.sessionId))
readOptionRestoreFailures = (sessionId: string): readonly string[] =>
this.sessions.get(sessionId)?.restoreSkipped ?? []
readCommands = (sessionId: string) => {
const session = this.children.sessions.get(sessionId)
return session && isAcpReadySession(session) ? session.options.readCommands() : undefined
}
readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands()
holdsDispatch = (sessionId: string): boolean => {
const session = this.children.sessions.get(sessionId)
return session ? isAcpReadySession(session) && session.turns.running : false
}
holdsLiveProviderProcess = (sessionId: string, generation: string): boolean => {
const session = this.children.sessions.get(sessionId)
return session?.acquisitionGeneration === generation && !session.connection.exited
}
acknowledgeSessionRelease = (sessionId: string): void => {
const session = this.children.sessions.get(sessionId)
if (session?.ended) {
this.children.sessions.delete(sessionId)
}
}
stopBackgroundTasks: NonNullable<StructuredAgentSessionAdapter['stopBackgroundTasks']> = (
input
) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge)
backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id))
backgroundTaskStops: NonNullable<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
sessionId
) => acpChildStopCapabilities(this.children.sessions.get(sessionId))
closeSession = (sessionId: string): Promise<boolean> => this.close(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.close(sessionId)
releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId)
closeSession = (sessionId: string): Promise<boolean> => this.children.close(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.children.close(sessionId)
releaseAcquisition = (input: { sessionId: string }) => this.children.close(input.sessionId)
hasOwnedAcquisition = (sessionId: string): boolean =>
this.sessions.has(sessionId) || this.starts.failedSessionIds().includes(sessionId)
this.children.sessions.has(sessionId) ||
this.children.starts.failedSessionIds().includes(sessionId)
/** After a sink failure: the exit is recovered as unexpected. */
forceCloseSession = (sessionId: string): Promise<boolean> => this.stop(sessionId, false)
forceCloseSession = (sessionId: string): Promise<boolean> => this.children.stop(sessionId, false)
async closeAll(): Promise<void> {
const copiedChildren = this.copies.closeAll()
const ids = new Set([...this.sessions.keys(), ...this.starts.failedSessionIds()])
const ids = new Set([
...this.children.sessions.keys(),
...this.children.starts.failedSessionIds()
])
// A start still under way is the host's to abort: its teardown does, before this runs.
const proven = await Promise.all([...ids].map((sessionId) => this.stop(sessionId, true)))
const proven = await Promise.all(
[...ids].map((sessionId) => this.children.stop(sessionId, true))
)
if (!(await copiedChildren) || proven.includes(false)) {
throw new Error('an ACP agent child could not be proven stopped')
}
}
/** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */
private async close(sessionId: string): Promise<boolean> {
const connection = this.sessions.get(sessionId)?.connection
const closed = await this.stop(sessionId, true)
if (closed && connection?.processTreeUnproven) {
throw new AgentSessionAcquisitionRootExitObservedError(
new Error(
`${this.deps.spec.agent} ACP agent exited, but its process tree was not proven gone`
)
)
}
return closed
}
/** True only once every child is proven gone, or when this adapter runs none for the session:
* a failed start's child, and the session's own. */
private async stop(sessionId: string, requested = true): Promise<boolean> {
const [failedStart, session] = await Promise.all([
this.starts.stopFailed(sessionId),
this.stopSession(sessionId, requested)
])
return failedStart && session
}
private async stopSession(sessionId: string, requested: boolean): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session || session.ended) {
return true
}
if (requested && session.journalClosed === null) {
// A close, dispose or quit ends a running turn as a Stop does before the child goes.
session.closeRequested = true
await windDownAcpTurn(session, this.stopGraceMs())
if (session.ended) {
return true
}
}
// A connection loss already decided why the child ends; a later stop does not relabel it.
if (session.journalClosed === null) {
session.closeRequested ||= requested
session.lane.flush()
}
const proven = await session.connection.close()
if (proven) {
this.finish(session, session.exitObservedAt ?? this.now())
}
return proven
}
/** The child's exit is proven: the host hears it, and nothing of the child stays here. */
private finish(session: AcpStructuredSession, observedAt: number): void {
endAcpStructuredSession(session, observedAt, this.deps.onEvent)
if (this.sessions.get(session.sessionId) === session) {
this.sessions.delete(session.sessionId)
}
}
/** The connection broke while the child may still run: nothing more it says can be journaled, so
* the journal closes now and the child is stopped; the host hears `ended` once that is proven. */
private connectionLost(session: AcpStructuredSession | null, error: Error): void {
if (
!session ||
this.sessions.get(session.sessionId) !== session ||
session.journalClosed !== null
) {
return
}
closeAcpSessionJournal(
session,
`${session.spec.agent} ACP connection closed: ${error.message || error.name}`,
error
)
void this.stop(session.sessionId, false)
}
private live(sessionId: string): AcpStructuredSession {
const session = this.sessions.get(sessionId)
if (!session || session.journalClosed !== null) {
const session = this.children.sessions.get(sessionId)
if (!session || !isAcpReadySession(session) || session.journalClosed !== null) {
throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`)
}
return session
+6 -3
View File
@@ -20,6 +20,7 @@ import type { AcpStructuredLane } from './acp-structured-lane'
import type { AcpStructuredOptions } from './acp-structured-options'
import type { AcpStructuredPrompts } from './acp-structured-prompts'
import type { AcpStructuredTurns } from './acp-structured-turns'
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
export type AcpStructuredSession = {
sessionId: string
@@ -33,8 +34,6 @@ export type AcpStructuredSession = {
prompts: AcpStructuredPrompts
options: AcpStructuredOptions
turns: AcpStructuredTurns
/** Saved picks the agent refused when this child started. */
restoreSkipped: readonly string[]
/** Orca asked this child to stop; its exit is then a requested close. */
closeRequested: boolean
/** Why nothing this child says reaches the journal any more; null while it does. */
@@ -43,6 +42,8 @@ export type AcpStructuredSession = {
ended: boolean
exitObservedAt: number | null
unbindReadingControl?: () => void
dispose: () => void
open: (signal: AbortSignal) => Promise<StructuredAgentSessionProviderOpenResult>
}
/** Every agent frame goes through here once the lane exists: options and commands first, so a
@@ -156,7 +157,9 @@ export function endAcpStructuredSession(
}
/** Only for a send refused before its prompt was written. */
export function rejectedAcpDispatch(session: AcpStructuredSession): AgentSessionDispatchOutcome {
export function rejectedAcpDispatch(
session: Pick<AcpStructuredSession, 'spec'>
): AgentSessionDispatchOutcome {
return {
state: 'rejected',
...agentSessionFailureWords(
+30 -11
View File
@@ -1,9 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { providerDiagnosticOf } from '../../shared/agent-session-failure'
import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { AcpSignInPromptError } from './acp-errors'
import { ACP_LAUNCH_SPECS } from './acp-launch-specs'
@@ -101,11 +99,13 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn
it('starts signed out without Orca words when the connection closes for sign-in', async () => {
const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) })
const acquired = rig.acquire().catch((failure: unknown) => failure)
// Session setup runs in the start's open, which the rig awaits after the acquire.
const starting = rig.acquire()
await rig.frame('session/new')
const internal = 'The agent asked to sign in in a browser'
AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal))
const error = await acquired
await starting
const error = rig.openFailure()
// Session setup re-types sign-in answers; this close must stay Orca's, words and all.
expect(error).toMatchObject({ reason: 'notSignedIn' })
expect(providerDiagnosticOf(error)).toBeUndefined()
@@ -119,16 +119,35 @@ it.each(ACP_LAUNCH_SPECS)(
spec,
script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail))
})
const error = await rig.acquire().catch((failure: unknown) => failure)
expect(error).toBeInstanceOf(AgentSessionAcquisitionRefusal)
expect(error).toMatchObject({ reason: 'notSignedIn' })
expect(providerDiagnosticOf(error)).toEqual({ text: detail, audience: 'person' })
const fact = providerStartupFailureFact(error)
expect(fact.detail?.text).toBe(detail)
// The host receives the refusal from open and then closes the child.
await rig.acquire()
expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' })
expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' })
const fact = agentSessionFailureFact('notSignedIn', {
detail: providerDiagnosticOf(rig.openFailure())
})
expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail)
}
)
it.each(ACP_LAUNCH_SPECS)(
"keeps $agent's sign-in words over log lines it wrote before refusing its start",
async (spec) => {
const detail = 'Sign-in credentials are missing.'
const rig = await openAcpAdapterRig({
spec,
script: (agent) =>
agent.on('session/new', (frame) => {
rig.child().stderr = 'warn: telemetry endpoint unreachable'
agent.fail(frame, -32000, detail)
})
})
await rig.acquire()
expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' })
expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' })
}
)
it.each([
[
'No API key found for anthropic.\n\nUse /login, set an API key environment variable, or create /host/agent.db',
+2 -2
View File
@@ -166,7 +166,7 @@ describe('a send while a Grok prompt runs', () => {
await new Promise((resolve) => setTimeout(resolve, 120))
await rig.settle()
expect(rig.sent('session/cancel')).toHaveLength(1)
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
expect(rig.child().closes).toBe(0)
expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first'])
// A Stop sends its own cancel; the host's grace, not the cancel, ends Grok.
@@ -223,7 +223,7 @@ describe('a send while a turn Grok began itself runs', () => {
// `a` runs a whole turn: a steer's cancel never ends Grok.
await new Promise((resolve) => setTimeout(resolve, 120))
await rig.settle()
expect(rig.lifecycle).toEqual([])
expect(rig.ended).toEqual([])
expect(rig.child().closes).toBe(0)
agent.reply(a, { stopReason: 'end_turn' })
const b = await rig.frame('session/prompt', 1)
+6 -12
View File
@@ -1,14 +1,13 @@
import {
agentSessionFailureFact,
providerDiagnostic,
withProviderDiagnostic
type SubmissionRejectionFact
} from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import { BoundedMap } from '../../shared/bounded-map'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import type { AcpDialect } from './acp-dialects/acp-dialect'
import { AcpAgentError, AcpAuthRequiredError, AcpSignInPromptError } from './acp-errors'
import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
/** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */
const FAILED_STOP_REASONS = ['error', 'rate_limit']
@@ -45,18 +44,13 @@ export function acpAuthenticationRequired(dialect: AcpDialect, error: unknown):
)
}
export function acpSignInRequiredRefusal(
agent: string,
/** A sign-in refusal in the agent's own words. */
export function acpSignInRequiredFailure(
dialect: AcpDialect,
error: AcpAgentError
): AgentSessionAcquisitionRefusal {
return withProviderDiagnostic(
new AgentSessionAcquisitionRefusal(
`${agent} reported that it is not signed in: ${error.message}`,
'notSignedIn'
),
providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person')
)
): SubmissionRejectionFact {
const detail = providerDiagnostic(acpPromptErrorDetail(dialect, error), 'person')
return agentSessionFailureFact('notSignedIn', detail ? { detail } : {})
}
/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads:
@@ -287,7 +287,8 @@ async function createSurface(
agent: intent.agent,
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
...(reservedTabId ? { tabId: reservedTabId } : {})
...(reservedTabId ? { tabId: reservedTabId } : {}),
...(intent.prompt ? {} : { withoutPrompt: true as const })
})
return {
outcome: {
@@ -22,6 +22,9 @@ export type AgentLaunchSurfaceFactory = {
/** The caller-reserved tab id (the tab half of `paneKey`), so the host records where this chat
* is shown; absent records the id clients derive. */
tabId?: string
/** The launch carries no prompt, so a caller that sends the first message itself may get the
* chat at rest, its agent started by that message. */
withoutPrompt?: true
}): Promise<AgentLaunchStructuredSurface>
createTerminalAgent(args: {
worktreeId: string
@@ -44,8 +44,7 @@ function definedEnv(env: Record<string, string | undefined>): Record<string, str
*/
export function createClaudeCodeProcessSpawn(
spawnImpl: PipedProcessSpawner = spawnProcess,
platform: NodeJS.Platform = process.platform,
onOutput?: () => void
platform: NodeJS.Platform = process.platform
): ClaudeCodeProcessSpawn {
let managed: ManagedProviderProcess | null = null
return {
@@ -63,8 +62,7 @@ export function createClaudeCodeProcessSpawn(
inheritedEnv: definedEnv(options.env),
site: 'claude-stream-json-teardown',
policy: (supervised) => claudeChildClosePolicy(supervised, platform),
acceptClose: claudeChildCloseProven,
...(onOutput ? { onOutput } : {})
acceptClose: claudeChildCloseProven
}
)
// The SDK drains stderr only for its own local spawn; the managed process drains it here.
@@ -58,6 +58,9 @@ beforeEach(async () => {
closeSession = vi.fn(async () => true)
const store = await openTestAgentSessionRecordStore(root)
const adapter: StructuredAgentSessionAdapter = {
open: async () => ({
reportedOptions: { model: '' }
}),
acquire: async ({ fence, spawnToken, events }) => {
sink = events ?? null
return {
@@ -1,3 +1,4 @@
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -98,7 +99,14 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist
readOptions: async () => ({ models: [], current: { model: 'opus' } })
},
agents,
store: { getRecord: () => undefined }
store: {
getRecord: () => ({
...agentSessionRecordFixture(),
sessionId: SESSION,
provider: 'claude',
options: { model: 'opus' }
})
}
},
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
openConversation: async () => running,
@@ -558,6 +558,7 @@ describe('Claude stream-json connection', () => {
const session = {
connection,
options: new Map<string, string>(),
reusableOptions: new Map<string, string>(),
reportedOptions: {},
startup: { state: 'proven' }
} as unknown as ClaudeSession
@@ -81,8 +81,6 @@ export type ClaudeStreamJsonConnectionHandlers = {
/** The root process exited, reported once. `expected`: a close had begun, so it is that close's
* end, even one that ran out of its own escalation first and came back unproven. */
onExit?: (error: Error, exit?: { expected: boolean }) => void
/** Any stdout or stderr chunk from the child. */
onOutput?: () => void
}
/**
@@ -139,7 +137,7 @@ export async function openClaudeStreamJsonConnection(
queryImpl?: typeof ClaudeAgentSdk.query
): Promise<ClaudeStreamJsonConnection> {
const { query } = await loadClaudeAgentSdk()
const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, handlers.onOutput)
const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform)
const inbox = createClaudeUserMessageQueue()
const session = (queryImpl ?? query)({
prompt: inbox.messages,
@@ -106,7 +106,6 @@ export async function resolveClaudeAcquisitionLaunch(args: {
options: spawn.sdkOptions,
savedOptions: {
options: spawn.options,
skipped: spawn.skipped,
fastModeAtStart: spawn.fastModeAtStart
}
}
@@ -29,7 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
reusableOptions: new Map(),
launchedModel: null,
fastModeAtStart: false,
capabilities: [],
@@ -20,6 +20,7 @@ import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-
import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection'
import {
agentSessionFailureFact,
providerDiagnostic,
type SubmissionRejectionFact
} from '../../shared/agent-session-failure'
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
@@ -28,6 +29,8 @@ import {
claudeUserMessageWasProvablyUnwritten
} from './claude-agent-sdk-user-message-queue'
import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement'
import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
const MAX_ACTIVE_DISPATCH_WAITERS = 64
@@ -171,6 +174,19 @@ export async function dispatchClaudeTurn(
if (error instanceof AgentSessionPreDispatchError) {
throw error
}
if (isAgentSessionOptionRejectedError(error)) {
return {
state: 'rejected',
...claudeDispatchRejection(
agentSessionFailureFact(
'providerRejected',
error.cause instanceof ClaudeControlRequestError
? { detail: providerDiagnostic(error.message, 'person') }
: {}
)
)
}
}
return { state: 'rejected', ...claudeWriteFailureRejection(error) }
}
const waiter = replay.waiter
@@ -17,7 +17,6 @@ import type { StructuredAgentSessionMutationContext } from '../native-chat/agent
import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog'
import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionEvent
@@ -418,10 +417,7 @@ describe('Claude effort default at rest', () => {
const reported = (await claudeOpened(adapter, SESSION)).reportedOptions
expect(reported).not.toHaveProperty('effort')
// The record the start persists names the listed row the catalog learned under.
const record = restingRecord(
nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] })
)
const record = restingRecord({ model: 'opus[1m]' })
expect(record.options).toEqual({ model: 'opus[1m]' })
expect((await readAtRest(store, record)).current.effort).toBe('medium')
expect(record.options).toEqual({ model: 'opus[1m]' })
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { setClaudeStructuredOption } from './claude-structured-options'
import { readClaudeSettingsEffort } from './claude-structured-session-options'
import {
readClaudeStructuredSessionOptions,
readClaudeSettingsEffort
} from './claude-structured-session-options'
import type { ClaudeSession } from './claude-structured-session-state'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter'
import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support'
@@ -26,7 +29,7 @@ function sessionWith(
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
restoreSkippedOptions: new Set<string>(),
reusableOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -199,6 +202,8 @@ describe('Claude effort against the model that must run it', () => {
setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined)
).resolves.toEqual({ model: 'sonnet', effort: 'max' })
expect(calls).toEqual(['list_models', 'apply:max'])
await readClaudeStructuredSessionOptions(session, undefined)
expect(session.reusableOptions.get('effort')).toBe('max')
expect(session.confirmedOptions.has('effort')).toBe(false)
})
@@ -0,0 +1,233 @@
import { describe, expect, it, vi } from 'vitest'
import {
acquired,
claudeFrame,
fakeClaude,
USER_MESSAGE
} from './claude-structured-session-test-support'
import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
const MODELS = [
{ value: 'default', resolvedModel: 'claude-sonnet-5' },
{
value: 'sonnet',
resolvedModel: 'claude-sonnet-5',
supportsEffort: true,
supportedEffortLevels: ['low', 'high']
},
{
value: 'opus',
resolvedModel: 'claude-opus-5',
supportsEffort: true,
supportedEffortLevels: ['low', 'high']
}
]
describe('Claude accepted message choices', () => {
it('applies a held message A before its prompt, then message B on the existing child', async () => {
const order: string[] = []
let currentEffort = 'high'
const claude = fakeClaude({
routes: {
list_models: () => MODELS,
set_model: ({ model } = {}) => order.push(`model:${String(model)}`),
apply_flag_settings: ({ settings } = {}) => {
order.push(`effort:${JSON.stringify(settings)}`)
if (
typeof settings === 'object' &&
settings !== null &&
'effortLevel' in settings &&
typeof settings.effortLevel === 'string'
) {
currentEffort = settings.effortLevel
}
}
}
})
const adapter = await acquired(claude)
const connection = claude.connections[0]!
connection.getSettings = async () => ({ effective: { effortLevel: currentEffort } })
const send = connection.send
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
let entered!: () => void
const enteredSend = new Promise<void>((resolve) => {
entered = resolve
})
let first = true
connection.send = async (message, beforeDispatch) => {
if (first) {
first = false
entered()
await held
}
const result = await send(message, beforeDispatch)
order.push('prompt')
return result
}
const a = adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'A',
body: USER_MESSAGE,
executionOptions: { model: 'sonnet', effort: 'low' },
fence: 7
})
await enteredSend
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 })
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
order.length = 0
release()
await expect(a).resolves.toEqual({ state: 'admitted' })
expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt'])
order.length = 0
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'B',
body: USER_MESSAGE,
executionOptions: { model: 'opus', effort: 'high' },
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt'])
})
it('restores A after an applied unanswered B and an uncorrelated old init', async () => {
let actual: string | undefined = 'sonnet'
const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } })
const adapter = await acquired(claude)
const connection = claude.connections[0]!
connection.setModel = async (model) => {
actual = model
if (model === 'opus') {
throw new Error('answer lost')
}
}
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 })
).rejects.toThrow('answer lost')
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'sonnet' })
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'restore',
body: USER_MESSAGE,
executionOptions: { model: 'sonnet' },
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(actual).toBe('sonnet')
expect(connection.sent).toHaveLength(1)
})
it('keeps saved Fast for an older partial send, freezes it, and honors explicit false', async () => {
let fastMode = true
const supported = MODELS.map((model) => ({ ...model, supportsFastMode: true }))
const claude = fakeClaude({
initModels: supported,
routes: {
list_models: () => supported,
apply_flag_settings: ({ settings } = {}) => {
if (
typeof settings === 'object' &&
settings !== null &&
'fastMode' in settings &&
typeof settings.fastMode === 'boolean'
) {
fastMode = settings.fastMode
}
}
}
})
const adapter = await acquired(claude)
const saved = { model: 'sonnet', effort: 'high', fastMode: 'true' }
const ctx = { agents: claudeAndCodexAgents(), agent: 'claude', persistedOptions: saved }
const captured = captureStructuredExecutionOptions(ctx, { model: 'sonnet', effort: 'high' })
if ('refusal' in captured) {
throw new Error(captured.refusal.message)
}
saved.fastMode = 'false'
const connection = claude.connections[0]!
connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } })
for (const [id, options] of [
['old', captured.options],
['off', { ...captured.options, fastMode: 'false' }]
] as const) {
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: id,
body: USER_MESSAGE,
executionOptions: options,
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
}
expect(captured.options.fastMode).toBe('true')
expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual(
expect.arrayContaining([
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } },
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } }
])
)
expect(connection.sent).toHaveLength(2)
})
it('restores the configured model and effort for an empty snapshot', async () => {
let currentEffort = 'high'
const claude = fakeClaude({
routes: {
list_models: () => MODELS,
apply_flag_settings: ({ settings } = {}) => {
if (
typeof settings === 'object' &&
settings !== null &&
'effortLevel' in settings &&
typeof settings.effortLevel === 'string'
) {
currentEffort = settings.effortLevel
}
}
}
})
const events: ClaudeStructuredSessionEvent[] = []
const adapter = await acquired(claude, {}, events)
await vi.waitFor(() =>
expect(events).toContainEqual(expect.objectContaining({ type: 'options-reported' }))
)
claude.connections[0]!.getSettings = async () => ({ effective: { effortLevel: currentEffort } })
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'A',
body: USER_MESSAGE,
executionOptions: { model: 'opus', effort: 'low' },
fence: 7
})
const connection = claude.connections[0]!
claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' })
connection.calls.length = 0
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'default',
body: USER_MESSAGE,
executionOptions: {},
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(
connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype))
).toEqual([
{ subtype: 'set_model', params: { model: 'claude-sonnet-5' } },
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }
])
expect(connection.sent).toHaveLength(2)
})
})
@@ -0,0 +1,100 @@
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import { listedModels } from './claude-structured-model-catalog'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
/** Applies the accepted message's choices while its prompt is still in the SDK input queue. */
export async function applyClaudeMessageOptions(
session: ClaudeSession,
options: AgentSessionExecutionOptions,
timeoutMs: number | undefined,
signal?: AbortSignal
): Promise<void> {
throwIfSignalAborted(signal)
const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal))
if (model && session.reusableOptions.get('model') !== model) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs),
signal
)
}
throwIfSignalAborted(signal)
const effort = options.effort ?? session.configuredDefaults?.effort
if (effort && session.reusableOptions.get('effort') !== effort) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs),
signal
)
} else if (!effort && session.options.has('effort')) {
throw new AgentSessionOptionRejectedError('Claude did not report its configured effort')
}
throwIfSignalAborted(signal)
const fastMode = options.fastMode
if (fastMode !== undefined && session.reusableOptions.get('fastMode') !== fastMode) {
await waitForPromiseWithSignal(
setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs),
signal
)
}
for (const [key, value] of Object.entries({ model, effort, fastMode })) {
if (value !== undefined && session.reusableOptions.get(key) !== value) {
throw new AgentSessionOptionRejectedError(`Claude did not apply ${key} ${value}`)
}
}
throwIfSignalAborted(signal)
}
async function defaultModelForMessage(
session: ClaudeSession,
timeoutMs: number | undefined,
signal?: AbortSignal
): Promise<string | undefined> {
if (session.configuredDefaults?.model) {
return session.configuredDefaults.model
}
if (!session.options.has('model')) {
return undefined
}
const response = await waitForPromiseWithSignal(
session.connection.supportedModels({ timeoutMs }),
signal
).catch(() => null)
throwIfSignalAborted(signal)
const listed = listedModels(response ? { models: response } : null)
const fallback = listed.find((model) => model.isDefault)?.id
if (!fallback) {
throw new AgentSessionOptionRejectedError('Claude did not report its default model')
}
return fallback
}
export function dispatchClaudeMessage(
session: ClaudeSession,
input: Parameters<StructuredAgentSessionAdapter['dispatch']>[0],
timeoutMs: number | undefined
): ReturnType<StructuredAgentSessionAdapter['dispatch']> {
const messageOptions = input.executionOptions
return dispatchClaudeTurn(
session,
input,
messageOptions === undefined
? input.beforeDispatch
: async () => {
await input.beforeDispatch?.()
try {
await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal)
input.signal?.throwIfAborted()
} catch (error) {
throw error instanceof AgentSessionOptionRejectedError
? error
: new AgentSessionOptionRejectedError(error)
}
}
)
}
@@ -186,6 +186,7 @@ describe('Claude effort the settings readback cannot report', () => {
reportedOptions: {},
optionMutationSequence: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -22,7 +22,7 @@ function sessionWith(catalog: readonly Record<string, unknown>[] | 'unavailable'
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
restoreSkippedOptions: new Set<string>(),
reusableOptions: new Map(),
connection: {
supportedModels: async () => {
calls.push('list_models')
@@ -168,6 +168,7 @@ describe('confirmation never outlives the write it belongs to', () => {
reportedOptions: {},
optionMutationSequence: 0,
confirmedOptions: new Set<string>(),
reusableOptions: new Map(),
connection: {
supportedModels: async () => CATALOG,
applyFlagSettings: async (s: { effortLevel?: string }) => {
@@ -36,7 +36,6 @@ describe('the context ring after a session option write', () => {
const s = ringSession()
adoptClaudeStructuredSpawnOptions(s.session, {
options: new Map([['model', 'opus[1m]']]),
skipped: [],
fastModeAtStart: false
})
expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 })
@@ -38,7 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
reusableOptions: new Map(),
launchedModel: null,
fastModeAtStart: false,
capabilities: [],
@@ -346,6 +346,8 @@ describe('Claude structured Fast mode', () => {
).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' })
expect(session.reportedOptions.fastMode).toBe(true)
expect(session.confirmedOptions.has('fastMode')).toBe(false)
expect(session.reusableOptions.get('model')).toBe('haiku')
expect(session.reusableOptions.has('fastMode')).toBe(false)
})
it('treats an unrecognized provider disabled reason as unavailable', async () => {
@@ -131,6 +131,11 @@ export async function setClaudeStructuredOption(
? claudeModelFastModeSupport(session, listed, input.value)
: null
const modelWasConfirmed = readClaudeCurrentModel(session).confirmed
session.reusableOptions.delete(input.key)
if (input.key === 'model') {
session.reusableOptions.delete('effort')
session.reusableOptions.delete('fastMode')
}
const mutationSequence = ++session.optionMutationSequence
// Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it.
const held = session.options.get(input.key)
@@ -160,6 +165,7 @@ export async function setClaudeStructuredOption(
if (mutationSequence !== session.optionMutationSequence) {
return Object.fromEntries(session.options)
}
session.reusableOptions.set('model', input.value)
session.options.set('model', input.value)
session.options.set('fastMode', 'false')
session.confirmedOptions.delete('effort')
@@ -190,6 +196,11 @@ export async function setClaudeStructuredOption(
if (mutationSequence !== session.optionMutationSequence) {
return Object.fromEntries(session.options)
}
if (input.key === 'model' || (input.key === 'effort' && UNREPORTED_EFFORTS.has(input.value))) {
session.reusableOptions.set(input.key, input.value)
} else if (adopted !== null) {
session.reusableOptions.set(input.key, String(adopted))
}
if (input.key === 'fastMode' && typeof adopted === 'boolean') {
session.reportedOptions.fastMode = adopted
}
@@ -41,8 +41,7 @@ function modelNotFound(parentToolUseId: string | null = null): Record<string, un
function sessionLaunchedWith(model: string | null, picked: string | null = model) {
return {
launchedModel: model,
options: new Map(picked === null ? [] : [['model', picked]]),
restoreSkippedOptions: new Set<string>()
options: new Map(picked === null ? [] : [['model', picked]])
}
}
@@ -52,7 +51,6 @@ describe("the CLI's word that a launched model does not exist", () => {
expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED)
expect(session.options.has('model')).toBe(false)
expect([...session.restoreSkippedOptions]).toEqual(['model'])
expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull()
})
@@ -63,7 +61,6 @@ describe("the CLI's word that a launched model does not exist", () => {
['a launch that named no model', sessionLaunchedWith(null), modelNotFound()]
])('keeps the saved model for %s', (_case, session, message) => {
expect(retireClaudeLaunchedModel(session, message)).toBeNull()
expect([...session.restoreSkippedOptions]).toEqual([])
})
})
@@ -8,9 +8,9 @@ import type {
} from './claude-structured-session-state'
/** The launched model, once, when a root reply says it does not exist and the chat still has it
* picked: the session stops holding it, so the record drops it. Null otherwise. */
* picked: the live session stops holding it. Saved picks remain the user's. */
export function retireClaudeLaunchedModel(
session: Pick<ClaudeSession, 'launchedModel' | 'options' | 'restoreSkippedOptions'>,
session: Pick<ClaudeSession, 'launchedModel' | 'options' | 'retiredModel'>,
message: Record<string, unknown>
): string | null {
const launched = session.launchedModel
@@ -24,7 +24,7 @@ export function retireClaudeLaunchedModel(
return null
}
session.options.delete('model')
session.restoreSkippedOptions.add('model')
session.retiredModel = launched
return launched
}
@@ -97,6 +97,14 @@ export async function acquireClaudeSession({
// running; set_model answers success for a model it never resolves, so this
// report is the session's only adoption evidence.
if (liveSession && init.model) {
if (liveSession.reusableOptions.get('model') !== init.model) {
liveSession.reusableOptions.delete('model')
liveSession.reusableOptions.delete('effort')
liveSession.reusableOptions.delete('fastMode')
}
if (liveSession.optionMutationSequence === 0) {
liveSession.reusableOptions.set('model', init.model)
}
liveSession.reportedOptions.model = init.model
liveSession.reportedModelMutation = liveSession.optionMutationSequence
}
@@ -189,7 +197,6 @@ export async function acquireClaudeSession({
onMessage,
canUseTool,
onUserDialog,
...(input.onOutput ? { onOutput: input.onOutput } : {}),
onFault: (error) => {
childEnded ??= error
initProof.reject(error)
@@ -277,7 +284,7 @@ export async function acquireClaudeSession({
)
}
// The host calls open after publication and holds input until it settles.
return { ...publication.acquisition, providerChildPhase: 'starting' }
return publication.acquisition
} catch (error) {
unbindReadingControl?.()
const acquisitionError = await resolveClaudeAcquisitionError({
@@ -12,6 +12,25 @@ import {
} from './claude-structured-session-test-support'
describe('ClaudeStructuredSessionAdapter turns and controls', () => {
it('applies a sent model and effort before handing Claude the message', async () => {
const claude = fakeClaude({ replayUuid: 'chosen-uuid' })
const adapter = await acquired(claude)
const connection = claude.connections[0]!
const before = connection.calls.length
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'chosen-send',
body: USER_MESSAGE,
fence: 7,
executionOptions: { model: 'sonnet', effort: 'high' }
})
).resolves.toEqual({ state: 'admitted' })
const controls = connection.calls.slice(before).map((call) => call.subtype)
expect(controls.indexOf('set_model')).toBeGreaterThan(-1)
expect(controls.indexOf('apply_flag_settings')).toBeGreaterThan(controls.indexOf('set_model'))
})
it("admits a dispatch on the write and names it from Claude's replay", async () => {
const claude = fakeClaude({ replayUuid: 'user-provider-uuid' })
const settled = vi.fn()
@@ -7,10 +7,6 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import {
ClaudeControlRequestError,
ClaudeControlRequestTimeoutError
} from './claude-agent-sdk-control-requests'
import type {
ClaudeStructuredSessionAdapter,
ClaudeStructuredSessionEvent
@@ -129,89 +125,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
expect(
claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings')
).toEqual([])
// Left out, not refused: the record takes the provider's own Fast, as before.
expect(adapter.readOptionRestoreFailures('session-1')).toEqual([])
expect(events.find((event) => event.type === 'options-reported')).toMatchObject({
reportedOptions: { fastMode: false }
})
})
// No message waits on it: the launch carries no Fast, and the start applies it once the CLI's
// settings show no per-session opt-in, as the live CLI reports with no settings file at all.
it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => {
const claude = fakeClaude({
settings: { applied: {}, effective: {}, sources: {} },
initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }]
})
const events: ClaudeStructuredSessionEvent[] = []
const adapter = adapterFor(claude, {}, events)
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
options: { model: 'opus', fastMode: 'true' }
})
expect(claude.connections[0].launch.options.settings).toBeUndefined()
expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([
'initialize',
'get_settings',
'apply_flag_settings'
])
expect(claude.connections[0].calls.at(-1)).toEqual({
subtype: 'apply_flag_settings',
params: { settings: { fastMode: true } }
})
expect(await claudeOpened(adapter, 'session-1')).toMatchObject({
reportedOptions: { fastMode: true }
})
})
it.each([
['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false],
['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true]
] as const)(
'settles a saved Fast on a new conversation when the CLI %s its apply',
async (_case, failure, kept) => {
let answerApply: (error: Error) => void = () => {}
const claude = fakeClaude({
settings: { applied: {}, effective: {}, sources: {} },
initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }],
routes: {
apply_flag_settings: () => new Promise<never>((_, reject) => (answerApply = reject))
}
})
const events: ClaudeStructuredSessionEvent[] = []
const adapter = adapterFor(claude, {}, events)
await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn-9',
options: { model: 'opus', fastMode: 'true' }
})
// Started, with the pick, while the apply is still unanswered: nothing waits on it.
expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings')
expect(await claudeOpened(adapter, 'session-1')).toMatchObject({
reportedOptions: { fastMode: true }
})
answerApply(failure)
await tick()
// A refusal drops the child's value; silence keeps it wanted and unconfirmed.
expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode'])
const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 })
expect(options.current.fastMode === true).toBe(kept)
expect(options.current.confirmed ?? []).not.toContain('fastMode')
}
)
it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => {
it('leaves Fast for message input when the launch does not carry it', async () => {
const claude = fakeClaude({
settings: { applied: {}, effective: {}, sources: {} },
initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }]
initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }]
})
const adapter = adapterFor(claude)
@@ -221,15 +143,16 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
spawnToken: 'spawn-9',
options: { model: 'opus', fastMode: 'true' }
})
await claudeOpened(adapter, 'session-1')
expect(claude.connections[0].launch.options.settings).toBeUndefined()
expect(
claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings')
).toEqual([])
expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode'])
})
// The chat's permission mode is the start's, never a saved option the CLI is handed.
it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => {
it('omits an invalid persisted effort at launch and uses the explicit start mode', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude)
const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch')
@@ -237,7 +160,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => {
const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }
await adapter.acquire({ ...start, permissionMode: 'accept-edits' })
expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort'])
expect(claude.connections[0].launch.options).not.toHaveProperty('effort')
expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits')
})
@@ -7,12 +7,12 @@ import type {
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import { claudeHoldsDispatch } from './claude-command-lifecycle'
import { releaseClaudeAcquisition } from './claude-structured-acquisition-release'
import { acquireClaudeSession } from './claude-structured-session-acquisition'
import { supportsClaudeStructuredLocation } from './claude-structured-location-support'
import { setClaudeStructuredSessionOption } from './claude-structured-options'
import { dispatchClaudeMessage } from './claude-structured-message-options'
import { readClaudeStructuredSessionOptions } from './claude-structured-session-options'
import {
ClaudeAcquisitionRegistry,
@@ -164,7 +164,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch)
dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) =>
dispatchClaudeCommand(this.session(input.sessionId), input.command)
@@ -256,9 +256,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}
readOptions = (input: { sessionId: string; fence: number }) =>
readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
readOptionRestoreFailures = (sessionId: string): readonly string[] => [
...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? [])
]
readOptionRestoreFailures = (sessionId: string): readonly string[] => {
const session = this.sessions.get(sessionId)
return session?.retiredModel !== undefined && !session.options.has('model') ? ['model'] : []
}
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId))
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands'
import {
adapterFor,
fakeClaude,
@@ -113,3 +115,18 @@ it('keeps a buffered stream catalog newer than the initialization response', asy
await adapter.closeSession('session-1')
}
})
it("answers a resting chat's `/` menu only for chats this host runs", async () => {
const listed = [{ name: 'review-pr', kind: 'skill' as const }]
const catalog = new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => '/work',
probe: async () => listed
})
const local = agentSessionRecordFixture()
catalog.read(local)
await vi.waitFor(() => expect(catalog.read(local)).toEqual(listed))
// Same workspace and account on another host: only that host can read its folders.
const remote = { ...local, location: { ...local.location, executionHostId: 'ssh:box' as const } }
expect(catalog.read(remote)).toBeUndefined()
})
@@ -53,6 +53,22 @@ export function observeClaudeSettingsApplied(session: ClaudeSession, settings: u
}
}
/** Applied effort describes execution; effective effort describes the underlying settings. */
export function observeClaudeSettingsExecutionEffort(
session: ClaudeSession,
settings: unknown
): void {
const applied = readClaudeSettingsApplied(settings)
const effort = applied ? applied.effort : readClaudeSettingsEffort(settings)
if (session.reusableOptions.get('effort') !== 'max') {
if (effort) {
session.reusableOptions.set('effort', effort)
} else if (applied) {
session.reusableOptions.delete('effort')
}
}
}
export function readClaudeSettingsFastMode(settings: unknown): boolean | null {
const value = record(record(settings)?.effective)?.fastMode
return typeof value === 'boolean' ? value : null
@@ -236,10 +252,12 @@ function observeClaudeSettingsReadback(
const fastMode = readClaudeSettingsFastMode(settings)
const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings)
observeClaudeSettingsApplied(session, settings)
observeClaudeSettingsExecutionEffort(session, settings)
if (effort) {
session.reportedOptions.effort = effort
}
if (fastMode !== null) {
session.reusableOptions.set('fastMode', String(fastMode))
session.reportedOptions.fastMode = fastMode
if (decodedFastMode(session) !== undefined) {
session.options.set('fastMode', String(fastMode))
@@ -293,6 +311,7 @@ export function claudeStructuredSessionOptionsFrom(
readMutationSequence === session.optionMutationSequence
) {
session.options.set('fastMode', 'false')
session.reusableOptions.delete('fastMode')
session.confirmedOptions.delete('fastMode')
desiredFastMode = false
}
@@ -63,7 +63,10 @@ export function createClaudeSessionPublication(input: {
reportedOptions: {},
reportedModelMutation: 0,
confirmedOptions: new Set(),
restoreSkippedOptions: new Set(),
// Only choices carried in this child's launch can skip their first write.
reusableOptions: new Map(
[...(input.options ?? [])].filter(([key]) => key === 'model' || key === 'effort')
),
launchedModel: null,
fastModeAtStart: false,
translator: input.translator,
@@ -3,9 +3,11 @@ import {
adapterAtPublishFor,
fakeClaude,
identityFor,
claudeOpened
claudeOpened,
USER_MESSAGE
} from './claude-structured-session-test-support'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import { CLAUDE_INITIALIZE_TIMEOUT_MS } from './claude-structured-session-startup'
const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }
@@ -22,7 +24,7 @@ describe('Claude host-awaited open', () => {
const claude = fakeClaude({ initDelayMs: 12_000 })
const { adapter, events } = adapterFor(claude)
const acquisition = await adapter.acquire(ACQUIRE)
expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } })
expect(acquisition).toMatchObject({ process: { pid: 4321 } })
expect(acquisition.link).toBeUndefined()
expect(claude.connections[0].calls).toEqual([])
expect(events.some((event) => event.type === 'options')).toBe(false)
@@ -60,6 +62,47 @@ describe('Claude host-awaited open', () => {
await adapter.closeAll()
})
it('applies a Fast choice at message input when launch settings cannot carry it', async () => {
let fastMode = false
const claude = fakeClaude({
initModels: [{ value: 'claude-sonnet-5', displayName: 'Sonnet', supportsFastMode: true }],
routes: {
apply_flag_settings: () => {
fastMode = true
}
}
})
const events: ClaudeStructuredSessionEvent[] = []
const adapter = adapterAtPublishFor(
claude,
{ options: { extraArgs: { settings: '/repo/claude.json' } } },
events
)
await adapter.acquire({ ...ACQUIRE, options: { fastMode: 'true' } })
await claudeOpened(adapter, 'session-1')
await vi.waitFor(() =>
expect(events.some((event) => event.type === 'options-reported')).toBe(true)
)
const connection = claude.connections[0]!
expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([])
connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } })
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'fast-choice',
body: USER_MESSAGE,
executionOptions: { fastMode: 'true' },
fence: 7
})
).resolves.toEqual({ state: 'admitted' })
expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([
{ subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } },
{ subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }
])
expect(connection.sent).toHaveLength(1)
await adapter.closeAll()
})
it('rejects an advertised signed-out initialize answer for the host to close once', async () => {
const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } })
const { adapter } = adapterFor(claude)
@@ -79,6 +122,19 @@ describe('Claude host-awaited open', () => {
expect(claude.connections[0].closeCount).toBe(1)
})
it('fails open when initialize does not answer within its own bound', async () => {
const claude = fakeClaude({ initDelayMs: 10 * 60_000 })
const { adapter } = adapterFor(claude)
await adapter.acquire(ACQUIRE)
const open = expect(claudeOpened(adapter, 'session-1')).rejects.toThrow(
'did not answer initialize'
)
await vi.advanceTimersByTimeAsync(CLAUDE_INITIALIZE_TIMEOUT_MS)
await open
await adapter.closeSession('session-1')
expect(claude.connections[0].closeCount).toBe(1)
})
it('lets the host abort a silent initialize wait and close the owned process', async () => {
const claude = fakeClaude({ initDelayMs: 12_000 })
const { adapter } = adapterFor(claude)
@@ -87,7 +143,7 @@ describe('Claude host-awaited open', () => {
const open = adapter.open({
sessionId: 'session-1',
fence: 7,
acquisitionGeneration: acquisition.acquisitionGeneration ?? null,
acquisitionGeneration: acquisition.acquisitionGeneration,
signal: controller.signal
})
controller.abort(new Error('host start expired'))
@@ -1,5 +1,5 @@
// Claude initialize is awaited by the host after process publication. Settings enrichment follows
// independently; the host owns a failed open and its single close.
// Claude initialize is awaited by the host after process publication, under its own bound. Settings
// enrichment follows independently; the host owns a failed open and its single close.
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract'
@@ -21,6 +21,8 @@ import {
claudeResolvedCatalogListing,
claudeStructuredSessionOptionsFrom,
observeClaudeSettingsApplied,
observeClaudeSettingsExecutionEffort,
readClaudeSettingsApplied,
readClaudeFastModeFacts,
readClaudeSettingsEffort
} from './claude-structured-session-options'
@@ -29,10 +31,6 @@ import type {
ClaudeSession,
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
import {
admitClaudeStartFastMode,
applyClaudeStartFastMode
} from './claude-structured-start-fast-mode'
export type ClaudeOpenFacts = Pick<
StructuredAgentSessionProviderOpenResult,
@@ -59,7 +57,10 @@ export type ClaudeStartupFacts = ClaudeInitializeFacts & {
prepared: ReturnType<typeof prepareClaudeStructuredSessionAcquisitionOptions>
}
/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */
/** The CLI's initialize answer is a start step like any other: past this the start fails. */
export const CLAUDE_INITIALIZE_TIMEOUT_MS = 60_000
/** Settles on the CLI's initialize answer, or on its exit, a refused proof or the bound. */
export async function readClaudeStartupFacts(input: {
connection: ClaudeStreamJsonConnection
initProof: ClaudeInitProof
@@ -70,7 +71,7 @@ export async function readClaudeStartupFacts(input: {
requestTimeoutMs: number | undefined
emit: (event: ClaudeStructuredSessionEvent) => void
}): Promise<ClaudeInitializeFacts> {
// The CLI's first answer has no request deadline of its own; the reads after it do.
let timer: ReturnType<typeof setTimeout> | undefined
const initialization = await Promise.race([
input.connection
.initializationResult()
@@ -86,8 +87,18 @@ export async function readClaudeStartupFacts(input: {
}
return result
}),
input.initProof.promise.then(() => new Promise<never>(() => {}))
])
input.initProof.promise.then(() => new Promise<never>(() => {})),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() =>
reject(
new Error(`claude did not answer initialize within ${CLAUDE_INITIALIZE_TIMEOUT_MS}ms`)
),
CLAUDE_INITIALIZE_TIMEOUT_MS
)
timer.unref?.()
})
]).finally(() => clearTimeout(timer))
if (input.connection.closed) {
throw new Error('claude session closed before startup completed')
}
@@ -109,6 +120,13 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial
const { init, initialization } = facts
// A turn's own init frame may already have reported the running model.
if (init?.model && session.reportedOptions.model === undefined) {
if (session.optionMutationSequence === 0) {
session.reusableOptions.set('model', init.model)
} else if (session.reusableOptions.get('model') !== init.model) {
session.reusableOptions.delete('model')
session.reusableOptions.delete('effort')
session.reusableOptions.delete('fastMode')
}
session.reportedOptions.model = init.model
session.reportedModelMutation = session.optionMutationSequence
}
@@ -132,6 +150,13 @@ function applyClaudeSettingsFacts(
const { settings, prepared } = facts
const effort = readClaudeSettingsEffort(settings)
const published = claudeStructuredSessionPublicationOptions(prepared)
if (!writtenSinceRead) {
const applied = readClaudeSettingsApplied(settings)
session.configuredDefaults = {
...(!session.options.has('model') && applied?.model ? { model: applied.model } : {}),
...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {})
}
}
observeClaudeSettingsApplied(session, settings)
if (published.fastModePerSessionOptIn !== null) {
session.fastModePerSessionOptIn = published.fastModePerSessionOptIn
@@ -140,6 +165,7 @@ function applyClaudeSettingsFacts(
session.events?.publish()
return
}
observeClaudeSettingsExecutionEffort(session, settings)
// The readback vouches for a value the child was launched with only when it reports that value.
const agrees = (key: string, reported: string): boolean =>
!session.options.has(key) || session.options.get(key) === reported
@@ -155,6 +181,7 @@ function applyClaudeSettingsFacts(
session.confirmedOptions.add('effort')
}
if (published.fastMode !== null) {
session.reusableOptions.set('fastMode', String(published.fastMode))
session.reportedOptions.fastMode = published.fastMode
if (agrees('fastMode', String(published.fastMode))) {
session.confirmedOptions.add('fastMode')
@@ -245,7 +272,6 @@ async function settleClaudeStartupSettings(
input.diagnose(claudeAuthDiagnostic(facts.initialization, facts.init, settings))
const writtenSinceRead = sequence !== session.optionMutationSequence
applyClaudeSettingsFacts(session, facts, writtenSinceRead)
const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts)
input.report({
type: 'options-reported',
// Open already returned the initialize listing to the host.
@@ -254,7 +280,4 @@ async function settleClaudeStartupSettings(
// The one report of what the config resolved: the readback just applied it.
...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization))
})
if (startFastMode !== null) {
void applyClaudeStartFastMode(session, facts, startFastMode)
}
}
@@ -168,6 +168,8 @@ export type ClaudeSession = {
/** What `get_settings` says the next request will send, after Claude's own env and settings
* precedence: the lowest-ranked answer, unconfirmed until a turn reports it. */
appliedOptions?: { model?: string; effort?: string }
/** CLI settings resolved before a turn changed them, when no launch override hid that value. */
configuredDefaults?: { model?: string; effort?: string }
fastModeState?: AgentSessionFastModeState
fastModeDisabledReason?: string
fastModePerSessionOptIn?: boolean
@@ -176,10 +178,13 @@ export type ClaudeSession = {
reportedModelMutation: number
/** Options whose recorded value the provider reported, not merely accepted. */
confirmedOptions: Set<string>
restoreSkippedOptions: Set<string>
/** Value-bound execution evidence, revoked before any possible write. */
reusableOptions: Map<string, string>
/** The model the child was launched with, as `--model`; null when it runs the CLI's own. */
launchedModel: string | null
/** The launch left the saved Fast out, so the start applies it once the settings are read. */
/** A launched model the CLI answered `model_not_found` for; reported until a new pick. */
retiredModel?: string
/** Fast was omitted from launch settings and needs confirmation at message input. */
fastModeAtStart: boolean
/** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */
capabilities: readonly string[]
@@ -44,7 +44,6 @@ describe('a Claude chat launched with its saved options', () => {
effort: 'high',
fastMode: 'true'
})
expect(spawn.skipped).toEqual([])
})
// A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer
@@ -55,7 +54,6 @@ describe('a Claude chat launched with its saved options', () => {
const spawn = launched({ model, effort: 'xhigh' })
expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' })
expect(spawn.skipped).toEqual([])
}
)
@@ -64,7 +62,6 @@ describe('a Claude chat launched with its saved options', () => {
expect(spawn.sdkOptions).not.toHaveProperty('effort')
expect(spawn.sdkOptions).not.toHaveProperty('settings')
expect(spawn.skipped).toEqual(['effort', 'fastMode'])
})
it('keeps a saved Fast on for a new conversation, for its start to apply', () => {
@@ -73,7 +70,6 @@ describe('a Claude chat launched with its saved options', () => {
expect(spawn.sdkOptions).not.toHaveProperty('settings')
expect(spawn.options.get('fastMode')).toBe('true')
expect(spawn.fastModeAtStart).toBe(true)
expect(spawn.skipped).toEqual([])
})
it('passes a saved Fast off to a new conversation', () => {
@@ -128,7 +124,6 @@ describe('a Claude chat launched with its saved options', () => {
expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH)
expect(spawn.options.size).toBe(0)
expect(spawn.skipped).toEqual([])
}
)
})
@@ -1,6 +1,4 @@
// A chat's saved options as the Claude child's launch options. The child starts already running
// them, so its first message can be written at once instead of waiting for initialize to answer and
// a control request to apply each one.
// Saved choices seed the CLI launch; message choices are applied again before input.
import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
@@ -39,8 +37,6 @@ export type ClaudeStructuredSpawnOptions = {
sdkOptions: ClaudeStructuredSdkOptions
/** The chat's options as the session holds them: what was launched, and a Fast the start applies. */
options: Map<string, string>
/** Saved options left out: the provider's own value wins and is re-persisted. */
skipped: readonly string[]
/** A saved Fast the launch does not carry, which the start applies. */
fastModeAtStart: boolean
}
@@ -56,7 +52,6 @@ export function claudeStructuredSpawnOptions(input: {
}): ClaudeStructuredSpawnOptions {
const saved = restoredClaudeStructuredSessionOptions(input.saved)
const options = new Map<string, string>()
const skipped: string[] = []
let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options }
let fastModeAtStart = false
const model = saved.get('model')
@@ -69,20 +64,15 @@ export function claudeStructuredSpawnOptions(input: {
if (isEffortLevel(effort)) {
options.set('effort', effort)
sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort }
} else {
skipped.push('effort')
}
}
const fastMode = saved.get('fastMode')
if (fastMode !== undefined) {
const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode)
if (typeof decoded !== 'boolean') {
skipped.push('fastMode')
} else {
if (typeof decoded === 'boolean') {
options.set('fastMode', fastMode)
// A new CLI session may opt in to Fast per session, which only its settings say, and the
// agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the
// start applies the saved Fast once it has read them (`applyClaudeStartFastMode`).
// A new CLI session may opt in to Fast per session; the first message applies a saved Fast
// when launch settings cannot carry it.
if (
(!decoded || input.launch.resumesTranscript) &&
!Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings')
@@ -93,22 +83,16 @@ export function claudeStructuredSpawnOptions(input: {
}
}
}
return { sdkOptions, options, skipped, fastModeAtStart }
return { sdkOptions, options, fastModeAtStart }
}
/** The published session takes on what its child was launched with. */
export function adoptClaudeStructuredSpawnOptions(
session: Pick<
ClaudeSession,
'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart'
>,
spawn: Pick<ClaudeStructuredSpawnOptions, 'options' | 'skipped' | 'fastModeAtStart'>
session: Pick<ClaudeSession, 'translator' | 'launchedModel' | 'fastModeAtStart'>,
spawn: Pick<ClaudeStructuredSpawnOptions, 'options' | 'fastModeAtStart'>
): void {
session.launchedModel = spawn.options.get('model') ?? null
session.fastModeAtStart = spawn.fastModeAtStart
for (const key of spawn.skipped) {
session.restoreSkippedOptions.add(key)
}
const model = spawn.options.get('model')
if (model !== undefined) {
session.translator?.modelWritten(model)
@@ -1,11 +1,23 @@
// A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the
// start once the child answers.
// message input once the child answers.
import { expect, it, vi } from 'vitest'
import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support'
import {
adapterFor,
fakeClaude,
identityFor,
claudeOpened,
USER_MESSAGE
} from './claude-structured-session-test-support'
it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => {
it("applies a resumed conversation's saved Fast off before message input beside the Arguments' settings", async () => {
let fastMode = true
const claude = fakeClaude({
routes: {
apply_flag_settings: () => {
fastMode = false
}
},
settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } }
})
const adapter = adapterFor(claude, {
@@ -25,8 +37,23 @@ it("applies a resumed conversation's saved Fast off after start beside the Argum
expect(claude.connections[0].launch.options.extraArgs).toEqual({
settings: '/repo/claude.json'
})
await claudeOpened(adapter, 'session-1')
claude.connections[0].getSettings = async () => ({
effective: { fastMode, fastModePerSessionOptIn: false }
})
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'message-fast',
body: USER_MESSAGE,
executionOptions: { fastMode: 'false' },
fence: 7
})
).resolves.toMatchObject({ state: 'admitted' })
await vi.waitFor(() =>
expect(claude.connections[0].calls.at(-1)).toEqual({
expect(
claude.connections[0].calls.find((call) => call.subtype === 'apply_flag_settings')
).toEqual({
subtype: 'apply_flag_settings',
params: { settings: { fastMode: false } }
})
@@ -1,70 +0,0 @@
// A saved Fast the Claude child was launched without, applied once its start has read the settings
// that decide whether it may run.
import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests'
import { readClaudeModels } from './claude-structured-init-proof'
import { listedModels } from './claude-structured-model-catalog'
import { claudeModelFastModeSupport } from './claude-structured-session-options'
import type { ClaudeStartupFacts } from './claude-structured-session-startup'
import type { ClaudeSession } from './claude-structured-session-state'
/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings
* may opt in to it per session, or either value beside the agent Arguments' own `--settings`.
* Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a
* live Fast write takes. The value still to be applied, or null. */
export function admitClaudeStartFastMode(
session: ClaudeSession,
facts: ClaudeStartupFacts
): string | null {
const fastMode = session.options.get('fastMode')
if (!session.fastModeAtStart || fastMode === undefined) {
return null
}
if (fastMode !== 'true') {
return fastMode
}
if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) {
session.options.delete('fastMode')
return null
}
// Over the listing this start already holds.
const listed = listedModels({ models: readClaudeModels(facts.initialization) })
const blocked =
session.fastModeDisabledReason !== undefined &&
!['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason)
if (
blocked ||
(listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true)
) {
session.options.delete('fastMode')
session.restoreSkippedOptions.add('fastMode')
return null
}
return fastMode
}
/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal
* drops the child's value; silence keeps it wanted and
* unconfirmed. A write the user made meanwhile owns the option. */
export async function applyClaudeStartFastMode(
session: ClaudeSession,
facts: ClaudeStartupFacts,
fastMode: string
): Promise<void> {
const sequence = session.optionMutationSequence
try {
await session.connection.applyFlagSettings(
{ fastMode: fastMode === 'true' },
{ timeoutMs: facts.requestTimeoutMs }
)
} catch (error) {
if (sequence !== session.optionMutationSequence) {
return
}
session.confirmedOptions.delete('fastMode')
if (error instanceof ClaudeControlRequestError) {
session.options.delete('fastMode')
session.restoreSkippedOptions.add('fastMode')
}
}
}
@@ -0,0 +1,30 @@
export const FAKE_APP_SERVER = String.raw`
const readline = require('node:readline')
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const message = JSON.parse(line)
if (message.method === 'initialize') return send({ id: message.id, result: {} })
if (message.method === 'test/env') {
return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } })
}
if (message.method === 'test/cwd') {
return send({ id: message.id, result: { cwd: process.cwd() } })
}
if (message.method === 'test/notify') {
send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } })
return send({ id: message.id, result: {} })
}
if (message.method === 'test/ask') {
return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } })
}
if (message.method === 'test/refuse') {
return send({ id: message.id, error: { code: -32602, message: 'bad params' } })
}
if (message.method === 'test/missing') {
return send({ id: message.id, error: { code: -32601, message: 'method not found' } })
}
if (message.id === 99) {
return send({ method: 'test/answered', params: message })
}
})
`
@@ -13,8 +13,6 @@ export type CodexAppServerConnectionHandlers = {
onExit?: (error: Error, exit?: { expected: boolean }) => void
/** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */
onSpawned?: (pid: number) => Promise<void>
/** Any stdout or stderr chunk from the child. */
onOutput?: () => void
}
export type CodexAppServerConnection = {
@@ -1,3 +1,4 @@
import { FAKE_APP_SERVER } from './__fixtures__/codex-connection-app-server'
import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child'
import type { EventEmitter } from 'node:events'
import { realpathSync } from 'node:fs'
@@ -37,36 +38,6 @@ afterEach(() => {
* a stub, but it is the only thing that proves the spawn, the environment, and
* both traffic directions actually work end to end.
*/
const FAKE_APP_SERVER = String.raw`
const readline = require('node:readline')
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const message = JSON.parse(line)
if (message.method === 'initialize') return send({ id: message.id, result: {} })
if (message.method === 'test/env') {
return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } })
}
if (message.method === 'test/cwd') {
return send({ id: message.id, result: { cwd: process.cwd() } })
}
if (message.method === 'test/notify') {
send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } })
return send({ id: message.id, result: {} })
}
if (message.method === 'test/ask') {
return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } })
}
if (message.method === 'test/refuse') {
return send({ id: message.id, error: { code: -32602, message: 'bad params' } })
}
if (message.method === 'test/missing') {
return send({ id: message.id, error: { code: -32601, message: 'method not found' } })
}
if (message.id === 99) {
return send({ method: 'test/answered', params: message })
}
})
`
async function openFakeServer(
handlers: CodexAppServerConnectionHandlers = {},
+26 -11
View File
@@ -36,20 +36,16 @@ export type CodexAppServerLaunch = ProviderProcessLaunch
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
/**
* Spawns `codex app-server`, completes the initialize handshake, and returns a
* connection that stays open until `close()`. Rejects — after reaping the child
* — when the handshake cannot complete.
*/
export async function openCodexAppServerConnection(
/** Builds the transport so an owner can record its process before initialization. */
async function connectCodexAppServer(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner = spawnProcess
spawnImpl: PipedProcessSpawner,
initialize: boolean
): Promise<CodexAppServerConnection> {
const managed = spawnManagedProviderProcess(launch, {
spawnImpl,
site: 'codex-app-server-teardown',
...(handlers.onOutput ? { onOutput: handlers.onOutput } : {})
site: 'codex-app-server-teardown'
})
const { child } = managed
@@ -234,8 +230,10 @@ export async function openCodexAppServerConnection(
if (child.pid !== undefined) {
await handlers.onSpawned?.(child.pid)
}
handshaking = true
await initializeCodexAppServerConnection(connection)
if (initialize) {
handshaking = true
await initializeCodexAppServerConnection(connection)
}
} catch (error) {
if ((await close()) !== true) {
throw new CodexAppServerHandshakeExitUnprovenError(connection, error)
@@ -248,3 +246,20 @@ export async function openCodexAppServerConnection(
}
return connection
}
/** Publishes the transport after its process identity is recorded, before initialization. */
export function spawnCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner = spawnProcess
): Promise<CodexAppServerConnection> {
return connectCodexAppServer(launch, handlers, spawnImpl, false)
}
export function openCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner = spawnProcess
): Promise<CodexAppServerConnection> {
return connectCodexAppServer(launch, handlers, spawnImpl, true)
}
@@ -14,6 +14,7 @@ import {
terminateCodexBackgroundTerminals
} from './codex-background-terminals'
import {
acquireReadyCodexForTest,
adapterFor,
fakeCodex,
identityFor,
@@ -93,7 +94,7 @@ function commandItem(status: 'inProgress' | 'completed') {
/** A turn that starts a dev server and ends with it still running. */
async function backgroundedDevServer(codex: ReturnType<typeof codexWith>): Promise<void> {
await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' })
await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' })
codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } })
codex.notify('item/started', {
threadId: THREAD_ID,
@@ -226,7 +227,7 @@ describe('Codex background-command Stop', () => {
return { data: [], nextCursor: null }
}
})
await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' })
await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' })
codex.notify('turn/started', {
threadId: THREAD_ID,
turn: { id: TURN_ID, status: 'inProgress' }
@@ -1,9 +1,14 @@
import {
acquireReadyCodexForTest,
fakeCodex,
identityFor,
THREAD_ID
} from './codex-structured-session-adapter-fixture'
// Default-mode collab frames as a live session sent them, and the journal rows the real adapter
// publishes for them: what a client reads.
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
// Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped.
@@ -114,7 +119,7 @@ export async function publishedRows(frames: Frame[]): Promise<AgentJournalRender
appendTombstone: () => {},
publish: () => {}
}
await adapter.acquire({
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9',
@@ -4,7 +4,12 @@ import type { CodexAppServerConnection } from './codex-app-server-connection'
// Why short: config/read is a local file read; a slow answer must not hold the listing.
const CONFIG_READ_TIMEOUT_MS = 3_000
type CodexConfiguredLaunchDefaults = { model: string | null; effort: string | null }
type CodexConfiguredLaunchDefaults = {
model: string | null
effort: string | null
personality?: string
serviceTier?: string
}
function configuredText(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
@@ -28,7 +33,11 @@ export async function readCodexConfiguredLaunchDefaults(
if (typeof config !== 'object' || config === null) {
return null
}
const personality = 'personality' in config ? configuredText(config.personality) : null
const serviceTier = 'service_tier' in config ? configuredText(config.service_tier) : null
return {
...(personality ? { personality } : {}),
...(serviceTier ? { serviceTier } : {}),
model: 'model' in config ? configuredText(config.model) : null,
effort:
'model_reasoning_effort' in config ? configuredText(config.model_reasoning_effort) : null
@@ -1,3 +1,9 @@
import {
acquireReadyCodexForTest,
fakeCodex,
identityFor,
THREAD_ID
} from './codex-structured-session-adapter-fixture'
// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that
// spawn, message, wait on or close it. These frames, through the real adapter, must register that
// helper the same way a `subAgentActivity` does: one child in the strip and the host's records,
@@ -11,7 +17,6 @@ import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child
import { createAgentStatusStore } from '../../shared/agent-status-store'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
@@ -161,7 +166,7 @@ async function session() {
appendTombstone: () => {},
publish: () => {}
}
await adapter.acquire({
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9',
@@ -51,6 +51,9 @@ beforeEach(async () => {
closeSession = vi.fn(async () => true)
const store = await openTestAgentSessionRecordStore(root)
const adapter: StructuredAgentSessionAdapter = {
open: async () => ({
reportedOptions: { model: '' }
}),
acquire: async ({ fence, spawnToken, events }) => {
sink = events ?? null
return {
@@ -1,3 +1,9 @@
import {
acquireReadyCodexForTest,
adapterFor,
fakeCodex,
identityFor
} from './codex-structured-session-adapter-fixture'
// A Codex stream error it is about to retry is a warning row per attempt, never a red row: the
// journal keeps every attempt, and the transcript draws only the latest of a run.
import { readFileSync } from 'node:fs'
@@ -10,7 +16,6 @@ import type {
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
const THREAD_ID = 'thread-abc'
@@ -171,7 +176,7 @@ describe('a Codex stream error it is about to retry', () => {
publish: () => undefined
}
for (const fence of [7, 8]) {
await adapter.acquire({
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence,
spawnToken: `spawn-${fence}`,
@@ -0,0 +1,177 @@
import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture'
import type {
CodexAppServerConnection,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake'
export const CODEX_SCRIPTED_THREAD = 'scripted-codex-thread'
const THREAD = CODEX_SCRIPTED_THREAD
type ScriptedCodexRoute = (params: Record<string, unknown> | undefined) => unknown
export type ScriptedCodexChild = ScriptedAgentChild & {
/** Every request any spawn received, in order. */
requests(): readonly { method: string; params?: Record<string, unknown> }[]
}
/** A held answer gives up on the request's own deadline, as the real connection's does (30 s when
* the caller names none). */
function heldAnswer(
answer: Promise<unknown>,
method: string,
timeoutMs = 30_000
): Promise<unknown> {
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() =>
reject(
new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`)
),
timeoutMs
)
})
return Promise.race([answer, timedOut]).finally(() => clearTimeout(timer))
}
/** `routes` answers methods the script does not, such as the history reads a rewind makes. */
export const codexScriptedChild = (
routes: Readonly<Record<string, ScriptedCodexRoute>> = {}
): ScriptedCodexChild => {
let holdHandshakes = true
let completeTurns = true
let spawned = 0
let resumed = 0
let closed = 0
const prompts: string[] = []
const requests: { method: string; params?: Record<string, unknown> }[] = []
let newest: ReturnType<typeof Promise.withResolvers<unknown>> | undefined
const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
spawned += 1
const gate = Promise.withResolvers<unknown>()
newest = gate
let ended = false
const turns = codexTurnLifecycleFake(THREAD, () => (method, params) => {
if (!ended) {
handlers.onNotification?.(method, params)
}
})
const connection: CodexAppServerConnection = {
pid: 10_000 + spawned,
get closed() {
return ended
},
request: async (method, params, options) => {
if (ended) {
throw new Error('scripted Codex child closed')
}
requests.push({ method, ...(params ? { params } : {}) })
if (method === 'initialize') {
return holdHandshakes ? heldAnswer(gate.promise, method, options?.timeoutMs) : {}
}
const route = routes[method]
if (method === 'thread/resume') {
resumed += 1
}
if (route) {
return route(params)
}
if (method === 'thread/start' || method === 'thread/resume') {
return { thread: { id: THREAD }, model: 'scripted-model' }
}
if (method === 'model/list') {
return {
data: [
{ model: 'scripted-model', isDefault: true, supportedReasoningEfforts: [] },
{
model: 'scripted-alt-model',
supportedReasoningEfforts: [{ reasoningEffort: 'high' }]
}
],
nextCursor: null
}
}
if (method === 'turn/start' || method === 'turn/steer') {
const input = params?.input
if (Array.isArray(input)) {
for (const block of input) {
if (
typeof block === 'object' &&
block !== null &&
'text' in block &&
typeof block.text === 'string'
) {
prompts.push(block.text)
}
}
}
const answer =
method === 'turn/start'
? await turns.routes['turn/start']()
: turns.routes['turn/steer'](params)
if (method === 'turn/start') {
turns.start()
}
if (typeof params?.clientUserMessageId === 'string') {
turns.echo(params.clientUserMessageId)
}
if (completeTurns) {
turns.end('completed')
}
return answer
}
if (method === 'turn/interrupt') {
return turns.routes['turn/interrupt'](params)
}
return {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
if (!ended) {
ended = true
closed += 1
gate.reject(new Error('scripted Codex child closed'))
handlers.onExit?.(new Error('scripted Codex child closed'), { expected: true })
}
return true
}
}
// A gate can be closed before initialization starts or after it has already answered.
void gate.promise.catch(() => {})
await handlers.onSpawned?.(connection.pid ?? 0)
return connection
}
return {
deps: {
openCodexConnection: open,
readProcessStartTime: async () => 1_700_000_000_000,
resolveCodexCommand: () => '/nonexistent/scripted-codex',
resolveEnvironment: async () => ({})
},
get holdHandshakes() {
return holdHandshakes
},
set holdHandshakes(value) {
holdHandshakes = value
},
get completeTurns() {
return completeTurns
},
set completeTurns(value) {
completeTurns = value
},
releaseHandshake: () => newest?.resolve({}),
failHandshake: (message) =>
newest?.reject(new CodexAppServerRequestError('initialize', -32603, message, message)),
prompts: () => prompts,
requests: () => requests,
spawns: () => spawned,
resumes: () => resumed,
closes: () => closed
}
}
@@ -1,3 +1,9 @@
import {
acquireReadyCodexForTest,
fakeCodex,
identityFor,
THREAD_ID
} from './codex-structured-session-adapter-fixture'
// A Codex session's frames, through the real adapter, into the host's child records: the order the
// host receives them in, and the parent row they fold to, written out step by step.
@@ -24,7 +30,6 @@ import type {
} from '../../shared/agent-session-journal-types'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
const parent = makeStructuredAgentStatusSubject(
@@ -135,7 +140,7 @@ async function producer() {
moment()
}
}
await adapter.acquire({
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9',
@@ -1,8 +1,12 @@
import { expect, it, vi } from 'vitest'
import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement'
import {
type LateSettlement,
acquiredCodexAdapter,
CODEX_TEST_USER_MESSAGE,
startTurn,
echoUserMessage,
refuseUnroutedSteer,
fakeCodexAppServer
} from './codex-structured-dispatch-test-support'
@@ -28,3 +32,99 @@ it('checks continuation authority before writing turn/start', async () => {
await adapter.closeAll()
}
})
it('stops a refused steer before its fallback write', async () => {
const refused = Promise.withResolvers<unknown>()
const codex = fakeCodexAppServer({ 'turn/steer': () => refused.promise })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'running')
const controller = new AbortController()
const reason = new Error('Stopped before fallback')
const send = (id: string) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: id,
fence: 7,
body: CODEX_TEST_USER_MESSAGE,
signal: controller.signal
})
const sending = send('stopped')
await vi.waitFor(() =>
expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true)
)
controller.abort(reason)
try {
refuseUnroutedSteer(undefined)
} catch (error) {
refused.reject(error)
}
await expect(sending).rejects.toBe(reason)
expect(connection.calls.some((call) => call.method === 'turn/start')).toBe(false)
echoUserMessage(connection, { turnId: 'running', itemId: 'stopped', clientId: 'stopped' })
expect(settlements).toEqual([])
await adapter.closeAll()
})
it('abandons only the stopped message while waiting for a previous turn to open', async () => {
const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'opening' } }) })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
const dispatch = (id: string, signal?: AbortSignal) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: id,
fence: 7,
body: CODEX_TEST_USER_MESSAGE,
signal
})
await expect(dispatch('first')).resolves.toEqual({ state: 'admitted' })
const controller = new AbortController()
const stopped = dispatch('waiting', controller.signal)
await new Promise<void>((resolve) => setTimeout(resolve, 10))
const reason = new Error('Stopped local wait')
controller.abort(reason)
await expect(stopped).rejects.toBe(reason)
expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(1)
startTurn(connection, 'opening')
echoUserMessage(connection, { turnId: 'opening', itemId: 'ignored', clientId: 'waiting' })
expect(settlements).toEqual([])
await adapter.closeAll()
})
it.each(['accepted', 'uncertain'] as const)(
'retains the %s outcome of a steer written before Stop',
async (outcome) => {
const answer = Promise.withResolvers<unknown>()
const codex = fakeCodexAppServer({ 'turn/steer': () => answer.promise })
const settlements: LateSettlement[] = []
const adapter = await acquiredCodexAdapter({ codex, settlements })
const connection = codex.connections[0]!
startTurn(connection, 'running')
const controller = new AbortController()
const sent = adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'written',
fence: 7,
body: CODEX_TEST_USER_MESSAGE,
signal: controller.signal
})
await vi.waitFor(() =>
expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true)
)
controller.abort(new Error('Stop after write'))
if (outcome === 'accepted') {
answer.resolve({ turnId: 'running' })
await expect(sent).resolves.toEqual({ state: 'admitted' })
} else {
const lost = new Error('Reply lost after write')
answer.reject(lost)
await expect(sent).rejects.toBe(lost)
}
echoUserMessage(connection, { turnId: 'running', itemId: 'written', clientId: 'written' })
expect(settlements.map((entry) => entry.clientMessageId)).toEqual(['written'])
await adapter.closeAll()
}
)
@@ -1,3 +1,4 @@
import { acquireReadyCodexForTest } from './codex-structured-session-adapter-fixture'
import { expect, vi } from 'vitest'
import type {
AgentJournalMessageItem,
@@ -117,7 +118,7 @@ export async function acquiredCodexAdapter(input: {
agent: 'codex',
providerHandle: codexProviderHandle(CODEX_TEST_THREAD_ID)
}
await adapter.acquire({
await acquireReadyCodexForTest(adapter, {
identity,
fence: 7,
spawnToken: 'spawn-9',
@@ -200,15 +200,15 @@ export function createCodexJournalTranslator(
return suppressionAdmission.accepted ? prompts.handle(event) : suppressionAdmission
}
if (event.type === 'server-request') {
return genericFrames.appendUnhandled(
`request:${event.method}`,
event.params,
event.threadId
)
const requestKey = `request:${event.method}`
return genericFrames.appendUnhandled(requestKey, event.params, event.threadId)
}
if (event.type === 'provider-frame') {
return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId)
}
if (event.type !== 'notification') {
return CODEX_JOURNAL_ADMITTED
}
if (event.method === 'turn/started' || event.method === 'turn/completed') {
return event.method === 'turn/started'
? turnBoundaries.start(event)
@@ -1,7 +1,7 @@
// How a durable session record becomes a Codex process launch.
//
// Every input is read back from the record the store already made durable, not
// from the call that triggered the acquire. A client that attaches twice must
// from the call that triggered spawn. A client that attaches twice must
// land in the same working directory under the same account home, and a resume
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
@@ -1,11 +1,16 @@
import {
acquireReadyCodexForTest,
adapterFor,
fakeCodex,
identityFor
} from './codex-structured-session-adapter-fixture'
import { describe, expect, it } from 'vitest'
import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture'
describe('Codex adapter liveness for lease renewal', () => {
it('holds its acquisition until the connection reports the root exit', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex)
const { acquisitionGeneration } = await adapter.acquire({
const { acquisitionGeneration } = await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
@@ -0,0 +1,253 @@
import { describe, expect, it, vi } from 'vitest'
import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
import { optionSession } from './codex-structured-option-session.test-fixture'
import { resolvedCodexMessageOptions } from './codex-structured-message-options'
import { startCodexTurn } from './codex-structured-turn-start'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
import {
acquireReadyCodexForTest,
USER_MESSAGE,
adapterFor,
fakeCodex,
identityFor
} from './codex-structured-session-adapter-fixture'
function seedMessageDefaults(session: CodexSession): void {
const store = new AgentModelCatalogStore()
const fingerprint = 'codex-message-defaults'
store.recordSuccess(
fingerprint,
'codex',
{
origin: 'probe',
models: [
{
id: 'model-a',
label: 'Model A',
isDefault: false,
defaultEffort: 'low',
serviceTiers: [{ value: 'priority', label: 'Priority' }],
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
{
id: 'model-b',
label: 'Model B',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' }
]
}
]
},
'discovery'
)
session.catalogAccess = { store, fingerprint, accountHomePath: '/test-account' }
}
describe('Codex captured message choices', () => {
it('uses each message’s choices on turn/start and tracks the native attempted choice', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'turn-1' }
}))
const session = optionSession(request)
session.options.set('model', 'saved-next')
session.options.set('effort', 'medium')
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: 'go' }]
}
for (const [id, model, effort] of [
['first', 'model-a', 'high'],
['second', 'model-b', 'low']
]) {
const turnOptions = await resolvedCodexMessageOptions(session, { model, effort })
await startCodexTurn(session, { clientMessageId: id, body, turnOptions })
}
session.activeTurnIds = new Set(['turn-1'])
await startCodexTurn(session, {
clientMessageId: 'steer',
body,
turnOptions: { personality: 'friendly', serviceTier: 'priority' }
})
expect(request.mock.calls.at(-1)?.[0]).toBe('turn/steer')
expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('personality')
expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('serviceTier')
expect(session.appliedTurnOptions).toEqual({ model: 'model-b', effort: 'low' })
session.activeTurnIds = new Set()
await startCodexTurn(session, {
clientMessageId: 'next',
body,
turnOptions: { personality: 'friendly', serviceTier: 'priority' }
})
expect(request.mock.calls.at(-1)?.[1]).toMatchObject({
personality: 'friendly',
serviceTier: 'priority'
})
expect(
request.mock.calls
.filter(([method]) => method === 'turn/start')
.map(([, params]) => [params?.model, params?.effort])
).toEqual([
['model-a', 'high'],
['model-b', 'low'],
[undefined, undefined]
])
expect(Object.fromEntries(session.options)).toEqual({
personality: 'friendly',
serviceTier: 'priority'
})
})
it('resets an explicit empty choice after a thread launch and earlier turn override', async () => {
const request = vi.fn(async (method: string, _params?: Record<string, unknown>) =>
method === 'turn/start'
? { turn: { id: 'opened' } }
: { config: { personality: 'pragmatic', service_tier: 'flex' } }
)
const session = optionSession(request)
session.reportedOptions = { model: 'model-a', effort: 'high' }
session.options = new Map([
['model', 'model-a'],
['effort', 'high'],
['personality', 'friendly'],
['serviceTier', 'priority']
])
seedMessageDefaults(session)
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: 'go' }]
}
for (const [id, choices] of [
[
'first',
{ model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }
],
['second', {}],
['third', { model: 'model-a' }]
] as const) {
const turnOptions = await resolvedCodexMessageOptions(session, choices)
await startCodexTurn(session, { clientMessageId: id, body, turnOptions })
}
expect(
request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params)
).toMatchObject([
{ model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' },
{ model: 'model-b', effort: 'medium', personality: 'pragmatic', serviceTier: 'flex' },
{ model: 'model-a', effort: 'low' }
])
expect(request.mock.calls.map(([method]) => method)).toEqual([
'config/read',
'turn/start',
'turn/start',
'turn/start'
])
expect(Object.fromEntries(session.options)).toEqual({
model: 'model-a',
effort: 'low'
})
})
it('inherits untouched native tier and personality without a configuration probe', async () => {
const request = vi.fn(async (_method: string) => ({ turn: { id: 'native' } }))
const session = optionSession(request)
session.reportedOptions = { model: 'model-a', serviceTier: 'flex', serviceTierKnown: true }
const turnOptions = await resolvedCodexMessageOptions(session, {})
await startCodexTurn(session, { clientMessageId: 'native', body: USER_MESSAGE, turnOptions })
expect(request).toHaveBeenCalledOnce()
expect(request.mock.calls[0]?.[0]).toBe('turn/start')
expect(turnOptions).toEqual({})
})
it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => {
const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) })
const adapter = adapterFor(codex, { model: 'model-b' })
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-1',
options: { model: 'model-a', effort: 'high' }
})
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'first',
body: USER_MESSAGE,
executionOptions: { model: 'model-a', effort: 'high' },
fence: 7
})
expect(
codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params
).toMatchObject({ model: 'model-a' })
expect(
codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params
).toMatchObject({ model: 'model-a', effort: 'high' })
})
it('preserves the old host path when no message snapshot was supplied', async () => {
const request = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({
turn: { id: 'legacy' }
}))
const session = optionSession(request)
session.options.set('model', 'saved-next')
session.options.set('effort', 'high')
const turnOptions = await resolvedCodexMessageOptions(session, undefined)
await startCodexTurn(session, {
clientMessageId: 'legacy',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] },
turnOptions
})
expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' })
})
it('refuses a default reset with no trustworthy default before prompt input', async () => {
const request = vi.fn(async () => ({ turn: { id: 'unexpected' } }))
const session = optionSession(request)
session.options.set('model', 'old-override')
session.options.set('effort', 'high')
await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow(
'default model is unavailable'
)
expect(request).not.toHaveBeenCalled()
})
it('restores native options after a known refusal and retains them after an unknown answer', async () => {
const body = {
kind: 'message' as const,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: 'go' }]
}
const known = optionSession(
vi.fn(async () => {
throw new CodexAppServerRequestError('turn/start', -32602, 'invalid model')
})
)
known.options.set('model', 'prior')
await expect(
startCodexTurn(known, { clientMessageId: 'known', body, turnOptions: { model: 'attempted' } })
).rejects.toThrow('invalid model')
expect(Object.fromEntries(known.options)).toEqual({ model: 'prior' })
const unknown = optionSession(
vi.fn(async () => {
throw new Error('connection lost after write')
})
)
unknown.options.set('model', 'prior')
await expect(
startCodexTurn(unknown, {
clientMessageId: 'unknown',
body,
turnOptions: { model: 'attempted' }
})
).rejects.toThrow('connection lost after write')
expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' })
})
})

Some files were not shown because too many files have changed in this diff Show More