Add Pi structured native chat through its RPC mode (#25851)

* End a running call as its turn's journal row ends

A call still running when its turn ends takes the state of that turn's
row: a row another writer settled first (a person's Stop) stands, so its
calls read interrupted whatever the provider's later end reports. The
no-ending path that settled calls from the Stop row is gone, since a Stop
now leaves running calls to the provider. Adds the two Spanish strings.

* Say why a Grok turn failed, and keep task rows in Grok's own words

A failed Grok turn ended with no reason on screen: the translator dropped
every copy of Grok's message. The failed turn now gets one status row in
Orca's existing "provider did not accept this message" words with Grok's
reason, read from whichever copy arrives first (the given-up retry, the
turn's end, the prompt's completion notice, or the prompt's error answer);
later copies only fill a reason the row still lacks.

A running background command no longer reads "Background task <id>
started": a task's summary is mapped only once it has settled. A monitor
stays a monitor when the agent reads its output: a frame that names no
kind keeps the known one, and a "[monitor" command is a monitor.

A prompt's turn is marked started, so a late frame for an ended prompt
neither reopens it nor becomes the active turn. A tool's turn is held in
one place at a time.

* Read a monitor from Grok's exact output prefix

* Word a failed Grok turn in Grok's own text, not as a refused message

A turn that started and then failed was told "The provider did not accept
this message", Orca's sentence for a message refused before its turn. The
row now reads as a Codex turn-ending error does: an error status row with the
provider's own words. With no words, the dialect names the failure ("Grok
ended this turn with an error." / "Grok usage limit reached."), else the
agent's display name does.

* Settle a stopped turn's running call as its turn row ended after a restart too

The restart sweep ended every running call by the death evidence alone, so after
a person's Stop with no proof the child died the call read failed under a turn
that read interrupted. The sweep and the live dead-generation settlement now ask
the same rule the assembler does: a call in a turn already settled ends as that
row ended; only a turn still running leaves its calls to the evidence.

* Keep the dead-generation settlement under the line cap

* Register the ACP schema verify step in the PR preflight phase test

* refactor(agent-session): one required agent registry; declarations admit what they claim

A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.

/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.

Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).

* Read ACP permissions, session events and prompt errors through the protocol client's own types

The translator now reads a permission request with the client's lenient reader, a session update
with its session-event reader, and takes only the agent's own error answer as a failed prompt's
reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values
newer than this build.

* refactor(agent-session): the router applies the declared rewind itself

The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.

* test(agent-session): register the agents the merged-in tests now need

The adoption replay test builds its host over this build's registered agents
instead of an adapter definition method, and the stored-form test passes the
stored agents the record guard now requires.

* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record

* fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop

The desktop renderer calls the restart methods as a paired runtime client
without the registered-agents capability, so it was handed a Claude/Codex
audience and its "dismiss all" took the scoped path: no dismissal fence and
no clearing of unwritten teardown witnesses, so a late teardown write could
bring a dismissed offer back.

The audience is now derived against this host's registry: a client that can
show every agent the host registered gets none, and dismisses exactly as
before (one fence for every offer). A client that truly cannot show some
agent still gets a final dismissal for what it sees: each cleared session is
fenced on its own (bounded, superseded by a dismiss-all fence) and only the
witnesses of agents it sees are dropped.

One predicate now answers which agents a client renders, for both tab
projection and restart offers; the Claude capability rule is folded in.

* fix(agent-session): a changed agent definition never hides that agent's chats

A record was readable only if every handle used the transport its agent's
definition declares today, and its account variable was declared by some
registered agent. A later build that drives the same agent over another
protocol, or under a renamed variable, would have set every existing chat of
that agent aside: no tab, no history, no error.

Readability now asks only that the record agree with itself: its agent is
registered, every handle shares one namespace owned by that agent, and the
account variable is a well-formed name. Whether this build can drive it (the
chain's transport is the one the agent speaks, and the account variable is
that agent's own, since it becomes the child's environment) is decided in
performAttach, the one admission every start of every agent passes through,
and refused there as hostUnsupported. A record pinning another agent's
variable is refused the same way rather than passing because some agent
declares it.

* refactor(agent-session): each agent's registration says where it runs and which account it pins

createSupport, create and the model catalog's account read still chose a
location rule and an account resolver by name (Claude, Codex, else none),
so a new agent would have needed a third branch beside the registration
list that already decides storage, routing and publication.

Each runtime registration now carries supportsLocation(location) and
resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }),
and Claude's and Codex's rules move into their entries unchanged: a read
still syncs no home and starts no bridge, a Codex launch still trusts the
workspace first, and WSL locations resolve as before. The list exists
before the host is built, so none of these reads installs the host, and an
agent the list does not hold is answered no without opening the journal.

* test(agent-session): drop a duplicate registry key; keep fences out of the capsule state

A spread input already carries the registry, so the explicit key was
overwritten (TS2783). The session-fence helper now returns only the fence,
not the capsule state it was given.

* fix(agent-session): a scoped dismiss-all persists no per-session fence

The per-session dismissal fence added a forever-persisted capsule field
with an arbitrary cap that served no caller: only the local desktop calls
the restart methods, and on a host whose agents it all shows it gets no
audience and takes the unscoped, fenced dismissal. A scoped dismiss-all
(a caller that cannot show some registered agent) keeps that agent's
offers, clears only its audience's unwritten witnesses, and writes no
fence; a late write from this process is serialized behind it. The field
never left this unreleased branch.

* fix(agent-session): refuse an attach whose agent is not the session's own

The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal.

* fix(agent-session): offer to start a chat only when the start would accept it

The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired.

* fix(agent-session): the model-catalog probe uses a record's account only if this build drives it

A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record.

* refactor(agent-session): the record store admits agent ids; comments say where transport is checked

The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept.

* docs(agent-session): the record store admits the registered agents' ids

* refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule

The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process
start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration
takes the full account-home resolver signature, and D3's tests build hosts with the agent registry.

* fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close

A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that
owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A
permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the
protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request
rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording
path. The child drops its own stderr tail and close policy for the managed process's, and a close
whose process tree was not proven gone is reported as the adapter contract asks.

* fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays

A chat with a saved Grok session reattaches with session/resume where the agent offers it, else
session/load. Either way the call runs inside the translator's load window, so what Grok sends while
it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens
no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no
longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing
notice. The attach window also closes after a failed attach, and a created session that session/resume
reports missing is replaced like one session/load reports missing.

The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input
grammar test and completed-turn check in the assembler go with it.

* refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced

* test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires

* fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable

Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the
launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside
the turns that send it, keeping both files in their line limit.

* fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes

Grok's session/cancel ends only the running turn: work it already moved to the background keeps
running and can begin a turn of its own after the person pressed Stop. Stop is now a session
boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host
waits a bounded grace for that, then ends the process; the next send relaunches and resumes.
The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host
ends the session unless the provider declines a Stop naming a turn that has since ended.

* test(native-chat): a Grok Stop ends the process only after Grok answered the cancel

* fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it

A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer
on a queued card took the card out of the host's editable queue and meant 'send after this turn'.
It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt
once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in
turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general
held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in
the journal). An older client's mid-turn send takes the same path. capabilities.steering is
unchanged and still unread.

* refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal

The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an
AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the
same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to
every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an
unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's
unproven child until its exit is proven.
The hook also let a later close ask that child again. The host now does that from state it holds:
a close of a chat with no live child whose record still names an owner process with no death
evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe
does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex
ignore the signal and hold no such child; their release is a no-op (tested).

* fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words

On macOS and Linux the agent's stdout ends before its exit is observed, with or without the
supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text
became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven
exit: the agent's last words when it left any, else why the connection closed. The failure already
carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the
start, and that dispatch re-checks image support are corrected.

* fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not

The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless
they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never
ended. D3 now marks every frame inside the window as replay before the translator reads it, so the
translator keeps only context usage whatever the agent marked; options and commands are still
adopted. The translator's load semantics are unchanged.

* test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened

* test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window

* test(native-chat): a Grok background task a Stop ended reads as stopped reporting

* refactor(native-chat): quit's stop of each start answers through one promise kind

* fix(native-chat): quit aborts every start the host has in flight before draining attaches

A Grok that never answered its handshake held quit until the start's own 60 s bound, past the
20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain
still begins), so the adapter's own quit controller and its map of starts are gone: a start
has one canceller, the host's signal.

* fix(native-chat): a Grok start's abort stops reaching its child once the start has returned

The listener stayed on the host's signal until the attach finished committing, so a Close in that
window killed the now-live child behind the host's back and it read as Grok crashing. The start
now detaches it when it ends; a later Close goes through the session's own stop.

* fix(native-chat): a close or Stop during any attach phase stops the start before it launches

The attach began its abort controller only after reconciling leases, resolving recovery and
probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok
launched anyway. The controller now begins first, and the acquisition checks it before asking the
adapter to start.

* test(native-chat): a close during the attach's owner probe asks no adapter to start

Also renames the close test after the hook it no longer exercises.

* test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left

The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not
prove gone, the close stops it again as a requested close, and Claude persists the handle of the
conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the
close awaits the re-ask, bounded by each adapter's kill ladder.

* fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends

A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which
queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn
as the running reply. The send now waits as a steer, the turn is cancelled once, and the message
goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent.

* test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent

Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the
running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected
as never sent.

* fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven

After the connection broke and the close could not prove Grok's exit, any later stop Orca asked
for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a
requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed.
The connection loss now decides the cause, and a send on that session is rejected as never sent.

* test(native-chat): fixtures this PR's registered Grok and desktop capability made stale

CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes
on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the
desktop now advertises registered agents (the restart-offer tests' older client drops that
capability explicitly); the attach context carries the start's abort controllers (the forget-status
double gains them); and the ACP real-host test rig sends to the host directly (listed beside the
other real-host rig in the send ratchet).

* fix(native-chat): a start quit stops is not the queued message's start failure

With quit now aborting a start it would have waited for, the delivery step recorded the aborted
start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step
leaves the message to quit, which settles it as a close does ("The chat closed before this message
was sent."). The test that pinned quit waiting for that start and stopping its child now pins that
nothing is launched behind quit.

* fix(native-chat): a message sent after a Stop or close aborted a start gets its own start

A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery
loop, which then rejected whatever was queued at that moment with "couldn't restart", including a
message the user sent after the Stop. The attach now reports that the host aborted it, and the loop
re-derives from the journal instead: what the Stop or close withdrew is already settled, a message
accepted since gets a start of its own, and quit's next step stops the loop. This replaces the
quit-only carve-out with the same rule for every abort and every agent.

* test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test

* fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close

The pick runs on the session's queue. It now registers in the host's out-of-queue
abort registry beside a start, so a close, an admitted Stop or quit abandons it, and
the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted.

* fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show

agent.launch now reads the caller's capabilities by the rule tabs and restart offers
use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as
a terminal again, as on main; the host's own callers and desktop clients are unchanged.

* fix(acp): strip every agent hook variable from the ACP child, from the shared list

ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed
ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane
identity keys.

* refactor(native-chat): the mutation context carries the provider-wait registry itself

Keeps the host file within its line limit; one field instead of two closures over it.

* fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats

The caller rule from the previous commit also turned Claude and Codex into terminals
for a client advertising only agent.launch.v2, whose contract says it opens a chat
(mobile retry-authority tests). Only an agent beyond those two now needs the client to
read it (clientRendersStructuredAgent); the test fixtures go back to what they were.

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(acp): drop session/load history adoption from the translator

The common pattern discards the history an agent replays during session/load,
keeping only what it says about the context window. Remove the adoption path
(acp-history-adoption.ts, the adopt option, and the historical background-task
liveness rewrite it fed) so load replay is always dropped except usage.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(acp): keep the task-result status table on live frames

Review follow-up to the adoption removal: the result-status mapping was only
tested through adopted history, so run the same table on live frames, and
cover an unmarked task notice during a load being dropped.

* test(acp): a frame helper for a shell command Grok is running

* fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended

A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's
provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with
Grok's last words) never reached the journal, and a later stale-session pass wrote a bare,
thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are.

At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the
host's exit contract expects of a child's own translator (Codex's does the same). When Grok's
stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven:
the host's provider-exit settlement now takes the exit as proof naming the child's fence and
revises what that child left unverifiable in the same batch, with the turn-scoped row and the
adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command
whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open.

* test(acp): a crash seen first leaves the host no Grok turn to revise

* refactor(native-chat): what a gone generation left unfinished gets its own module

The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads
(capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged,
apart from the exit proof they now take.

* refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks

An exit's own instant is the turn's end, so the revision needs only each row's fence: the
settlement's journal type gains itemFence alone, and the host test fakes say so.

* test(native-chat): drop the duplicate itemFence on the fake that already had one

* test(claude, codex): an exit whose stdout ended first still reports as it always did

The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF
arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and
report the exit, with its usual reason, once it is seen.

* fix(acp): reopen a chat with session/load, as the common pattern does

An agent that offers both now reloads its session instead of resuming it; the
reattach window still discards what it replays except context usage.

* fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once

Neither common design bounds an ACP handshake: Close, Stop and quit end a start
that never answers. The abort now also closes the connection, as a kill there
does, so the start settles even before the child's exit is proven. The
host-stopped start refusal only this bound produced goes with it; the idle
sweep keeps its words.

* fix(acp): a Stop naming an ended turn follows Claude's rule

It still stops nothing while another turn is live, but in the gap before a
follow-up's turn opens, which no client can name, it now stops what is in
flight and the session ends, as a Claude Stop does.

* fix(native-chat): a close no longer re-asks a failed start's unproven child

Neither common design retries that stop at Close, and Orca's Claude contract
re-asks only at the next start and at quit. The ACP adapter keeps the child
until its exit is proven and asks it again there, as Claude does.

* fix(acp): a message sent during a turn the agent began itself goes at once

Both common designs send it straight to the agent with no cancel; only Orca's
own running prompt is steered (cancelled, then re-prompted).

* test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer

Uses an audience production sends (one that cannot show every agent), per review.

* fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags

The common pattern passes neither --no-auto-update, --no-leader nor
GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve.

* fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection

As in the common pattern, only a broken stdin (or Orca's own close) ends the
agent; one that closed its output but can still be written to stays until a
Stop, a close or its exit. The provider supervisor goes back to its base
content, so Claude and Codex no longer get the forwarded stdout end either.

* fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts

On main every close of the chat writes its own Stop and settle. Here a later close joins the
first and writes no row, and the first's settle closed when its kill failed, so a turn that opened
in between and was cut by the next close read as failed. A person's close joining a person's close
whose Stop opened a settle now reopens that settle until its attempt is done.

Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and
the next reads as the person's cancellation (each fails without its half of the fix).

* test(native-chat): Grok opens as a chat only behind the structured-chat setting

agent.launch and orchestration worker-start read the same setting as the
renderer route; pin both states for Grok on each. The setting's description no
longer names only Codex and Claude, in every catalog.

* docs(acp): generic ACP comments say what holds for every agent, not Grok

Stop ends the session for every ACP agent, as in the common pattern; the
adoption hook comment goes (adoption is not planned); a failed start's child is
retried at the next start or quit.

* test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF

The Claude test wrote to the child's stdout and stderr through their Readable
type, which the node typecheck rejects; it now holds its own PassThrough
streams. The comments no longer credit the reverted supervisor change.

* feat(acp): a steer's cancel asks once and never ends the agent

The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle,
then close the connection, which ends the agent. A steer used it too, so a slow agent lost its
process just because the person added a message. requestSteerCancel() now sends session/cancel
once per prompt, cancels the agent's open requests and answers later permissions cancelled, and
never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel()
stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel
paths move into acp-prompt-cancel.ts over one cancel channel.

* chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge

* fix(acp): a repeated steer shares the cancel in flight; say what the caller owns

Per review: a second steer before the first write lands returns that write instead of resolving
early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that
fails instead must not take the steer until the caller rebuilds the session; the Stop's says a
prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives
the runtime a handler that would allow: the open permission's signal aborts and the late one never
reaches it.

* fix(native-chat): drop the stopDelivery the A3 merge doubled

* fix(acp): a steer's cancel asks Grok once and never ends it

A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself
cut that reply, as the common pattern does, and then both run; before, the queued first
message could not answer the bounded cancel and Orca ended Grok although Grok answered.
A Stop keeps the bounded cancel and its 4 s grace.

* fix(acp): a permission Grok asks with no prompt of Orca's running is declined

During a turn Grok began itself nobody asked it to act, so the request is answered
cancelled at once instead of opening a card that waits, as the common pattern does.

* fix(acp): a Grok that dies while starting is reported with its own last words

A dying process's stdout ends before its exit is seen, so the start failed as a closed
connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by
the Stop grace (or a Close/Stop), for the exit before it is told.

* test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled

* test(native-chat): main's Stop-note test builds its turn context with the agent registry

* test(claude): say why the close test's fake child cast is safe

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(native-chat): build the Stop-opened-turn test's identity with the opaque handle

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.

* feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore

A chat whose saved conversation the agent cannot reopen can now continue in a
fresh one: the handle chain records the new conversation as a creation that
replaces the lost one (which, why, and when), keeping every earlier link.

Rows keep a shape older builds read: the stored chain starts at the latest
replacement and carries the earlier links inside it.

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.

* docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds

* refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row

Older builds only read Claude and Codex records, so the nested stored form
protected rows no replacement can reach while adding a cap mismatch after a
downgrade. Store the chain as held, refuse a replacement in a Claude or Codex
chain until one has a stored shape older builds read, and refuse a supersession
key on a replacement that names no creation in the chain.

* refactor(native-chat): read hosts' structured agents from the app-shell services

Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there.

* Use current provider handles in transition tests

* Use current provider handles in timeline fixtures

* test(native-chat): prove replacement rows survive downgrade and re-upgrade

* Require the ACP directory in the runtime import check

* test(ratchet): require src/main/acp now that this PR lands it

* feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row

When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start.

* chore(acp): rewrap the acquire header comment

* test(acp): a start closed while the agent reopens fails without opening or announcing a new session

* Let ACP connections own their supervised agent process

* Preserve ACP cleanup evidence and isolate exit observers

* Expose ACP cleanup observations and type the permission fixture

* refactor(native-chat): the registered-agents capability lives in its own module

Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability
was added; like main's other per-feature capabilities, it now has its own module, and importers read
it from there.

* refactor(acp): one connection owns the Grok process and its protocol

D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the
process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and
tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start
keeps that same connection for the next close to retry rather than spawning another process.

Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's
exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as
exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to
pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace.

The adapter owns what the protocol no longer does: one session/cancel per running prompt however many
steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer
answers every open agent request the person has not already answered with the agent's own cancelled
reply. An answer already being saved when the Stop lands is sent.

Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel
write is retried, a real process exiting while a child holds its stdout ends the session, and the
existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig.

* fix(acp): Grok signs in on its own machine with its API key or cached sign-in

When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs
on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and
Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing
not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new
and reopened sessions through the protocol client's caller-named method (authenticate, then retry once).
No new sign-in UI; interactive methods are never chosen.

* fix(acp): the adapter decides which of Grok's requests reach the person

The turn owner now admits every agent request, permission or question, from its own turn state: a
request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a
question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's
own cancelled reply and opens no card, so a question arriving after Stop or during a steer never
appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is
still sent. The protocol client's abort-on-cancel path is no longer used: after the connection
change its request signal aborts only when the connection closes.

* feat(native-chat): add inactive Pi RPC transport foundation

* fix(acp): a plan Grok proposes shows as a plan, with no approval card

When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a
blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the
plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates
already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and
wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on
the person's behalf. Dialects gain settleRequest for requests answered without asking anyone.

* fix(orchestration): worker-start opens a Grok worker in a terminal, as before

With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured
worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok
worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a
terminal worker as before. agent.launch and the app's own launches still open Grok as a structured
chat. Temporary until structured workers take registered agents.

* fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it

A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's
schema, or one too large to read) left the turn running: the next message became a steer with nothing
to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any
prompt failure now ends the turn as failed (a failed-turn row without words, since none are the
agent's) and settles the send, so the next message goes. Only a closed connection keeps the send
running, for the connection-loss path to settle.

* feat(native-chat): add bounded RPC reading control

* fix(acp): send Grok's prompt-identity extension only to agents that echo it

session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect
echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension;
other ACP agents get a plain prompt.

* refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main

This reverts 0ef6d21815. That commit moved the capability to its own module only because main's
protocol-version.ts was then one counted line over its limit; main now defines it there itself within
the limit, and main's new restart test imports it from there. Main's test also reads the desktop's
capability list as an older client; on this branch the desktop advertises registered agents, so its
older client is that list without this one capability.

* test(acp): read the sign-in method with a schema, not a type assertion

* refactor(native-chat): composer transport and Stop control in their own modules

Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts
to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines.
The composer's transport (sends, commands, options, image acceptance) moves to
use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to
structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's
'local' | 'remote' is now a typed return.

* test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes

Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed
{ agent } objects (CI typecheck TS2322).

* Add Pi structured chat over its native RPC mode

* Use shared provider spawn identity and base environment

* Complete Pi startup ownership and prompt delivery safeguards

* Use generic failure copy for Pi RPC sessions

* Admit finalized Pi output before session eviction

* Apply Pi client capability checks to all chat routes

* Keep older Pi versions on terminal chat

* Use supervised process support checks for Pi launches

* Complete the Pi location-test adapter fixture

* Retain the settled-work extraction in the main merge

* Use the settled-work module in the new reasoning sweep test

* Test that Pi prompt alerts skip clients that cannot read Pi

Main added prompt alerts to the turn-completion stream; the merge extended Pi's audience filter to them.

* Pin why a timed-out write closes the agent connection only once it is being written

A line still waiting in the queue is dropped and only its request fails, as before. A line already handed to the stream cannot be taken back and would hold every later write, a cancel included, so that timeout closes the connection; tests now cover both for the shared queue and for ACP.

* Type the prompt-alert fixture as the wire's prompt attention

Its host id is a branded type; the literal only checks under the wire type, as the completion fixture beside it does.

* Run the Pi launch-resolution test in the Node runtime project

It opens a real SQLite record store, which main's new boundary check now requires to run on Node.

* Pin that a Pi chat reopened after a restart resumes its stored session file

A fresh record store reading the journal back from disk must hand the launch the session file the chat proved before the restart, and the spawn must carry it as --session.

* Show an explicitly empty text answer as "Empty answer", not as unreadable

Live QA: answering a Pi input dialog with nothing (which Pi accepts) left the resolved card reading "Selected answer unavailable", the wording for an answer Orca cannot read. The receipt joined the answer's labels and text and turned an empty result into "no answer". It now keeps an answer whose text is present but empty (or whitespace) as an empty answer, shown as "Empty answer"; an answer naming neither text nor a known option still reads as unavailable.

Also pins that the editor dialog submits the text the user edited, not its prefill.

* refactor(pi): check and resolve Pi's binary through the shared version probe and command resolver

Pi's own `--version` runner and PATH lookup are gone. Create support and
every launch now resolve the binary through resolveStructuredAgentCommand,
so the Pi Command setting picks the binary both check and spawn, and an
unrunnable Command is refused at launch with agentCommandNotRunnable as for
the other agents. The version check runs through probeAgentCliVersion with
the same rule as before: stable 1.x of the current package; the older 0.x
package keeps the terminal chat. Pi's launch reads the shared per-agent
environment overlay and Command settings instead of Pi-only runtime deps.

* feat(pi): run stable Pi from 0.84.0 in the structured chat, not only 1.x
This commit is contained in:
Brennan Benson
2026-10-07 17:44:45 -07:00
committed by GitHub
parent 369b202b01
commit c71601f51c
115 changed files with 7992 additions and 320 deletions
@@ -49,7 +49,9 @@ export const STRUCTURED_CHAT_LANES = [
{ directory: ['src', 'shared'] },
{ directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ },
{ directory: ['src', 'main', 'provider-process'] },
{ directory: ['src', 'main', 'acp'] }
{ directory: ['src', 'main', 'acp'] },
{ directory: ['src', 'main', 'jsonl-rpc'] },
{ directory: ['src', 'main', 'pi'], basename: /^rpc-/ }
]
export function collectStructuredChatEntryPoints(root = ROOT) {
@@ -39,6 +39,8 @@ describe('structured chat coverage', () => {
'src/main/runtime/structured-agent-session-host.ts': 'export {}',
'src/main/provider-process/provider-process-teardown.ts': 'export {}',
'src/main/acp/acp-structured-session-adapter.ts': 'export {}',
'src/main/jsonl-rpc/peer.ts': 'export {}',
'src/main/pi/rpc-launch.ts': 'export {}',
'src/shared/agent-session-record.ts': 'export {}'
}
@@ -55,7 +57,8 @@ describe('structured chat coverage', () => {
'src/main/runtime/structured-agent-runtime-registrations.ts',
'src/main/runtime/rpc/methods/structured-agent-session-agents.ts',
'src/main/acp/adapter.ts',
'src/main/provider-process/worker.ts'
'src/main/provider-process/worker.ts',
'src/main/jsonl-rpc/nested/dialect.ts'
]
const excluded = [
'src/main/native-chat/reader.test.ts',
@@ -73,7 +76,10 @@ describe('structured chat coverage', () => {
'src/main/runtime/orca-runtime-tests/structured-agent-session-host.ts',
'src/shared/types.d.ts',
'src/main/runtime/other.ts',
'src/main/runtime/rpc/methods/browser.ts'
'src/main/runtime/rpc/methods/browser.ts',
'src/main/jsonl-rpc/peer.test.ts',
'src/main/pi/rpc-launch.test.ts',
'src/main/pi/titlebar-extension-service.ts'
]
const root = fixture(
Object.fromEntries([...sources, ...excluded].map((file) => [file, 'export {}']))
@@ -90,7 +96,9 @@ describe('structured chat coverage', () => {
Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`))
)
expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`)
expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7)
expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(
Object.keys(requiredLanes).length
)
}
)
@@ -101,6 +109,8 @@ describe('structured chat coverage', () => {
...requiredLanes,
'src/main/acp/adapter.ts': "import 'acp-desktop-package'",
'src/main/provider-process/worker.ts': "import 'provider-desktop-package'",
'src/main/jsonl-rpc/peer.ts': "import 'rpc-desktop-package'",
'src/main/pi/rpc-launch.ts': "import 'pi-desktop-package'",
'node_modules/acp-desktop-package/package.json': JSON.stringify({
main: 'index.js',
sideEffects
@@ -110,12 +120,24 @@ describe('structured chat coverage', () => {
main: 'index.js',
sideEffects
}),
'node_modules/provider-desktop-package/index.js': "require('electron')"
'node_modules/provider-desktop-package/index.js': "require('electron')",
'node_modules/rpc-desktop-package/package.json': JSON.stringify({
main: 'index.js',
sideEffects
}),
'node_modules/rpc-desktop-package/index.js': "require('electron')",
'node_modules/pi-desktop-package/package.json': JSON.stringify({
main: 'index.js',
sideEffects
}),
'node_modules/pi-desktop-package/index.js': "require('electron')"
})
const current = await collectElectronImporters(collectStructuredChatEntryPoints(root))
expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([
'acp-desktop-package/index.js',
'provider-desktop-package/index.js'
'pi-desktop-package/index.js',
'provider-desktop-package/index.js',
'rpc-desktop-package/index.js'
])
}
)
@@ -150,6 +150,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts',
'src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts',
'src/main/orcad/orcad-automations.test.ts',
'src/main/pi/rpc-launch-resolution.test.ts',
'src/main/runtime/agent-session-conversation-clear-commit.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/agent-session-death-evidence-persistence.test.ts',
@@ -129,4 +129,71 @@ describe('MobileNativeChatQuestion', () => {
await act(async () => cancel.props.onPress())
expect(onCancel).toHaveBeenCalledWith({ itemId: 'question-1', expectedRevision: 7 })
})
it('prefills an editor and sends its whitespace unchanged', async () => {
const onAnswer = vi.fn(async () => true)
await act(async () => {
renderer = create(
createElement(MobileNativeChatQuestion, {
question: {
question: 'Edit the draft',
options: [],
multiSelect: false,
optionTokens: [],
freeTextToken: 'editor-token',
freeTextInput: {
allowEmpty: true,
multiline: true,
initialValue: ' draft\n',
placeholder: 'Write here'
}
},
onAnswer
})
)
})
const input = renderer.root.findByType('TextInput')
expect(input.props).toMatchObject({
value: ' draft\n',
placeholder: 'Write here',
multiline: true
})
await act(async () => input.props.onChangeText(' \n '))
const send = renderer.root.findByProps({ accessibilityLabel: 'Send reply' })
expect(send.props.disabled).toBe(false)
await act(async () => send.props.onPress())
expect(onAnswer).toHaveBeenCalledWith(`editor-token:${encodeURIComponent(' \n ')}`)
})
it('sends an empty allowed answer and still disables an empty legacy answer', async () => {
const onAnswer = vi.fn(async () => true)
const question = {
question: 'Input',
options: [],
multiSelect: false,
optionTokens: [],
freeTextToken: 'input-token'
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatQuestion, {
question: { ...question, freeTextInput: { allowEmpty: true, multiline: false } },
onAnswer
})
)
})
const send = renderer.root.findByProps({ accessibilityLabel: 'Send reply' })
expect(renderer.root.findByType('TextInput').props.multiline).toBe(false)
expect(send.props.disabled).toBe(false)
await act(async () => send.props.onPress())
expect(onAnswer).toHaveBeenCalledWith('input-token:')
await act(async () =>
renderer.update(createElement(MobileNativeChatQuestion, { question, onAnswer }))
)
expect(renderer.root.findByProps({ accessibilityLabel: 'Send reply' }).props.disabled).toBe(
true
)
})
})
+14 -10
View File
@@ -30,13 +30,13 @@ export function MobileNativeChatQuestion({
onCollapse
}: Props): React.JSX.Element {
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState<number[]>([])
const [freeText, setFreeText] = useState('')
const [freeText, setFreeText] = useState(() => question.freeTextInput?.initialValue ?? '')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const allowOther = question.allowOther !== false
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
const answerText = question.freeTextInput?.allowEmpty ? freeText : freeText.trim()
const toggle = (optionIndex: number): void => {
setSelectedOptionIndexes((prev) =>
@@ -72,8 +72,8 @@ export function MobileNativeChatQuestion({
return
}
const answer =
question.freeTextToken && trimmedFreeText.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
question.freeTextToken && answerText.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
: formatQuestionAnswerByIndexes(question, selectedOptionIndexes)
if (await sendAnswer(answer)) {
setFreeText('')
@@ -81,20 +81,21 @@ export function MobileNativeChatQuestion({
}
const submitFreeText = async (): Promise<void> => {
if (trimmedFreeText.length === 0) {
if (!allowOther || (answerText.length === 0 && !question.freeTextInput?.allowEmpty)) {
return
}
const answer =
question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText)
: formatQuestionFreeTextAnswer(question, trimmedFreeText)
? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText)
: formatQuestionFreeTextAnswer(question, answerText)
if (await sendAnswer(answer)) {
setFreeText('')
}
}
const canSubmitMulti = selectedOptionIndexes.length > 0 && !sending
const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
const canSendFreeText =
allowOther && (answerText.length > 0 || question.freeTextInput?.allowEmpty === true) && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
@@ -178,12 +179,15 @@ export function MobileNativeChatQuestion({
style={mobileNativeChatInputStyles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholder={
question.freeTextInput?.placeholder ??
(hasOptions ? 'Or type a reply…' : 'Type your reply…')
}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
multiline={question.freeTextInput?.multiline ?? true}
/>
<Pressable
accessibilityLabel="Send reply"
@@ -148,6 +148,21 @@ describe('formatQuestionAnswer', () => {
formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
).toBe(`target:${encodeURIComponent('hi there')}`)
})
it('encodes empty and whitespace answers only when allowed', () => {
const withInput: MobileChatQuestion = {
...numbered,
freeTextToken: 'target',
freeTextInput: { allowEmpty: true }
}
expect(formatQuestionFreeTextAnswer(withInput, '')).toBe('target:')
expect(formatQuestionFreeTextAnswer(withInput, ' \n ')).toBe(
`target:${encodeURIComponent(' \n ')}`
)
expect(formatQuestionFreeTextAnswer({ ...withInput, freeTextInput: undefined }, ' \n ')).toBe(
''
)
})
})
describe('mobileChatQuestionKey', () => {
@@ -3,6 +3,8 @@
// have no structured signal, so we parse the text conservatively and only treat
// it as a question when a clear option list is present.
import type { AgentJournalFreeTextInput } from '../../../src/shared/agent-session-journal-types'
export type MobileChatQuestion = {
question: string
/** Structured prompt identity, present only for durable host prompts. */
@@ -19,6 +21,7 @@ export type MobileChatQuestion = {
optionDescriptions?: (string | undefined)[]
/** Opaque prefix used when free-text answers must target a specific prompt. */
freeTextToken?: string
freeTextInput?: AgentJournalFreeTextInput
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -203,11 +206,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
}
export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string {
const trimmed = text.trim()
if (trimmed.length === 0) {
const answer = question.freeTextInput?.allowEmpty ? text : text.trim()
if (answer.length === 0 && !question.freeTextInput?.allowEmpty) {
return ''
}
return question.freeTextToken
? `${question.freeTextToken}:${encodeURIComponent(trimmed)}`
: formatQuestionAnswer(question, [trimmed])
? `${question.freeTextToken}:${encodeURIComponent(answer)}`
: question.freeTextInput?.allowEmpty
? answer
: formatQuestionAnswer(question, [answer])
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalFreeTextInput } from '../../../src/shared/agent-session-journal-types'
import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question'
import {
projectStructuredQuestion,
structuredQuestionResponseTarget,
type StructuredQuestionItem
} from './mobile-structured-agent-prompts'
function prompt(freeTextInput?: AgentJournalFreeTextInput): StructuredQuestionItem {
return {
itemId: 'item-1',
revision: 3,
sequence: 1,
observedAt: 1,
body: {
kind: 'question',
question: 'Edit the draft',
options: [],
freeTextQuestionId: 'q1',
...(freeTextInput ? { freeTextInput } : {}),
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
}
describe('mobile structured free-text questions', () => {
it('projects editor input metadata onto the existing question card', () => {
const freeTextInput = {
allowEmpty: true,
multiline: true,
initialValue: 'draft',
placeholder: 'Edit here'
}
expect(projectStructuredQuestion(prompt(freeTextInput))?.freeTextInput).toEqual(freeTextInput)
expect(projectStructuredQuestion(prompt())?.freeTextInput).toBeUndefined()
})
it('accepts empty and whitespace text only for the matching allowed prompt', () => {
const current = prompt({ allowEmpty: true })
const projected = projectStructuredQuestion(current)!
for (const answer of ['', ' \n ']) {
const response = formatQuestionFreeTextAnswer(projected, answer)
expect(structuredQuestionResponseTarget(response, current)).toEqual({
itemId: 'item-1',
expectedRevision: 3,
answer: { questionId: 'q1', optionIds: [], other: answer }
})
expect(structuredQuestionResponseTarget(response, prompt())).toBeNull()
}
expect(
structuredQuestionResponseTarget(formatQuestionFreeTextAnswer(projected, ''), {
...current,
revision: 4
})
).toBeNull()
})
it('keeps legacy trimming for ordinary free-text answers', () => {
const current = prompt()
const projected = projectStructuredQuestion(current)!
expect(
structuredQuestionResponseTarget(
formatQuestionFreeTextAnswer(projected, ' draft '),
current
)
).toMatchObject({ answer: { other: 'draft' } })
expect(structuredQuestionResponseTarget('', current)).toBeNull()
})
})
@@ -192,6 +192,7 @@ export function projectStructuredQuestion(
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
multiSelect: false,
allowOther: Boolean(prompt.body.freeTextQuestionId),
...(prompt.body.freeTextInput ? { freeTextInput: prompt.body.freeTextInput } : {}),
optionTokens: prompt.body.options.map((option) =>
encodePromptToken({
kind: 'question-option',
@@ -258,8 +259,13 @@ export function structuredQuestionResponseTarget(
}
const freeText = decodeQuestionFreeTextAnswer(response)
if (freeText) {
const answer = freeText.answer.trim()
return answer.length > 0
const allowEmpty =
currentPrompt?.itemId === freeText.payload.itemId &&
currentPrompt.revision === freeText.payload.revision &&
currentPrompt.body.freeTextQuestionId === freeText.payload.questionId &&
currentPrompt.body.freeTextInput?.allowEmpty === true
const answer = allowEmpty ? freeText.answer : freeText.answer.trim()
return answer.length > 0 || allowEmpty
? {
itemId: freeText.payload.itemId,
expectedRevision: freeText.payload.revision,
@@ -272,6 +278,7 @@ export function structuredQuestionResponseTarget(
}
const [question] = agentSessionPromptQuestions(currentPrompt.body)
const trimmed = response.trim()
const allowEmpty = currentPrompt.body.freeTextInput?.allowEmpty === true
const option = currentPrompt.body.options.find(
(candidate) => candidate.id === response || candidate.label === trimmed
)
@@ -282,11 +289,11 @@ export function structuredQuestionResponseTarget(
answer: { questionId: question.id, optionIds: [option.id] }
}
}
return question && currentPrompt.body.freeTextQuestionId && trimmed
return question && currentPrompt.body.freeTextQuestionId && (trimmed || allowEmpty)
? {
itemId: currentPrompt.itemId,
expectedRevision: currentPrompt.revision,
answer: { questionId: question.id, optionIds: [], other: trimmed }
answer: { questionId: question.id, optionIds: [], other: allowEmpty ? response : trimmed }
}
: null
}
@@ -123,6 +123,43 @@ describe('mobile structured grouped questions', () => {
])
})
it('projects editor metadata and preserves allowed empty and whitespace answers', () => {
const editor = question({
options: [],
freeTextInput: {
allowEmpty: true,
multiline: true,
initialValue: 'draft',
placeholder: 'Edit response'
}
})
const projected = projectGroupedQuestion([editor], null, PROMPT_KEY)!
expect(projected.freeTextInput).toEqual(editor.freeTextInput)
for (const answer of ['', ' \n ']) {
const result = advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(projected, answer),
questions: [editor],
draft: null,
promptKey: PROMPT_KEY
})
expect(result).toEqual({
kind: 'submit',
answers: [{ questionId: 'q1', optionIds: [], other: answer }]
})
}
const legacy = question({ options: [] })
expect(
advanceGroupedQuestion({
response: formatQuestionFreeTextAnswer(projected, ''),
questions: [legacy],
draft: null,
promptKey: PROMPT_KEY
})
).toBeNull()
})
it('keeps selected options and other text for grouped multi-select answers', () => {
const questions = [SECOND]
const only = projectGroupedQuestion(questions, null, PROMPT_KEY)!
@@ -122,6 +122,7 @@ export function projectGroupedQuestion(
...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}),
multiSelect: question.multiSelect,
allowOther: Boolean(question.freeTextQuestionId),
...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}),
optionTokens: question.options.map((option) =>
encodeGroupedToken({
kind: 'option',
@@ -155,11 +156,12 @@ function answerFromResponse(
const trimmed = part.trim()
const freeText = decodeGroupedFreeTextAnswer(trimmed)
if (freeText) {
const answer = freeText.answer.trim()
const allowEmpty = question.freeTextInput?.allowEmpty === true
const answer = allowEmpty ? freeText.answer : freeText.answer.trim()
if (
freeText.promptKey !== promptKey ||
freeText.questionId !== question.id ||
answer.length === 0 ||
(answer.length === 0 && !allowEmpty) ||
other !== undefined
) {
return null
@@ -182,14 +184,14 @@ function answerFromResponse(
if (optionIds.some((optionId) => !offered.has(optionId))) {
return null
}
if (other && !question.freeTextQuestionId) {
if (other !== undefined && !question.freeTextQuestionId) {
return null
}
const answerCount = optionIds.length + (other ? 1 : 0)
const answerCount = optionIds.length + (other !== undefined ? 1 : 0)
if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) {
return null
}
return { questionId: question.id, optionIds, ...(other ? { other } : {}) }
return { questionId: question.id, optionIds, ...(other !== undefined ? { other } : {}) }
}
/**
+16
View File
@@ -293,6 +293,22 @@ describe('ACP JSON-RPC peer', () => {
output.destroy()
})
it('closes when a request times out mid-write, so a cancel never waits behind it', async () => {
vi.useFakeTimers()
const input = new PassThrough()
const output = new Writable({ write() {} })
const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 })
peers.push(peer)
const timedOut = expect(peer.request('stuck', {})).rejects.toBeInstanceOf(
AcpRequestTimeoutError
)
await vi.advanceTimersByTimeAsync(20)
await timedOut
expect(peer.closed).toBe(true)
await expect(peer.notify('session/cancel', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError)
input.destroy()
})
it('fails pending requests on an output stream error', async () => {
const { peer, agent } = fixture()
const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe')
+1 -16
View File
@@ -1,16 +1 @@
import type { Readable, Writable } from 'node:stream'
function ignoreLateError(): void {}
export function detachAcpStreamErrorHandler(
stream: Readable | Writable,
handler: (error: Error) => void
): void {
stream.removeListener('error', handler)
if (stream.closed) {
return
}
// Node may emit the write error after its callback has already closed the peer.
stream.on('error', ignoreLateError)
stream.once('close', () => stream.removeListener('error', ignoreLateError))
}
export { detachProviderStreamErrorHandler as detachAcpStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary'
@@ -27,7 +27,11 @@ import {
endAcpStructuredSession,
type AcpStructuredSession
} from './acp-structured-session'
import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts'
import {
ProviderAcquisitionStarts,
type ProviderStartAttempt
} from '../provider-process/provider-acquisition-starts'
import type { AcpStructuredConnection } from './acp-structured-connection'
import { waitForAcpExit } from './acp-structured-connection'
import { AcpConnectionClosedError } from './acp-errors'
import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop'
@@ -43,7 +47,7 @@ import { readAcpRecoveryHistory } from './acp-recovery-history'
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 AcpStructuredStarts()
private readonly starts = new ProviderAcquisitionStarts<AcpStructuredConnection>()
constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {}
@@ -75,7 +79,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
private async start(
input: StructuredAgentSessionAcquireInput,
attempt: AcpStartAttempt
attempt: ProviderStartAttempt<AcpStructuredConnection>
): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const generation = this.deps.mintGeneration?.() ?? randomUUID()
+6 -113
View File
@@ -1,119 +1,12 @@
import type { Writable } from 'node:stream'
import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue'
import { AcpConnectionClosedError } from './acp-errors'
type Write = {
line: string
resolve: () => void
reject: (error: Error) => void
detachAbort?: () => void
}
export class AcpWriteQueue {
private readonly queue: Write[] = []
private bytes = 0
private active?: Write
private terminalError?: Error
private detachDrain?: () => void
constructor(
private readonly output: Writable,
private readonly maxBytes: number,
private readonly onFailure: (error: Error) => void
) {}
write(line: string, signal?: AbortSignal): Promise<void> {
if (this.terminalError) {
return Promise.reject(this.terminalError)
}
if (signal?.aborted) {
return Promise.reject(signal.reason)
}
const bytes = Buffer.byteLength(line)
if (this.bytes + bytes > this.maxBytes) {
return Promise.reject(new Error('ACP write queue capacity exceeded'))
}
this.bytes += bytes
return new Promise((resolve, reject) => {
const write: Write = { line, resolve, reject }
const abort = (): void => {
const index = this.queue.indexOf(write)
if (index === -1) {
return
}
this.queue.splice(index, 1)
this.bytes -= bytes
write.detachAbort?.()
reject(signal?.reason)
}
signal?.addEventListener('abort', abort, { once: true })
write.detachAbort = () => signal?.removeEventListener('abort', abort)
this.queue.push(write)
this.flush()
export class AcpWriteQueue extends ProviderStdioWriteQueue {
constructor(output: Writable, maxBytes: number, onFailure: (error: Error) => void) {
super(output, maxBytes, onFailure, {
capacity: () => new Error('ACP write queue capacity exceeded'),
closed: () => new AcpConnectionClosedError('ACP output is not writable')
})
}
close(error: Error): void {
if (this.terminalError) {
return
}
this.terminalError = error
this.detachDrain?.()
this.active?.reject(error)
this.active = undefined
for (const write of this.queue.splice(0)) {
write.detachAbort?.()
write.reject(error)
}
this.bytes = 0
}
private flush(): void {
if (this.active || this.terminalError) {
return
}
const write = this.queue.shift()
if (!write) {
return
}
write.detachAbort?.()
this.active = write
if (this.output.destroyed || !this.output.writable) {
this.onFailure(new AcpConnectionClosedError('ACP output is not writable'))
return
}
let completed = false
let drained = false
let returned = false
const finish = (): void => {
if (!returned || !completed || !drained || this.terminalError) {
return
}
this.detachDrain?.()
this.active = undefined
this.bytes -= Buffer.byteLength(write.line)
write.resolve()
this.flush()
}
const onDrain = (): void => {
drained = true
finish()
}
this.output.once('drain', onDrain)
this.detachDrain = () => this.output.removeListener('drain', onDrain)
try {
const accepted = this.output.write(write.line, (error) => {
if (error) {
this.onFailure(error)
return
}
completed = true
finish()
})
drained ||= accepted
returned = true
finish()
} catch (error) {
this.onFailure(error instanceof Error ? error : new Error(String(error)))
}
}
}
@@ -159,6 +159,31 @@ describe('a structured launch that creates its own worktree', () => {
expect(result.receipt).toMatchObject({ reason: 'structured_support_unknown' })
})
it('keeps Pi on the terminal path when its RPC version is unsupported', async () => {
const h = harness({ createSupport: { supported: false, reason: 'agent' } })
const result = await h.run({
...CREATE_INTENT,
agent: 'pi',
prompt: { text: 'continue my task', delivery: 'submit' }
})
expect(h.calls).toEqual([
'createWorktree(startupAgent=undefined)',
'createSupport',
'createTerminalAgent'
])
expect(h.createStructuredSession).not.toHaveBeenCalled()
expect(h.createTerminalAgent).toHaveBeenCalledWith(
expect.objectContaining({
startupPrompt: 'continue my task'
})
)
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
expect(result.receipt).toMatchObject({
mode: 'terminal',
reason: 'structured_unsupported_on_host'
})
})
it('falls back only for a definitive structured refusal after the worktree exists', async () => {
const h = harness({
structuredCreateError: new AgentLaunchStructuredSessionRefusedError(
@@ -5,6 +5,7 @@ import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
@@ -42,6 +43,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
// The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists.
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
// Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer
+224
View File
@@ -0,0 +1,224 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { spawnProcess } from '../../shared/child-process/run-process'
import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close'
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor'
import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown'
import { JsonlRpcAgentConnection, type JsonlRpcAgentConnectionOptions } from './agent-connection'
const teardown = vi.hoisted(() =>
vi.fn<typeof terminateProviderProcessTree>(async () => 'unverifiable')
)
vi.mock('../provider-process/provider-process-teardown', () => ({
terminateProviderProcessTree: teardown
}))
const opened: { connection: JsonlRpcAgentConnection; exit: () => void }[] = []
const grace =
process.platform === 'win32' ? ROOT_ONLY_GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS
function fixture(
options: JsonlRpcAgentConnectionOptions = {},
behavior: { exitOnEnd?: boolean; processless?: boolean } = {}
) {
const child = Object.assign(new EventEmitter(), {
pid: behavior.processless ? undefined : 9_999_999,
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => true)
})
const spawn = vi.fn<typeof spawnProcess>(() => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fixture supplies piped stdio, pid, kill and process lifecycle events read by the shared supervisor.
return child as unknown as ReturnType<typeof spawnProcess>
})
if (behavior.exitOnEnd !== false) {
child.stdin.once('finish', () => child.emit('exit', 0, null))
}
const connection = new JsonlRpcAgentConnection(
{
command: 'fixture-rpc-agent',
args: ['--mode', 'rpc'],
cwd: '/execution-host/folder',
env: { PI_CODING_AGENT_DIR: '/host/account', STRIPPED: 'overlay' },
envToDelete: ['STRIPPED']
},
options,
spawn
)
opened.push({ connection, exit: () => child.emit('exit', 0, null) })
return { child, spawn, connection }
}
afterEach(async () => {
for (const { connection, exit } of opened.splice(0)) {
exit()
await connection.close()
}
teardown.mockReset().mockResolvedValue('unverifiable')
vi.useRealTimers()
})
describe('JSON-lines RPC process ownership', () => {
it('uses host-resolved launch data and exposes ordinary RPC replies', async () => {
const { connection, child, spawn } = fixture()
expect(spawn).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
cwd: '/execution-host/folder',
env: expect.objectContaining({ PI_CODING_AGENT_DIR: '/host/account' }),
stdio: ['pipe', 'pipe', 'pipe']
})
)
expect(spawn.mock.calls[0][0].env).not.toHaveProperty('STRIPPED')
const pending = connection.request('get_state')
child.stdout.write(
'{"type":"response","id":"orca-1","command":"get_state","success":true,"data":{"sessionFile":"/host/session.jsonl"}}\n'
)
expect(await pending).toEqual({ sessionFile: '/host/session.jsonl' })
expect(await connection.close()).toEqual({ root: 'exited', tree: null })
})
it('settles requests on proven exit with stdout open, and excludes stderr from public errors', async () => {
vi.useFakeTimers()
const onExit = vi.fn()
const { connection, child } = fixture({ onExit })
const pending = expect(connection.request('prompt')).rejects.toThrow('code 7')
child.stderr.write('sensitive stderr text')
child.emit('exit', 7, null)
child.emit('close', 7, null)
await vi.advanceTimersByTimeAsync(1_000)
await pending
expect(child.stdout.readableEnded).toBe(false)
expect(connection.rootVerdict).toBe('exited')
expect(onExit).toHaveBeenCalledExactlyOnceWith(expect.any(Error), {
expected: false,
exit: { code: 7, signal: null, processless: false }
})
expect(onExit.mock.calls[0][0].message).not.toContain('sensitive')
})
it('drains final response and event bytes written before the root exit', async () => {
const onRecord = vi.fn()
const onExit = vi.fn()
const { connection, child } = fixture({ onRecord, onExit })
const request = connection.request('get_state')
child.emit('exit', 0, null)
expect(connection.rootVerdict).toBe('exited')
expect(onExit).not.toHaveBeenCalled()
child.stdout.end(
'{"type":"response","command":"get_state","id":"orca-1","success":true,"data":{}}\n{"type":"agent_settled"}\n'
)
await expect(request).resolves.toEqual({})
await new Promise<void>((resolve) => setImmediate(resolve))
expect(onRecord).toHaveBeenCalledExactlyOnceWith({ type: 'agent_settled' })
expect(onExit).toHaveBeenCalledOnce()
})
it('closes a broken stdout transport and cleans up, while keeping host exit evidence separate', async () => {
vi.useFakeTimers()
const onExit = vi.fn()
const onClose = vi.fn()
const { connection, child } = fixture({ onExit, onClose }, { exitOnEnd: false })
const pending = expect(connection.request('prompt')).rejects.toThrow('stream closed')
child.stdout.emit('end')
await pending
expect(connection.closed).toBe(true)
expect(connection.rootVerdict).toBe('live')
expect(onClose).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(250)
expect(onClose).toHaveBeenCalledOnce()
expect(onExit).not.toHaveBeenCalled()
child.emit('exit', 0, null)
expect(connection.rootVerdict).toBe('exited')
await connection.close()
})
it('preserves the numeric exit when stdout closes first', async () => {
const onClose = vi.fn(),
onExit = vi.fn()
const { connection, child } = fixture({ onClose, onExit }, { exitOnEnd: false })
child.stdout.emit('end')
child.emit('exit', 7, null)
expect(onClose).not.toHaveBeenCalled()
expect(onExit).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('code 7') }),
expect.anything()
)
await connection.close()
})
it('joins close attempts and retries unproven exit without starting another process', async () => {
vi.useFakeTimers()
const { connection, child, spawn } = fixture({}, { exitOnEnd: false })
const first = connection.close()
const joined = connection.close()
await vi.advanceTimersByTimeAsync(grace + 1_000)
expect(await first).toEqual({ root: 'live', tree: 'unverifiable' })
expect(await joined).toEqual({ root: 'live', tree: 'unverifiable' })
expect(teardown).toHaveBeenCalledOnce()
teardown.mockImplementationOnce(async () => {
child.emit('exit', null, 'SIGKILL')
return 'unverifiable'
})
const retry = connection.close()
await vi.advanceTimersByTimeAsync(grace + 1_000)
expect(await retry).toEqual({ root: 'exited', tree: 'unverifiable' })
expect(spawn).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps retained descendant uncertainty when the root exits between attempts', async () => {
vi.useFakeTimers()
const { connection, child } = fixture({}, { exitOnEnd: false })
const close = connection.close()
await vi.advanceTimersByTimeAsync(grace + 1_000)
await close
child.emit('exit', null, 'SIGKILL')
await connection.close()
expect(connection.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' })
expect(connection.rootVerdict).toBe('exited')
})
it('requires processless close evidence after a failed spawn', async () => {
const onExit = vi.fn()
const { connection, child } = fixture({ onExit }, { processless: true, exitOnEnd: false })
const pending = expect(connection.request('prompt')).rejects.toThrow('ENOENT')
child.emit('error', new Error('ENOENT'))
await pending
expect(connection.rootVerdict).toBe('unverifiable')
expect(onExit).not.toHaveBeenCalled()
child.emit('close', null, null)
expect(connection.rootVerdict).toBe('exited')
expect(onExit.mock.calls[0][1].exit.processless).toBe(true)
})
it('handles stderr failure, and isolates failing close and exit observers', async () => {
const { connection, child } = fixture({
onClose: () => {
throw new Error('close observer')
},
onExit: () => {
throw new Error('exit observer')
},
onDiagnostic: () => {
throw new Error('diagnostic observer')
}
})
const pending = expect(connection.request('prompt')).rejects.toThrow('stderr failed')
child.stderr.emit('error', new Error('stderr failed'))
await pending
expect(() => child.emit('exit', 1, null)).not.toThrow()
})
it('validates peer limits before spawning', () => {
const spawn = vi.fn<typeof spawnProcess>()
expect(
() =>
new JsonlRpcAgentConnection(
{ command: 'fixture', args: [] },
{ peer: { requestTimeoutMs: 0 } },
spawn
)
).toThrow('timer duration')
expect(spawn).not.toHaveBeenCalled()
})
})
+182
View File
@@ -0,0 +1,182 @@
import { spawnProcess } from '../../shared/child-process/run-process'
import {
spawnManagedProviderProcess,
type ManagedProviderProcess,
type ProviderProcessExit
} from '../provider-process/managed-provider-process'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import type { ProviderProcessCloseResult } from '../provider-process/provider-process-close'
import {
JsonlRpcPeer,
JsonlRpcStreamClosedError,
type JsonlRpcPeerHandlers,
type JsonlRpcRecord
} from './peer'
import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-limits'
export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & {
peer?: JsonlRpcPeerOptions
onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void
}
/** The owning runtime keeps process exit evidence separate from transport closure. */
export class JsonlRpcAgentConnection {
private readonly managed: ManagedProviderProcess
private readonly peer: JsonlRpcPeer
private closing = false
private finishObservedExit?: () => void
private streamExitTimer?: ReturnType<typeof setTimeout>
constructor(
launch: ProviderProcessLaunch,
private readonly options: JsonlRpcAgentConnectionOptions = {},
spawnImpl: typeof spawnProcess = spawnProcess
) {
const peerOptions = resolveJsonlRpcPeerOptions(options.peer)
this.managed = spawnManagedProviderProcess(launch, {
spawnImpl,
site: 'jsonl-rpc-agent-teardown'
})
const managed = this.managed
this.peer = new JsonlRpcPeer(
managed.child.stdout,
managed.child.stdin,
{
...options,
onClose: (error) => {
const fail = (): void => {
if (this.closing || managed.rootVerdict === 'exited') {
return
}
void managed.close().then(
(result) => {
if (result.root !== 'exited') {
this.diagnose('Agent exit was not proven after RPC failure')
}
},
() => this.diagnose('Agent cleanup failed after RPC failure')
)
options.onClose?.(error)
}
if (
error instanceof JsonlRpcStreamClosedError &&
!this.closing &&
managed.rootVerdict !== 'exited'
) {
// EOF often precedes the exit carrying an import/startup failure's code.
this.streamExitTimer = setTimeout(fail, 250)
this.streamExitTimer.unref()
} else {
fail()
}
}
},
peerOptions
)
managed.child.on('error', this.onError)
managed.child.stderr.on('error', this.onError)
managed.onExit((exit) => {
clearTimeout(this.streamExitTimer)
const error = new Error(
exit.processless
? 'Agent process could not start'
: `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})`
)
this.peer.finishOnInputEnd(error)
const stdout = managed.child.stdout
let finished = false
let timer: ReturnType<typeof setTimeout> | undefined
const finish = (): void => {
if (finished) {
return
}
finished = true
clearTimeout(timer)
stdout.removeListener('end', finish)
stdout.removeListener('close', finish)
this.finishObservedExit = undefined
this.peer.close(error)
managed.child.removeListener('error', this.onError)
try {
options.onExit?.(error, { expected: this.closing, exit })
} catch {
this.diagnose('Agent exit observer failed')
}
}
this.finishObservedExit = finish
if (
this.closing ||
exit.processless ||
this.peer.closed ||
stdout.destroyed ||
stdout.readableEnded
) {
finish()
} else {
stdout.once('end', finish)
stdout.once('close', finish)
// A descendant can inherit stdout after the provider root exits.
timer = setTimeout(finish, 1_000)
timer.unref()
}
})
}
get pid(): number | undefined {
return this.managed.child.pid
}
get closed(): boolean {
return this.peer.closed
}
get rootVerdict(): ManagedProviderProcess['rootVerdict'] {
return this.managed.rootVerdict
}
get processless(): boolean {
return this.managed.processless
}
get lastCloseResult(): ManagedProviderProcess['lastCloseResult'] {
return this.managed.lastCloseResult
}
onExit(listener: () => void): void {
this.managed.onExit(listener)
}
request(
command: string,
params: Record<string, unknown> = {},
options: { timeoutMs?: number | null } = {}
): Promise<unknown> {
return this.peer.request(command, params, options)
}
send(record: JsonlRpcRecord): Promise<void> {
return this.peer.send(record)
}
pauseReading(): void {
this.peer.pauseReading()
}
resumeReading(): void {
this.peer.resumeReading()
}
/** Stop sends the dialect's abort first, then closes this connection through the supervisor. */
close(error?: Error): Promise<ProviderProcessCloseResult> {
this.closing = true
clearTimeout(this.streamExitTimer)
this.finishObservedExit?.()
this.peer.close(error)
return this.managed.close()
}
private readonly onError = (error: Error): void => this.peer.close(error)
private diagnose(message: string): void {
try {
this.options.onDiagnostic?.(message)
} catch {
/* Diagnostics cannot break cleanup. */
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { isSafeTimerDelayMs } from '../../shared/timer-delay'
export type JsonlRpcPeerOptions = {
maxLineBytes?: number
maxQueuedWriteBytes?: number
maxPendingRequests?: number
requestTimeoutMs?: number
}
export function resolveJsonlRpcPeerOptions(
options: JsonlRpcPeerOptions = {}
): Required<JsonlRpcPeerOptions> {
return {
maxLineBytes: positiveInteger(options.maxLineBytes, 8 * 1024 * 1024),
maxQueuedWriteBytes: positiveInteger(options.maxQueuedWriteBytes, 32 * 1024 * 1024),
maxPendingRequests: positiveInteger(options.maxPendingRequests, 128),
requestTimeoutMs: jsonlRpcRequestTimeout(options.requestTimeoutMs ?? 30_000)
}
}
function positiveInteger(value: number | undefined, fallback: number): number {
if (value === undefined) {
return fallback
}
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError('JSON-lines RPC limits must be positive finite integers')
}
return value
}
export function jsonlRpcRequestTimeout(value: number): number {
if (!isSafeTimerDelayMs(value) || value <= 0) {
throw new RangeError('JSON-lines RPC timeout must be a positive finite timer duration')
}
return value
}
+224
View File
@@ -0,0 +1,224 @@
import { PassThrough, Writable } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { JsonlRpcPeer, type JsonlRpcPeerHandlers } from './peer'
import type { JsonlRpcPeerOptions } from './peer-limits'
const peers: JsonlRpcPeer[] = []
function fixture(
handlers: JsonlRpcPeerHandlers = {},
options: JsonlRpcPeerOptions = {},
output = new PassThrough()
) {
const input = new PassThrough()
const peer = new JsonlRpcPeer(input, output, handlers, options)
peers.push(peer)
const reply = (record: unknown): void => {
input.write(`${JSON.stringify(record)}\n`)
}
return { input, output, peer, reply }
}
afterEach(() => {
for (const peer of peers.splice(0)) {
peer.close()
}
vi.useRealTimers()
})
describe('JSON-lines RPC peer', () => {
it('correlates control replies and preserves unknown event fields', async () => {
const onRecord = vi.fn()
const { peer, output, reply } = fixture({ onRecord })
const pending = peer.request('get_state')
expect(JSON.parse(output.read().toString())).toEqual({ type: 'get_state', id: 'orca-1' })
reply({ type: 'agent_start', future: { enabled: true } })
reply({
type: 'response',
id: 'orca-1',
command: 'get_state',
success: true,
data: { isStreaming: false }
})
expect(await pending).toEqual({ isStreaming: false })
expect(onRecord).toHaveBeenCalledExactlyOnceWith({
type: 'agent_start',
future: { enabled: true }
})
})
it('frames only on LF, preserving Unicode separators and split UTF-8 code points', () => {
const onRecord = vi.fn()
const { input } = fixture({ onRecord })
const bytes = Buffer.from('{"type":"message_update","text":"a\u2028b\u2029c😀"}\r\n')
const split = bytes.indexOf(Buffer.from('😀')) + 2
input.write(bytes.subarray(0, split))
expect(onRecord).not.toHaveBeenCalled()
input.write(bytes.subarray(split))
expect(onRecord).toHaveBeenCalledExactlyOnceWith({
type: 'message_update',
text: 'a\u2028b\u2029c😀'
})
})
it('pauses within a burst and preserves queued records and a partial suffix across repeated pauses', async () => {
const records: unknown[] = []
const { peer, input } = fixture({
onRecord: (record) => {
records.push(record)
peer.pauseReading()
}
})
input.write('{"type":"first"}\n{"type":"second"}\n{"type":"thi')
expect(records).toEqual([{ type: 'first' }])
input.write('rd"}\n{"type":"fourth"}\n')
peer.resumeReading()
expect(records).toEqual([{ type: 'first' }, { type: 'second' }])
peer.resumeReading()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(records).toEqual([{ type: 'first' }, { type: 'second' }, { type: 'third' }])
peer.resumeReading()
expect(records).toEqual([
{ type: 'first' },
{ type: 'second' },
{ type: 'third' },
{ type: 'fourth' }
])
})
it('ignores non-JSON output and routes id-less acknowledgements and dialogs', async () => {
const onRecord = vi.fn()
const onDiagnostic = vi.fn()
const { peer, input, output, reply } = fixture({ onRecord, onDiagnostic })
const writes: string[] = []
output.on('data', (chunk: Buffer) => writes.push(chunk.toString()))
input.write('extension banner\n\n')
await peer.send({ type: 'prompt', message: '/ask' })
reply({ type: 'extension_ui_request', id: 'dialog-1', method: 'input', title: 'Answer' })
await peer.send({ type: 'extension_ui_response', id: 'dialog-1', value: '' })
reply({ type: 'response', command: 'prompt', success: true })
expect(onRecord.mock.calls.map(([record]) => record.type)).toEqual([
'extension_ui_request',
'response'
])
expect(writes.join('')).toContain('"value":""')
expect(onDiagnostic).toHaveBeenCalledExactlyOnceWith('Ignored non-JSON agent output')
})
it('does not time out prompts waiting for slash expansion, but bounds control requests', async () => {
vi.useFakeTimers()
const { peer, reply } = fixture({}, { requestTimeoutMs: 100 })
const prompt = peer.request('prompt', { message: '/ask' }, { timeoutMs: null })
const control = expect(peer.request('get_state')).rejects.toThrow('get_state in time')
await vi.advanceTimersByTimeAsync(1_000)
await control
reply({ type: 'response', id: 'orca-1', command: 'prompt', success: true })
await expect(prompt).resolves.toBeUndefined()
const bounded = expect(peer.request('prompt', {}, { timeoutMs: 10 })).rejects.toThrow('in time')
await vi.advanceTimersByTimeAsync(10)
await bounded
expect(vi.getTimerCount()).toBe(0)
})
it.each([
{ type: 'response', command: 'wrong', success: true },
{ type: 'response', command: 'prompt', success: 'yes' },
{ type: 'reponse', command: 'prompt', success: true },
{ type: 4 }
])('rejects malformed identified replies without stranding a prompt: %j', async (record) => {
const { peer, reply } = fixture()
const pending = expect(peer.request('prompt')).rejects.toThrow('Invalid agent RPC response')
reply({ ...record, id: 'orca-1' })
await pending
})
it('ignores unsolicited replies and preserves structured command errors', async () => {
const onRecord = vi.fn()
const { peer, reply } = fixture({ onRecord })
const pending = expect(peer.request('get_state')).rejects.toThrow(
'No API key found for provider'
)
reply({ type: 'response', id: 'other', command: 'get_state', success: true })
reply({
type: 'response',
id: 'orca-1',
command: 'get_state',
success: false,
error: 'No API key found for provider'
})
await pending
expect(onRecord).not.toHaveBeenCalled()
})
it('closes on oversized records, with bounded diagnostics and no later dispatch', async () => {
const onRecord = vi.fn()
const { peer, input, reply } = fixture({ onRecord }, { maxLineBytes: 64 })
const pending = expect(peer.request('get_state')).rejects.toThrow('size limit')
input.write(`{"type":"secret","text":"${'x'.repeat(100)}`)
reply({ type: 'agent_settled' })
await pending
expect(peer.closed).toBe(true)
expect(onRecord).not.toHaveBeenCalled()
})
it('enforces pending capacity, and releases it after timeout and invalid response', async () => {
vi.useFakeTimers()
const { peer, reply } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 10 })
const first = expect(peer.request('get_state')).rejects.toThrow('in time')
await expect(peer.request('get_state')).rejects.toThrow('capacity')
await vi.advanceTimersByTimeAsync(10)
await first
const second = peer.request('get_state')
reply({ type: 'response', id: 'orca-2', command: 'get_state', success: true })
await second
expect(vi.getTimerCount()).toBe(0)
})
it('settles all requests and detaches listeners on EOF, and isolates close observers', async () => {
const { peer, input, output } = fixture({
onClose: () => {
throw new Error('observer')
}
})
const pending = expect(peer.request('prompt')).rejects.toThrow('stream closed')
input.emit('end')
input.emit('close')
await pending
expect(input.listenerCount('data')).toBe(0)
expect(output.listenerCount('drain')).toBe(0)
await expect(peer.send({ type: 'abort' })).rejects.toThrow('stream closed')
})
it('rejects future writes after a broken pipe and consumes delayed stream errors', async () => {
const output = new Writable({
autoDestroy: false,
write(_data, _encoding, callback) {
callback(new Error('broken pipe'))
}
})
const input = new PassThrough()
const peer = new JsonlRpcPeer(input, output)
peers.push(peer)
await expect(peer.send({ type: 'abort' })).rejects.toThrow('broken pipe')
await expect(peer.request('prompt')).rejects.toThrow('broken pipe')
expect(() => output.emit('error', new Error('late error'))).not.toThrow()
})
it('closes a stalled active control write on timeout so later aborts cannot wait on it', async () => {
vi.useFakeTimers()
const output = new Writable({ write() {} })
const peer = new JsonlRpcPeer(new PassThrough(), output, {}, { requestTimeoutMs: 10 })
peers.push(peer)
const pending = expect(peer.request('get_state')).rejects.toThrow('in time')
await vi.advanceTimersByTimeAsync(10)
await pending
expect(peer.closed).toBe(true)
await expect(peer.send({ type: 'abort' })).rejects.toThrow('in time')
})
it('rejects invalid limits before installing stream listeners', () => {
const input = new PassThrough()
expect(() => new JsonlRpcPeer(input, new PassThrough(), {}, { maxLineBytes: 0 })).toThrow(
'positive finite integers'
)
expect(input.listenerCount('data')).toBe(0)
})
})
+295
View File
@@ -0,0 +1,295 @@
import type { Readable, Writable } from 'node:stream'
import { z } from 'zod'
import {
createIncrementalNdjsonFramer,
encodeNdjson
} from '../../shared/main-process-ndjson-framer'
import { detachProviderStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary'
import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue'
import {
jsonlRpcRequestTimeout,
resolveJsonlRpcPeerOptions,
type JsonlRpcPeerOptions
} from './peer-limits'
const recordSchema = z.looseObject({ type: z.string() })
const responseSchema = z.looseObject({
type: z.literal('response'),
id: z.string().optional(),
command: z.string(),
success: z.boolean(),
data: z.unknown().optional(),
error: z.string().optional()
})
export type JsonlRpcRecord = z.infer<typeof recordSchema>
export class JsonlRpcStreamClosedError extends Error {
constructor() {
super('Agent JSON-lines RPC stream closed')
this.name = 'JsonlRpcStreamClosedError'
}
}
export class JsonlRpcResponseError extends Error {
constructor(
readonly command: string,
message: string
) {
super(message)
this.name = 'JsonlRpcResponseError'
}
}
export type JsonlRpcPeerHandlers = {
/** Includes deferred prompt acknowledgements without an id. */
/** A dialect failure is fatal: continuing would discard session or tool lifecycle evidence. */
onRecord?: (record: JsonlRpcRecord) => void
onClose?: (error: Error) => void
onDiagnostic?: (message: string) => void
}
type PendingRequest = {
command: string
resolve: (value: unknown) => void
reject: (error: Error) => void
timer?: ReturnType<typeof setTimeout>
}
/** Transport only: a dialect owns turns, dialogs, and session handles. */
export class JsonlRpcPeer {
private readonly limits: Required<JsonlRpcPeerOptions>
private readonly pending = new Map<string, PendingRequest>()
private readonly writer: ProviderStdioWriteQueue
private readonly framer: ReturnType<typeof createIncrementalNdjsonFramer>
private nextId = 0
private terminalError?: Error
private inputEndError?: Error
private readingPaused = false
constructor(
private readonly input: Readable,
private readonly output: Writable,
private readonly handlers: JsonlRpcPeerHandlers = {},
options: JsonlRpcPeerOptions = {}
) {
this.limits = resolveJsonlRpcPeerOptions(options)
this.writer = new ProviderStdioWriteQueue(
output,
this.limits.maxQueuedWriteBytes,
(error) => this.close(error),
{
capacity: () => new Error('JSON-lines RPC write queue capacity exceeded'),
closed: () => new Error('JSON-lines RPC output is not writable')
}
)
this.framer = createIncrementalNdjsonFramer(
(record) => this.dispatch(record),
(rejected) => {
if (rejected.kind === 'line-too-long') {
this.close(new Error('JSON-lines RPC record exceeded the size limit'))
} else {
this.diagnose('Ignored non-JSON agent output')
}
},
{
maxLineBytes: this.limits.maxLineBytes,
shouldPause: () => this.readingPaused || this.closed
}
)
input.setEncoding('utf8')
input.on('data', this.onData)
input.on('end', this.onEnd)
input.on('close', this.onEnd)
input.on('error', this.onError)
output.on('finish', this.onEnd)
output.on('close', this.onEnd)
output.on('error', this.onError)
if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) {
this.onEnd()
}
}
get closed(): boolean {
return this.terminalError !== undefined
}
/** A process exit can precede the last bytes already written to stdout. */
finishOnInputEnd(error: Error): void {
this.inputEndError = error
}
pauseReading(): void {
this.readingPaused = true
this.input.pause()
}
resumeReading(): void {
if (this.closed) {
return
}
this.readingPaused = false
try {
this.framer.resume()
if (!this.readingPaused && !this.closed) {
this.input.resume()
}
} catch (error) {
this.close(error instanceof Error ? error : new Error(String(error)))
}
}
request(
command: string,
params: Record<string, unknown> = {},
options: { timeoutMs?: number | null } = {}
): Promise<unknown> {
if (this.terminalError) {
return Promise.reject(this.terminalError)
}
if (this.pending.size >= this.limits.maxPendingRequests) {
return Promise.reject(new Error('JSON-lines RPC pending request capacity exceeded'))
}
let timeoutMs: number | null
try {
timeoutMs =
options.timeoutMs === null
? null
: jsonlRpcRequestTimeout(options.timeoutMs ?? this.limits.requestTimeoutMs)
} catch (error) {
return Promise.reject(error)
}
const id = `orca-${++this.nextId}`
const controller = new AbortController()
return new Promise((resolve, reject) => {
const timer =
timeoutMs === null
? undefined
: setTimeout(() => {
this.pending.delete(id)
const error = new Error(`Agent did not answer ${command} in time`)
controller.abort(error)
reject(error)
}, timeoutMs)
timer?.unref()
this.pending.set(id, { command, resolve, reject, timer })
void this.send({ ...params, type: command, id }, controller.signal).catch(
(error: unknown) => {
this.rejectPending(id, error instanceof Error ? error : new Error(String(error)))
}
)
})
}
/** The dialect may track acknowledgements independently of transport correlation. */
send(record: JsonlRpcRecord, signal?: AbortSignal): Promise<void> {
if (this.terminalError) {
return Promise.reject(this.terminalError)
}
try {
return this.writer.write(encodeNdjson(record, this.limits.maxLineBytes), signal)
} catch (error) {
return Promise.reject(error)
}
}
close(error: Error = new Error('Agent JSON-lines RPC stream closed')): void {
if (this.terminalError) {
return
}
this.terminalError = error
this.input.removeListener('data', this.onData)
this.input.removeListener('end', this.onEnd)
this.input.removeListener('close', this.onEnd)
detachProviderStreamErrorHandler(this.input, this.onError)
this.output.removeListener('finish', this.onEnd)
this.output.removeListener('close', this.onEnd)
detachProviderStreamErrorHandler(this.output, this.onError)
this.framer.reset()
this.writer.close(error)
for (const id of this.pending.keys()) {
this.rejectPending(id, error)
}
try {
this.handlers.onClose?.(error)
} catch {
this.diagnose('Agent RPC close observer failed')
}
}
private readonly onData = (chunk: string): void => {
try {
this.framer.feed(chunk)
} catch (error) {
this.close(error instanceof Error ? error : new Error(String(error)))
}
}
private readonly onEnd = (): void =>
this.close(this.inputEndError ?? new JsonlRpcStreamClosedError())
private readonly onError = (error: Error): void => this.close(error)
private diagnose(message: string): void {
try {
this.handlers.onDiagnostic?.(message)
} catch {
/* Diagnostics cannot break cleanup. */
}
}
private rejectPending(id: string, error: Error): void {
const pending = this.pending.get(id)
if (!pending) {
return
}
this.pending.delete(id)
clearTimeout(pending.timer)
pending.reject(error)
}
private dispatch(value: unknown): void {
if (this.closed) {
return
}
const parsed = recordSchema.safeParse(value)
if (!parsed.success) {
const identified = z.object({ id: z.string() }).safeParse(value)
if (identified.success) {
this.rejectPending(identified.data.id, new Error('Invalid agent RPC response'))
}
this.diagnose('Ignored invalid agent RPC record')
return
}
const record = parsed.data
if (
record.type !== 'response' &&
record.type !== 'extension_ui_request' &&
typeof record.id === 'string' &&
this.pending.has(record.id) &&
(Object.hasOwn(record, 'command') || Object.hasOwn(record, 'success'))
) {
this.rejectPending(record.id, new Error('Invalid agent RPC response'))
return
}
if (record.type === 'response' && typeof record.id === 'string') {
const pending = this.pending.get(record.id)
if (!pending) {
return
}
const response = responseSchema.safeParse(record)
if (!response.success || response.data.command !== pending.command) {
this.rejectPending(record.id, new Error('Invalid agent RPC response'))
return
}
this.pending.delete(record.id)
clearTimeout(pending.timer)
if (response.data.success) {
pending.resolve(response.data.data)
} else {
pending.reject(
new JsonlRpcResponseError(
pending.command,
response.data.error ?? `Agent rejected ${pending.command}`
)
)
}
return
}
this.handlers.onRecord?.(record)
}
}
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import {
closeProviderTimelineRigs,
openProviderTimelineRig,
pendingApproval,
SESSION,
GENERATION,
NAMESPACE
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { evictStructuredAgentSession } from '../native-chat/agent-session-wire/structured-agent-session-eviction'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { JsonlRpcTimelineLane } from './timeline-lane'
const cleanups: (() => void)[] = []
afterEach(async () => {
cleanups.splice(0).forEach((cleanup) => cleanup())
vi.useRealTimers()
await closeProviderTimelineRigs()
})
describe('Pi final tail before host eviction', () => {
it('admits text and settlements before the exit barrier while ordinary and lifecycle queues are full', async () => {
const { journal } = await openProviderTimelineRig({ agent: 'pi' })
const logging = testEventSinkLogging(SESSION)
const deferred = createDeferredStructuredAgentSessionEventSink({
...logging,
watermarks: { maxQueuedOperations: 1, maxLifecycleQueuedOperations: 1 }
})
deferred.bind({ journal, fence: 1, publish: () => {} })
const sink = providerTimelineSink(deferred.sink)
if (!sink) {
throw new Error('the sink must provide transitions')
}
const onFailed = vi.fn()
const lane = new JsonlRpcTimelineLane({
sink,
sessionId: SESSION,
agent: 'pi',
generation: GENERATION,
namespace: NAMESPACE,
pauseReading: vi.fn(),
resumeReading: vi.fn(),
onInputAccepted: vi.fn(),
onFailed
})
cleanups.push(
() => lane.dispose(),
() => deferred.close()
)
for (const event of [
{ type: 'turn.open', turn: 'run:1', at: 1 },
{ type: 'request.open', request: 'confirm:1', body: pendingApproval },
{
type: 'item.open',
item: 'tool:1',
body: { kind: 'tool-call', name: 'bash', input: {}, state: 'running' }
}
] as const) {
lane.apply([event])
await deferred.drained()
lane.retry()
}
const gate = Promise.withResolvers<void>()
cleanups.push(() => gate.resolve())
const append = (recordId: string, lifecycle = false) =>
deferred.sink.tryAppendItem?.(
{ provider: 'legacy', agent: 'pi', sessionId: SESSION, recordId },
{ kind: 'status', tone: 'info', text: recordId },
{ lifecycle, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
// The nested write joins behind this held read; later writes stay in the real journal queue.
const blocked = journal.readInOrder(() => {
expect(append('ordinary-full')).toEqual({ accepted: true })
return gate.promise
})
expect(append('lifecycle-full', true)).toEqual({ accepted: true })
expect(append('ordinary-refused')).toEqual({ accepted: false, reason: 'backpressure' })
expect(append('lifecycle-refused', true)).toEqual({ accepted: false, reason: 'backpressure' })
const before = journal.snapshot().items.length
vi.useFakeTimers()
lane.apply([
{ type: 'text.delta', item: { id: 'reply' }, channel: 'assistant', text: 'Final reply' },
{
type: 'item.update',
item: 'tail',
body: { kind: 'status', tone: 'info', text: 'Final status' }
}
])
lane.finalize()
lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 2 } }])
let landed = false
const barrier = deferred.drained().then((result) => {
landed = true
return result
})
await vi.advanceTimersByTimeAsync(2_501)
expect(journal.snapshot().items).toHaveLength(before)
expect(landed).toBe(false)
expect(onFailed).not.toHaveBeenCalled()
gate.resolve()
await blocked
await expect(barrier).resolves.toEqual({ ok: true })
const acknowledgeRelease = vi.fn(() => lane.dispose())
await evictStructuredAgentSession({
sessionId: SESSION,
eventSink: deferred,
logger: logging.logger,
acknowledgeRelease,
discardSink: vi.fn()
})
expect(acknowledgeRelease).toHaveBeenCalledOnce()
expect(journal.snapshot().items.map((item) => item.body)).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Final reply' }]
}),
expect.objectContaining({ kind: 'status', text: 'Final status' }),
expect.objectContaining({
kind: 'approval',
resolution: expect.objectContaining({ state: 'cancelled' })
}),
expect.objectContaining({ kind: 'tool-call', state: 'failed' })
])
)
})
it('fails excessive final output explicitly instead of leaving a refused tail waiting for eviction', async () => {
const failures: unknown[] = []
const deferred = createDeferredStructuredAgentSessionEventSink({
...testEventSinkLogging(SESSION),
onFailed: (error) => failures.push(error)
})
cleanups.push(() => deferred.close())
expect(
deferred.sink.tryAppendTransition?.({
finalTail: true,
lifecycle: true,
publish: false,
steps: [
{
kind: 'settlement',
settlementId: 'oversize',
reservedBytes: 16 * 1024 * 1024 + 1,
resolve: () => []
}
]
})
).toEqual({ accepted: false, reason: 'failed' })
expect(failures).toEqual([
expect.objectContaining({
message: 'structured agent-session final tail exceeded its reserved capacity'
})
])
await expect(deferred.drained()).resolves.toMatchObject({ ok: false })
})
})
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
closeProviderTimelineRigs,
openProviderTimelineRig,
refusingSink,
SESSION,
GENERATION,
NAMESPACE
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { JsonlRpcTimelineLane } from './timeline-lane'
const lanes: JsonlRpcTimelineLane[] = []
afterEach(async () => {
for (const lane of lanes.splice(0)) {
lane.dispose()
}
vi.useRealTimers()
await closeProviderTimelineRigs()
})
async function rig() {
const journal = await openProviderTimelineRig({ agent: 'pi' })
let blocked = true
const pauseReading = vi.fn(),
resumeReading = vi.fn(),
onInputAccepted = vi.fn(),
onFailed = vi.fn()
const lane = new JsonlRpcTimelineLane({
sink: refusingSink(journal.sink, () => blocked),
sessionId: SESSION,
agent: 'pi',
generation: GENERATION,
namespace: NAMESPACE,
pauseReading,
resumeReading,
onInputAccepted,
onFailed
})
lanes.push(lane)
return {
journal,
lane,
pauseReading,
resumeReading,
onInputAccepted,
onFailed,
unblock: () => {
blocked = false
}
}
}
describe('JSON-lines timeline admission', () => {
it('admits the final tail with the host settlement budget when ordinary writes are full', async () => {
const journal = await openProviderTimelineRig({ agent: 'pi' })
const lane = new JsonlRpcTimelineLane({
sink: {
...journal.sink,
tryAppendTransition: (transition) =>
transition.lifecycle
? journal.sink.tryAppendTransition(transition)
: { accepted: false, reason: 'backpressure' }
},
sessionId: SESSION,
agent: 'pi',
generation: GENERATION,
namespace: NAMESPACE,
pauseReading: vi.fn(),
resumeReading: vi.fn(),
onInputAccepted: vi.fn(),
onFailed: vi.fn()
})
lanes.push(lane)
lane.apply([
{
type: 'item.update',
item: 'final',
body: { kind: 'status', tone: 'info', text: 'Final tail' }
}
])
expect(await journal.rows()).toEqual([])
lane.finalize()
await lane.drained()
expect(await journal.rows()).toEqual([
expect.objectContaining({ body: { kind: 'status', tone: 'info', text: 'Final tail' } })
])
})
it('holds an entire parsed batch and accepts input only after journal admission', async () => {
const test = await rig()
test.lane.apply([
{ type: 'turn.open', turn: 'run:1', at: 1 },
{ type: 'input.accepted', clientMessageId: 'message:1', requestedAt: 1 },
{ type: 'turn.end', at: 2, state: 'completed', outcome: 'success' }
])
expect(test.pauseReading).toHaveBeenCalled()
expect(test.onInputAccepted).not.toHaveBeenCalled()
expect(await test.journal.turns()).toEqual([])
test.unblock()
test.lane.retry()
await test.lane.drained()
expect(test.onInputAccepted).toHaveBeenCalledExactlyOnceWith('message:1')
expect(test.resumeReading).toHaveBeenCalled()
expect(await test.journal.turns()).toHaveLength(1)
expect((await test.journal.turns())[0]).toMatchObject({
state: 'completed',
outcome: 'success'
})
})
it('retries paused input and clears its timer after draining', async () => {
const test = await rig()
vi.useFakeTimers()
test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }])
test.unblock()
await vi.advanceTimersByTimeAsync(250)
await test.lane.drained()
expect(test.lane.openTurnId).not.toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it('bounds data already parsed in a batch after the reader pauses', async () => {
const test = await rig()
test.lane.apply(
Array.from({ length: 257 }, (_, index) => ({
type: 'item.close',
item: `item:${index}`,
body: { kind: 'status', tone: 'info', text: 'held' }
}))
)
expect(test.onFailed).toHaveBeenCalledExactlyOnceWith('Agent timeline queue capacity exceeded')
await expect(test.lane.drained()).resolves.toBeUndefined()
expect(test.onInputAccepted).not.toHaveBeenCalled()
})
it('releases drain and turn waiters when a failed lane is disposed', async () => {
const test = await rig()
test.unblock()
test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }])
const turn = test.lane.openTurnId
expect(turn).not.toBeNull()
const waiting = test.lane.whenTurnLeaves(turn!)
test.lane.dispose()
await expect(waiting).resolves.toBeUndefined()
})
})
+190
View File
@@ -0,0 +1,190 @@
import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
createProviderTimelineAssembler,
type ProviderTimelineAssembler
} from '../native-chat/agent-session-timeline/provider-timeline-assembler'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity'
import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
const MAX_HELD_EVENTS = 256
const MAX_HELD_BYTES = 4 * 1024 * 1024
export type JsonlRpcTimelineLaneDeps = {
sink: ProviderTimelineSink
sessionId: string
agent: string
generation: string
namespace: string
pauseReading: () => void
resumeReading: () => void
onInputAccepted: (clientMessageId: string) => void
onFailed: (reason: string) => void
}
/** A dialect supplies semantic events; the shared assembler owns every journal write. */
export class JsonlRpcTimelineLane {
private readonly assembler: ProviderTimelineAssembler
private readonly identity: ReturnType<typeof createLegacyProviderTimelineIdentityScheme>
private readonly held: { event: ProviderTimelineEvent; bytes: number }[] = []
private heldBytes = 0
private draining = false
private ended = false
private finalizing = false
private retryTimer?: ReturnType<typeof setTimeout>
private readonly watchers = new Set<() => void>()
constructor(private readonly deps: JsonlRpcTimelineLaneDeps) {
this.assembler = createProviderTimelineAssembler({
...deps,
sink: {
...deps.sink,
tryAppendTransition: (transition) =>
deps.sink.tryAppendTransition(
this.finalizing ? { ...transition, lifecycle: true, finalTail: true } : transition
)
}
})
this.identity = createLegacyProviderTimelineIdentityScheme(deps)
}
get openTurnId(): string | null {
return this.assembler.openTurnId
}
beginCommand(command: StructuredAgentSessionCommandRun): void {
this.retry()
if (this.ended || this.held.length > 0) {
throw new Error('Provider timeline has not drained for this command')
}
this.assembler.beginCommand(command)
}
forgetCommand(turnId: string): void {
this.assembler.forgetCommand(turnId)
}
apply(events: readonly ProviderTimelineEvent[]): void {
if (this.ended) {
return
}
for (const event of events) {
const bytes = Buffer.byteLength(JSON.stringify(event))
if (this.held.length >= MAX_HELD_EVENTS || this.heldBytes + bytes > MAX_HELD_BYTES) {
this.fail('Agent timeline queue capacity exceeded')
return
}
this.held.push({ event, bytes })
this.heldBytes += bytes
this.retry()
}
}
retry(): void {
if (this.draining || this.ended) {
return
}
clearTimeout(this.retryTimer)
this.retryTimer = undefined
this.draining = true
try {
while (this.held.length > 0 && !this.ended) {
const entry = this.held[0]
const result = this.assembler.apply(entry.event)
if (!result.admission.accepted) {
if (result.admission.reason !== 'backpressure') {
this.fail(result.admission.reason)
} else {
this.deps.pauseReading()
this.retryTimer = setTimeout(() => this.retry(), 250)
this.retryTimer.unref()
}
return
}
this.held.shift()
this.heldBytes -= entry.bytes
if (entry.event.type === 'input.accepted' && !result.dropped) {
this.deps.onInputAccepted(entry.event.clientMessageId)
}
}
} finally {
this.draining = false
this.notify()
}
this.deps.resumeReading()
}
whenTurnLeaves(turnId: string): Promise<void> {
return new Promise((resolve) => {
const check = (): void => {
if (this.ended || this.openTurnId !== turnId) {
this.watchers.delete(check)
resolve()
}
}
this.watchers.add(check)
check()
})
}
drained(): Promise<void> {
return new Promise((resolve) => {
const check = (): void => {
if (this.ended || this.held.length === 0) {
this.watchers.delete(check)
resolve()
}
}
this.watchers.add(check)
check()
})
}
isRequestRow(itemId: string, key: string): boolean {
const row = parseAgentJournalItemKey(itemId)
const expected = this.identity.request({
generation: this.deps.generation,
key,
incarnation: 1
})
return (
row?.provider === 'legacy' &&
expected.provider === 'legacy' &&
row.agent === this.deps.agent &&
row.sessionId === this.deps.sessionId &&
(row.recordId === expected.recordId || row.recordId.startsWith(`${expected.recordId}#`))
)
}
flush(): void {
this.assembler.flush()
}
finalize(): void {
// Admission must precede the host's exit barrier even when lifecycle writes are full.
this.finalizing = true
this.retry()
this.flush()
}
dispose(): void {
this.ended = true
clearTimeout(this.retryTimer)
this.held.length = 0
this.heldBytes = 0
this.assembler.dispose()
this.notify()
}
private fail(reason: string): void {
this.dispose()
this.deps.onFailed(reason)
}
private notify(): void {
for (const check of this.watchers) {
check()
}
}
}
@@ -3,7 +3,8 @@ import type {
AgentJournalItemBody,
AgentJournalPromptOption,
AgentJournalQuestion,
AgentJournalQuestionItem
AgentJournalQuestionItem,
AgentJournalFreeTextInput
} from '../../../shared/agent-session-journal-types'
import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject'
import {
@@ -97,7 +98,8 @@ export function boundJournalPromptBody(
: {}),
...(body.freeTextQuestionId
? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) }
: {})
: {}),
...(body.freeTextInput ? { freeTextInput: boundFreeTextInput(body.freeTextInput) } : {})
}
}
@@ -110,7 +112,19 @@ function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuesti
options: boundPromptOptions(question.options),
...(question.freeTextQuestionId
? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) }
: {})
: {}),
...(question.freeTextInput ? { freeTextInput: boundFreeTextInput(question.freeTextInput) } : {})
}
}
function boundFreeTextInput(input: AgentJournalFreeTextInput): AgentJournalFreeTextInput {
return {
...(input.allowEmpty === undefined ? {} : { allowEmpty: input.allowEmpty }),
...(input.multiline === undefined ? {} : { multiline: input.multiline }),
...(input.initialValue === undefined
? {}
: { initialValue: boundPromptText(input.initialValue) }),
...(input.placeholder === undefined ? {} : { placeholder: boundPromptText(input.placeholder) })
}
}
@@ -14,6 +14,9 @@
// recovered: a new child is a new assembler, in a new acquisition generation.
import type { AgentType } from '../../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import type { StructuredAgentSessionCommandRun } from '../agent-session-wire/structured-agent-session-adapter'
import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink'
import {
@@ -47,6 +50,9 @@ export type { ProviderTimelineDropRule } from './provider-timeline-decision'
export type { ProviderTimelineApplyResult } from './provider-timeline-text-events'
export type ProviderTimelineAssembler = {
/** The host already wrote this command's running turn; the provider ends that same row. */
beginCommand(command: StructuredAgentSessionCommandRun): void
forgetCommand(turnId: string): void
apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult
/** The turn id of the open turn, as its row and a client's Stop name it. */
readonly openTurnId: string | null
@@ -191,6 +197,27 @@ export function createProviderTimelineAssembler(
}
return {
forgetCommand: (turnId) => {
if (state.open?.turnId === turnId) {
state.endTurn(state.open)
}
},
beginCommand: (command) => {
if (state.ended || state.open || state.stopped) {
throw new Error('Provider timeline cannot adopt a command while work is open')
}
const running = readAgentJournalTurn(command.running)
if (!running || running.state !== 'running' || running.turnId !== command.turnId) {
throw new Error('Provider timeline command is not a running host turn')
}
state.open = {
identity: command.identity,
itemId: agentJournalItemKey(command.identity),
turnId: command.turnId,
key: { source: 'provider', value: command.turnId },
running
}
},
apply,
get openTurnId() {
return state.open?.turnId ?? null
@@ -9,6 +9,7 @@ import type {
StructuredAgentSessionSinkWatermarks
} from './structured-agent-session-event-sink'
import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition'
import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation'
export type StructuredAgentSessionSinkOperation = {
sequence: number
@@ -16,6 +17,7 @@ export type StructuredAgentSessionSinkOperation = {
/** Lifecycle rows use their own bounded reservation budget. */
lifecycleBytes?: number
lifecycle?: boolean
finalTail?: true
/** Marks a publication, which writes no row: it runs at handover, or at its place in the
* journal's queue while writes wait there, and one still waiting with the same key is replaced
* by the next. Journal writes never coalesce:
@@ -26,7 +28,10 @@ export type StructuredAgentSessionSinkOperation = {
run: (target: StructuredAgentSessionEventTarget) => Promise<unknown> | void
}
type Admitted = StructuredAgentSessionSinkOperation & { superseded?: boolean }
type Admitted = StructuredAgentSessionSinkOperation & {
superseded?: boolean
releaseFinalTail?: () => void
}
export type StructuredAgentSessionDrainWaiter = {
through: number
@@ -59,6 +64,7 @@ export class StructuredAgentSessionSinkQueue {
private readonly buffered: Admitted[] = []
private readonly waitingPublications = new Map<string, Admitted>()
private readonly waiters: StructuredAgentSessionDrainWaiter[] = []
private readonly finalTail = new StructuredAgentSessionFinalTailReservation()
constructor(
private readonly deps: {
@@ -121,6 +127,7 @@ export class StructuredAgentSessionSinkQueue {
close(): void {
this.closed = true
this.dropBuffered()
this.finalTail.close()
this.updateBackpressure()
}
@@ -160,7 +167,8 @@ export class StructuredAgentSessionSinkQueue {
: key === undefined
? undefined
: this.waitingPublications.get(key)
const lifecycle = operation.lifecycle ?? options.lifecycle === true
const finalTail = operation.finalTail === true
const lifecycle = !finalTail && (operation.lifecycle ?? options.lifecycle === true)
const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0
const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes
const nextOperations = this.queuedOperations + (replaced ? 0 : 1)
@@ -169,6 +177,7 @@ export class StructuredAgentSessionSinkQueue {
const nextLifecycleOperations =
this.lifecycleQueuedOperations - (replaced?.lifecycle ? 1 : 0) + (lifecycle ? 1 : 0)
const exceedsOrdinary =
!finalTail &&
!lifecycle &&
(nextBytes > this.deps.watermarks.maxQueuedBytes ||
nextOperations > this.deps.watermarks.maxQueuedOperations)
@@ -180,11 +189,18 @@ export class StructuredAgentSessionSinkQueue {
this.setBackpressure(true)
return { accepted: false, reason: 'backpressure' }
}
const releaseFinalTail =
finalTail && key === undefined ? this.finalTail.reserve(operation.bytes) : undefined
if (finalTail && !releaseFinalTail) {
this.fail(new Error('structured agent-session final tail exceeded its reserved capacity'))
return { accepted: false, reason: 'failed' }
}
const accepted: Admitted = {
...operation,
sequence: ++this.acceptedSequence,
lifecycle,
lifecycleBytes
lifecycleBytes,
releaseFinalTail: releaseFinalTail ?? undefined
}
if (replaceAt >= 0) {
this.buffered.splice(replaceAt, 1)
@@ -243,6 +259,7 @@ export class StructuredAgentSessionSinkQueue {
}
private release(operation: Admitted): void {
operation.releaseFinalTail?.()
this.queuedBytes = Math.max(0, this.queuedBytes - operation.bytes)
this.queuedOperations = Math.max(0, this.queuedOperations - 1)
if (operation.lifecycle) {
@@ -313,6 +330,7 @@ export class StructuredAgentSessionSinkQueue {
this.deps.onFailed?.(error)
}
this.dropBuffered()
this.finalTail.close()
this.updateBackpressure()
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation'
describe('finalized provider tail reservations', () => {
it('bounds outstanding bytes and releases each admission once', () => {
const reservation = new StructuredAgentSessionFinalTailReservation()
const release = reservation.reserve(16 * 1024 * 1024)
expect(release).not.toBeNull()
expect(reservation.reserve(1)).toBeNull()
release?.()
release?.()
expect(reservation.reserve(16 * 1024 * 1024)).not.toBeNull()
expect(reservation.reserve(1)).toBeNull()
})
it('bounds operations even when they write no bytes', () => {
const reservation = new StructuredAgentSessionFinalTailReservation()
const releases = Array.from({ length: 512 }, () => reservation.reserve(0))
expect(releases.every(Boolean)).toBe(true)
expect(reservation.reserve(0)).toBeNull()
releases[0]?.()
expect(reservation.reserve(0)).not.toBeNull()
expect(reservation.reserve(0)).toBeNull()
})
it('abandons outstanding reservations on close and refuses later admission', () => {
const reservation = new StructuredAgentSessionFinalTailReservation()
const release = reservation.reserve(100)
reservation.close()
release?.()
reservation.close()
expect(reservation.reserve(0)).toBeNull()
})
it('refuses invalid sizes', () => {
const reservation = new StructuredAgentSessionFinalTailReservation()
for (const bytes of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(reservation.reserve(bytes)).toBeNull()
}
expect(reservation.reserve(0)).not.toBeNull()
})
})
@@ -0,0 +1,38 @@
const MAX_FINAL_TAIL_BYTES = 16 * 1024 * 1024
const MAX_FINAL_TAIL_OPERATIONS = 512
/** Finalized providers can admit their bounded tail before the host takes its exit barrier. */
export class StructuredAgentSessionFinalTailReservation {
private bytes = 0
private operations = 0
private closed = false
reserve(bytes: number): (() => void) | null {
if (
this.closed ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
this.bytes + bytes > MAX_FINAL_TAIL_BYTES ||
this.operations >= MAX_FINAL_TAIL_OPERATIONS
) {
return null
}
this.bytes += bytes
this.operations += 1
let released = false
return () => {
if (released || this.closed) {
return
}
released = true
this.bytes -= bytes
this.operations -= 1
}
}
close(): void {
this.closed = true
this.bytes = 0
this.operations = 0
}
}
@@ -57,6 +57,8 @@ export type StructuredAgentSessionTransition = {
steps: readonly StructuredAgentSessionTransitionStep[]
/** Rides the sink's lifecycle budget: it ends or settles something. */
lifecycle: boolean
/** Internal admission only, for a provider's bounded tail after finalization. */
finalTail?: true
/** Announce the writes once they land, when any step wrote. */
publish: boolean
}
@@ -88,6 +90,7 @@ function transitionAppend(
transition.steps.reduce((total, step) => total + step.reservedBytes, 0) +
(transition.publish ? 1 : 0),
lifecycle: transition.lifecycle,
finalTail: transition.finalTail,
run: async (bound) => {
const { journal, fence } = bound
const wrote = await journal.appendSteps(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"}
{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"}
{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"}
{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"}
{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"}
{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"}
{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"}
{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[]}}"}
{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"prompt\", \"message\": \"Reply exactly SIGNED_OUT_CHECK.\"}"}
{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"prompt\",\"success\":false,\"error\":\"No API key found for the selected model.\\n\\nUse /login to log into a provider via OAuth or API key. See:\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/providers.md\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/models.md\"}"}
{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"get_state\"}"}
{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition'
export const PI_RPC_AGENT: DirectoryAccountAgentDefinition = {
agent: 'pi',
handleTransport: 'jsonl-rpc',
accountHomeVariable: 'PI_CODING_AGENT_DIR',
capabilities: {
rewind: false,
compact: true,
threadGoal: false,
contextUsage: true,
imagePrompts: true,
steering: 'inject',
approvalEnforcement: 'provider'
},
restingOptions: {
acceptsKey: (key) => key === 'model' || key === 'effort',
fallbackModels: () => null,
effortDefaultsToModel: false
}
}
+155
View File
@@ -0,0 +1,155 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane'
import { PiRpcTurns } from './rpc-turns'
import { piRpcDialogPresentation } from './rpc-extension-dialogs'
import {
closeProviderTimelineRigs,
openProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
const envelope = z.object({ dir: z.enum(['in', 'out']), raw: z.string() })
const record = z.looseObject({ type: z.string() })
function capture(name: string) {
return readFileSync(join(import.meta.dirname, '__fixtures__', `${name}.jsonl`), 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => {
const parsed = envelope.parse(JSON.parse(line))
return { dir: parsed.dir, frame: record.parse(JSON.parse(parsed.raw)) }
})
}
const disposals: (() => void)[] = []
afterEach(async () => {
disposals.splice(0).forEach((dispose) => dispose())
await closeProviderTimelineRigs()
vi.useRealTimers()
})
async function replay(name: string) {
const frames = capture(name)
const states = frames.filter(
({ dir, frame }) => dir === 'out' && frame.type === 'response' && frame.command === 'get_state'
)
const stats = frames.findLast(
({ dir, frame }) =>
dir === 'out' && frame.type === 'response' && frame.command === 'get_session_stats'
)?.frame.data
const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' })
const accepted = vi.fn(),
failed = vi.fn(),
idle = vi.fn(),
settled = vi.fn()
const lane = new JsonlRpcTimelineLane({
sink: rig.sink,
sessionId: 'session-timeline',
agent: 'pi',
generation: 'gen-pi',
namespace: 'pi-session',
pauseReading: vi.fn(),
resumeReading: vi.fn(),
onInputAccepted: accepted,
onFailed: failed
})
const turns = new PiRpcTurns({
lane,
generation: 'gen-pi',
send: vi.fn(async () => {}),
request: vi.fn(async (command: string) =>
command === 'get_state' ? states.at(-1)?.frame.data : stats
),
settled,
failed,
idle
})
disposals.push(() => {
turns.end()
lane.dispose()
})
let sequence = 0
for (const { dir, frame } of frames) {
if (dir === 'in' && frame.type === 'prompt') {
await turns.submit(`send-${++sequence}`, 100, frame)
}
if (dir === 'in' && frame.type === 'abort') {
turns.stop()
}
if (dir === 'out' && (frame.type !== 'response' || frame.command === 'prompt')) {
turns.receive(frame)
}
}
await new Promise<void>((resolve) => setImmediate(resolve))
lane.flush()
const rows = await rig.rows()
return { rows, turns: await rig.turns(), accepted, failed, idle, settled }
}
describe('scrubbed Pi 1.0.4 RPC captures', () => {
it('records tool output, streamed text, and all three consumed inputs in one settled run', async () => {
const result = await replay('steer-followup')
expect(result.failed).not.toHaveBeenCalled()
expect(result.accepted.mock.calls.map(([id]) => id)).toEqual(['send-1', 'send-2', 'send-3'])
expect(result.turns).toHaveLength(1)
expect(result.turns[0]?.outcome).toBe('success')
expect(
result.rows.some((row) => row.body.kind === 'tool-call' && row.body.state === 'completed')
).toBe(true)
const texts = result.rows.flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : []))
: []
)
expect(texts.join('\n')).toContain('STEER_CONSUMED')
expect(texts.join('\n')).toContain('FOLLOWUP_CONSUMED')
})
it.each(['provider-fetch-error', 'provider-socket-error'])(
'marks captured %s as failure with the provider diagnostic',
async (name) => {
const result = await replay(name)
expect(result.failed).not.toHaveBeenCalled()
expect(result.turns[0]?.outcome).toBe('failure')
expect(
result.rows.filter(
(row) => row.body.kind === 'status' && row.body.failure?.kind === 'providerRejected'
)
).toHaveLength(1)
}
)
it('treats the captured abort error as cancellation', async () => {
const result = await replay('stop-mid-tool')
expect(result.failed).not.toHaveBeenCalled()
expect(result.turns[0]?.outcome).toBe('cancellation')
expect(result.rows.some((row) => row.body.kind === 'status' && row.body.failure)).toBe(false)
})
it('settles a handled extension command with no agent events', async () => {
const result = await replay('command-only')
expect(result.failed).not.toHaveBeenCalled()
expect(result.turns[0]?.outcome).toBe('success')
expect(result.accepted).toHaveBeenCalledWith('send-1')
})
it('preserves empty answers and literal Unicode separators in captured dialogs', () => {
const dialogs = capture('dialogs').flatMap(({ dir, frame }) =>
dir === 'out' && frame.type === 'extension_ui_request' ? [frame] : []
)
const input = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'input'))
const editor = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'editor'))
expect(
input?.reply({
kind: 'answers',
answers: [{ questionId: String(input.id), optionIds: [], other: '' }]
})
).toEqual({ value: '' })
const body = editor?.body
expect(body?.kind === 'question' && body.freeTextInput?.initialValue).toContain('\n')
expect(
editor?.reply({
kind: 'answers',
answers: [
{ questionId: String(editor.id), optionIds: [], other: 'first\u2028second\u2029last' }
]
})
).toEqual({ value: 'first\u2028second\u2029last' })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type {
AgentSessionCommandAdmission,
StructuredAgentSessionCommandRun
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { JsonlRpcResponseError } from '../jsonl-rpc/peer'
import type { PiRpcSession } from './rpc-session'
export async function compactPiRpcSession(
session: PiRpcSession,
command: StructuredAgentSessionCommandRun
): Promise<AgentSessionCommandAdmission> {
session.turns.beginCommand(command)
try {
await session.connection.request('compact', {}, { timeoutMs: null })
session.turns.commandCompleted()
return { state: 'accepted', providerIdentity: null }
} catch (error) {
if (!(error instanceof JsonlRpcResponseError)) {
throw error
}
if (error.message === 'Nothing to compact (session too small)') {
session.lane.apply([
{
type: 'item.close',
item: `compact-noop:${command.turnId}`,
body: { kind: 'status', tone: 'warning', text: error.message }
}
])
session.turns.commandCompleted()
return { state: 'accepted', providerIdentity: null }
}
session.turns.commandRejected()
return {
state: 'rejected',
...agentSessionFailureWords(
agentSessionFailureFact('providerRejected', {
detail: providerDiagnostic(error.message, 'person')
}),
{ agentName: 'Pi', surface: 'rejection' }
)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { z } from 'zod'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { piRpcUsageSchema, type PiRpcModel } from './rpc-protocol'
const statsSchema = z.object({
contextUsage: z
.object({
tokens: z.number().nonnegative().nullable().optional(),
contextWindow: z.number().positive(),
percent: z.number().nullable().optional()
})
.optional()
})
export class PiRpcContextUsage {
private model?: PiRpcModel
private compactedTokens?: number
compacting(): ProviderTimelineEvent[] {
this.compactedTokens = undefined
return [{ type: 'context.usage', usage: { used: { kind: 'unknown', capturedAt: Date.now() } } }]
}
compacted(result: unknown): void {
const parsed = z.object({ estimatedTokensAfter: z.number().nonnegative() }).safeParse(result)
this.compactedTokens = parsed.success ? parsed.data.estimatedTokensAfter : undefined
}
setModel(model: PiRpcModel | undefined, at: number): ProviderTimelineEvent[] {
const changed = model?.id !== this.model?.id || model?.provider !== this.model?.provider
this.model = model
return model?.contextWindow && model.contextWindow > 0
? [
{
type: 'context.usage',
usage: {
window: { tokens: model.contextWindow, capturedAt: at },
...(changed ? { used: { kind: 'unknown' as const, capturedAt: at } } : {})
}
}
]
: []
}
live(value: unknown, at: number): ProviderTimelineEvent[] {
const usage = piRpcUsageSchema.safeParse(value)
if (!usage.success || Object.values(usage.data).every((tokens) => tokens === 0)) {
return []
}
this.compactedTokens = undefined
return [
{
type: 'context.usage',
usage: {
used: {
kind: 'estimate',
usage: {
inputTokens: usage.data.input,
outputTokens: usage.data.output,
cacheCreationInputTokens: usage.data.cacheWrite,
cacheReadInputTokens: usage.data.cacheRead
},
capturedAt: at
}
}
}
]
}
stats(value: unknown, at: number): ProviderTimelineEvent[] {
const parsed = statsSchema.safeParse(value)
const context = parsed.success ? parsed.data.contextUsage : undefined
if (!context) {
return []
}
return [
{
type: 'context.usage',
usage: {
window: { tokens: context.contextWindow, capturedAt: at },
used:
context.tokens == null
? this.compactedTokens === undefined
? { kind: 'unknown', capturedAt: at }
: {
kind: 'estimate',
usage: {
inputTokens: this.compactedTokens,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0
},
capturedAt: at
}
: {
kind: 'report',
model: this.model ? `${this.model.provider}/${this.model.id}` : 'unknown',
usedTokens: context.tokens,
windowTokens: context.contextWindow,
percentage: context.percent ?? (context.tokens / context.contextWindow) * 100,
categories: [],
capturedAt: at
}
}
}
]
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer'
import {
AgentSessionPromptAnswerRejectedError,
AgentSessionPromptUnavailableError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane'
import type { JsonlRpcRecord } from '../jsonl-rpc/peer'
import { piRpcDialogPresentation, type PiRpcDialogPresentation } from './rpc-extension-dialogs'
type Callback = { presentation: PiRpcDialogPresentation; claimed: boolean; bytes: number }
const DECORATION = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'setEditorText'])
export class PiRpcDialogCallbacks {
private readonly pending = new Map<string, Callback>()
private pendingBytes = 0
constructor(
private readonly lane: JsonlRpcTimelineLane,
private readonly send: (frame: JsonlRpcRecord) => Promise<void>,
private readonly onFailed: (error: Error) => void
) {}
receive(frame: JsonlRpcRecord): void {
if (typeof frame.method === 'string' && DECORATION.has(frame.method)) {
return
}
const presentation = piRpcDialogPresentation(frame)
if (!presentation) {
if (typeof frame.id === 'string' || typeof frame.id === 'number') {
void this.send({ type: 'extension_ui_response', id: frame.id, cancelled: true }).catch(
(error: unknown) =>
this.onFailed(error instanceof Error ? error : new Error(String(error)))
)
}
return
}
const key = String(presentation.id)
const bytes = Buffer.byteLength(JSON.stringify(presentation.body))
if (
this.pending.has(key) ||
this.pending.size >= 64 ||
this.pendingBytes + bytes > 8 * 1024 * 1024
) {
throw new Error('Pi dialog identity or capacity violation')
}
this.pending.set(key, { presentation, claimed: false, bytes })
this.pendingBytes += bytes
this.lane.apply([{ type: 'request.open', request: key, body: presentation.body }])
}
async respond(
itemId: string,
response: AgentSessionPromptResponse | null,
commit: () => Promise<void>,
answer = true
): Promise<void> {
const entry = [...this.pending].find(([key]) => this.lane.isRequestRow(itemId, key))
if (!entry || entry[1].claimed) {
throw new AgentSessionPromptUnavailableError(itemId)
}
const [key, callback] = entry
let reply: Record<string, unknown>
try {
reply = callback.presentation.reply(response)
} catch (error) {
throw new AgentSessionPromptAnswerRejectedError(
error instanceof Error ? error.message : 'Invalid Pi dialog answer'
)
}
callback.claimed = true
try {
await commit()
if (this.pending.get(key) !== callback) {
throw new AgentSessionPromptUnavailableError(itemId)
}
if (answer) {
await this.send({ type: 'extension_ui_response', id: callback.presentation.id, ...reply })
}
this.pending.delete(key)
this.pendingBytes -= callback.bytes
} catch (error) {
callback.claimed = false
throw error
}
}
cancelAll(): void {
for (const [key, callback] of this.pending) {
this.lane.apply([{ type: 'request.withdrawn', request: key }])
void this.send({
type: 'extension_ui_response',
id: callback.presentation.id,
cancelled: true
}).catch(() => {})
}
this.pending.clear()
this.pendingBytes = 0
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer'
import type { ProviderTimelineRequestBody } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { piRpcDialogSchema } from './rpc-protocol'
const pendingResolution = {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
} as const
export type PiRpcDialogPresentation = {
id: string | number
body: ProviderTimelineRequestBody
reply: (response: AgentSessionPromptResponse | null) => Record<string, unknown>
}
/** Terminal decoration is deliberately absent from the chat projection. */
export function piRpcDialogPresentation(value: unknown): PiRpcDialogPresentation | null {
const parsed = piRpcDialogSchema.safeParse(value)
if (!parsed.success) {
return null
}
const dialog = parsed.data
if (dialog.method === 'confirm') {
return {
id: dialog.id,
body: {
kind: 'approval',
title: dialog.title,
detail: dialog.message ?? null,
options: [
{ id: 'yes', label: 'Yes' },
{ id: 'no', label: 'No' }
],
resolution: pendingResolution
},
reply: (response) => {
if (response === null) {
return { cancelled: true }
}
if (response.kind !== 'option' || !['yes', 'no'].includes(response.optionId)) {
throw new Error('Confirmation must select an offered option')
}
return { confirmed: response.optionId === 'yes' }
}
}
}
const questionId = String(dialog.id)
const options =
dialog.method === 'select'
? dialog.options.map((label, index) => ({
id: `option-${index}`,
label: label || 'Empty value'
}))
: []
return {
id: dialog.id,
body: {
kind: 'question',
question: [dialog.title, dialog.message].filter((text) => text !== undefined).join('\n\n'),
options,
resolution: pendingResolution,
...(dialog.method === 'select'
? {}
: {
freeTextQuestionId: questionId,
freeTextInput: {
allowEmpty: true,
...(dialog.method === 'editor'
? { multiline: true, initialValue: dialog.prefill ?? '' }
: {}),
...(dialog.placeholder === undefined ? {} : { placeholder: dialog.placeholder })
}
})
},
reply: (response) => {
if (response === null) {
return { cancelled: true }
}
if (response.kind !== 'answers' || response.answers.length !== 1) {
throw new Error('Dialog requires one answer')
}
const answer = response.answers[0]
if (dialog.method === 'select') {
const selected = options.findIndex((option) => option.id === answer.optionIds[0])
if (answer.optionIds.length !== 1 || answer.other !== undefined || selected === -1) {
throw new Error('Selection must choose an offered option')
}
return { value: dialog.options[selected] }
}
if (
answer.questionId !== questionId ||
answer.optionIds.length !== 0 ||
answer.other === undefined
) {
throw new Error('Dialog requires a text answer')
}
return { value: answer.other }
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { piRpcIdle, piRpcStateSchema, type PiRpcState } from './rpc-protocol'
type IdleCheckDeps = {
request: () => Promise<unknown>
current: (revision: number) => boolean
settled: (state: PiRpcState) => void
failed: (error: Error) => void
}
/** Detached compaction and new input invalidate an older settled signal and its idle reply. */
export class PiRpcIdleCheck {
private readonly timers = new Set<ReturnType<typeof setTimeout>>()
private ended = false
constructor(private readonly deps: IdleCheckDeps) {}
schedule(revision: number, attempt = 0): void {
queueMicrotask(() => {
if (this.ended || !this.deps.current(revision)) {
return
}
void this.deps
.request()
.then((value) => {
if (this.ended || !this.deps.current(revision)) {
return
}
const state = piRpcStateSchema.parse(value)
if (piRpcIdle(state)) {
this.deps.settled(state)
}
})
.catch((error: unknown) => {
if (this.ended || !this.deps.current(revision)) {
return
}
if (attempt >= 2) {
this.deps.failed(error instanceof Error ? error : new Error(String(error)))
return
}
const timer = setTimeout(() => {
this.timers.delete(timer)
this.schedule(revision, attempt + 1)
}, 100)
timer.unref()
this.timers.add(timer)
})
})
}
dispose(): void {
this.ended = true
for (const timer of this.timers) {
clearTimeout(timer)
}
this.timers.clear()
}
}
+227
View File
@@ -0,0 +1,227 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle'
import { closeTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness'
import { buildPiRpcLaunch } from './rpc-launch'
import { createPiRpcLaunchResolver, piRpcProviderLink } from './rpc-launch-resolution'
const probeVersion = vi.fn(async (..._input: unknown[]) => true)
const identity: AgentSessionJournalIdentity = {
sessionId: 'session-pi-resolve',
workspaceId: 'folder-1',
hostId: 'local',
agent: 'pi',
providerHandle: null
}
let root: string
beforeEach(async () => {
probeVersion.mockReset().mockResolvedValue(true)
root = await mkdtemp(join(tmpdir(), 'orca-pi-resolver-'))
})
afterEach(async () => {
closeTestJournalHostDatabase(root)
await rm(root, { recursive: true, force: true })
})
async function setup() {
const store = await openTestAgentSessionRecordStore(root)
const { record } = await store.reserveOwner({
sessionId: identity.sessionId,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder-1',
workspaceKind: 'folder'
},
provider: 'pi',
accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/host/account' },
expectedFence: null,
spawnToken: 'spawn-pi',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: {
callerKey: 'client-1',
operationId: '1800000000000-00000000000000000000000000000001',
fingerprint: 'pi-create'
},
now: 1_800_000_000_000
})
const workspace = join(root, 'workspace')
await mkdir(workspace)
const resolveEnvironment = vi.fn(async () => ({
PATH: '/host/bin',
HOME: '/host/home',
PI_CODING_AGENT_DIR: '/inherited/wrong'
}))
const resolveCommand = vi.fn((..._input: unknown[]) => '/host/bin/pi')
const settings: { agentCmdOverrides: Record<string, string> } = { agentCmdOverrides: {} }
const resolver = createPiRpcLaunchResolver({
store,
resolveWorkspacePath: async () => workspace,
resolveEnvironment,
resolveCommandSettings: () => settings,
resolveCommand,
probeVersion
})
const prior = (
file: string,
origin: AgentSessionProviderHandleLink['origin'] = 'created'
): AgentSessionProviderHandleLink => ({
linkId: 'prior-link',
handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file },
origin,
mintedAtFence: 1,
observedAt: 100
})
const withPrior = (link: AgentSessionProviderHandleLink) => {
vi.spyOn(store, 'getRecord').mockReturnValue({ ...record, providerHandleChain: [link] })
}
return { store, record, workspace, resolver, resolveCommand, settings, prior, withPrior }
}
describe('Pi host launch resolution', () => {
it('refuses acquisition if the selected binary was replaced by an unsupported version', async () => {
const h = await setup()
probeVersion.mockResolvedValue(false)
await expect(h.resolver(identity)).rejects.toThrow('structured_agent_session_unsupported')
expect(probeVersion).toHaveBeenCalledWith(
{
program: '/host/bin/pi',
cwd: h.workspace,
env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' }
},
expect.any(Function)
)
})
it('uses the runtime workspace, binary and account home for a new folder session', async () => {
const h = await setup()
const launch = await h.resolver(identity)
expect(launch).toMatchObject({
command: '/host/bin/pi',
cwd: h.workspace,
env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' },
previous: null,
fullAccess: true
})
expect(h.resolveCommand).toHaveBeenCalledWith('pi', {
pathEnv: '/host/bin',
homePath: '/host/home'
})
})
it('spawns the binary the Command setting names, and refuses one that is not runnable', async () => {
const h = await setup()
h.settings.agentCmdOverrides = { pi: `"${process.execPath}"` }
await expect(h.resolver(identity)).resolves.toMatchObject({ command: process.execPath })
expect(h.resolveCommand).not.toHaveBeenCalled()
probeVersion.mockClear()
h.settings.agentCmdOverrides = { pi: '/missing/pi' }
await expect(h.resolver(identity)).rejects.toMatchObject({
reason: 'agentCommandNotRunnable'
})
expect(probeVersion).not.toHaveBeenCalled()
})
it('resumes the same directory and forks a session from a different directory', async () => {
const h = await setup()
const same = join(root, 'same.jsonl')
await writeFile(same, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`)
h.withPrior(h.prior(same))
const resumed = await h.resolver(identity)
expect(resumed.sessionFile).toBe(same)
expect(resumed.forkFile).toBeUndefined()
expect(piRpcProviderLink(resumed, same, 2, 'next', 200).origin).toBe('resumed')
expect(() => piRpcProviderLink(resumed, join(root, 'wrong.jsonl'), 2, 'bad', 200)).toThrow(
'different session file'
)
const elsewhere = join(root, 'elsewhere.jsonl')
await writeFile(elsewhere, `${JSON.stringify({ type: 'session', cwd: join(root, 'other') })}\n`)
h.withPrior(h.prior(elsewhere))
const forked = await h.resolver(identity)
expect(forked.forkFile).toBe(elsewhere)
expect(forked.sessionFile).toBeUndefined()
expect(piRpcProviderLink(forked, join(root, 'fork.jsonl'), 2, 'fork', 200)).toMatchObject({
origin: 'forked',
forkedFromKey: agentSessionProviderHandleKey(h.prior(elsewhere).handle)
})
expect(() => piRpcProviderLink(forked, elsewhere, 2, 'bad', 200)).toThrow('did not fork')
})
it('resumes the stored session file after Orca restarts and reopens the store', async () => {
const h = await setup()
const file = join(root, 'conversation.jsonl')
await writeFile(file, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`)
const fence = h.record.lease.runtimeFence
await h.store.commitProcessIdentity({
sessionId: identity.sessionId,
fence,
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-pi' },
now: 1_800_000_000_001
})
await h.store.proveOwner({
sessionId: identity.sessionId,
fence,
link: { ...h.prior(file), mintedAtFence: fence },
now: 1_800_000_000_002
})
// A restart: the store is closed and a fresh one reads the record back from disk.
closeTestJournalHostDatabase(root)
const reopened = await openTestAgentSessionRecordStore(root)
const launch = await createPiRpcLaunchResolver({
store: reopened,
resolveWorkspacePath: async () => h.workspace,
resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }),
resolveCommand: () => '/host/bin/pi',
probeVersion
})(identity)
expect(launch.sessionFile).toBe(file)
expect(buildPiRpcLaunch(launch).args).toEqual(['--mode', 'rpc', '--session', file])
})
it('distinguishes an unsaved creation from an existing session that could not restore', async () => {
const h = await setup()
const missing = join(root, 'missing.jsonl')
h.withPrior(h.prior(missing))
const unsaved = await h.resolver(identity)
expect(unsaved.replacement).toBe('unsaved')
expect(piRpcProviderLink(unsaved, join(root, 'new.jsonl'), 2, 'new', 200)).toMatchObject({
origin: 'created',
supersedesKey: agentSessionProviderHandleKey(h.prior(missing).handle)
})
h.withPrior(h.prior(missing, 'resumed'))
const lost = await h.resolver(identity)
expect(lost.replacement).toBe('restore-failed')
expect(
piRpcProviderLink(lost, join(root, 'replacement.jsonl'), 2, 'replace', 200)
).toMatchObject({ replaces: { reason: 'restore-failed', replacedAt: 200 } })
})
it('refuses a different execution host and a foreign account home before resolving paths', async () => {
const h = await setup()
const record = h.store.getRecord(identity.sessionId)
if (!record) {
throw new Error('record missing')
}
vi.spyOn(h.store, 'getRecord').mockReturnValue({
...record,
location: { ...record.location, executionHostId: 'ssh:remote' }
})
await expect(h.resolver(identity)).rejects.toThrow('another execution host')
vi.spyOn(h.store, 'getRecord').mockReturnValue({
...record,
accountHome: { variable: 'CODEX_HOME', path: '/wrong' }
})
await expect(h.resolver(identity)).rejects.toThrow('account home')
expect(h.resolveCommand).not.toHaveBeenCalled()
})
})
+211
View File
@@ -0,0 +1,211 @@
import { open, realpath } from 'node:fs/promises'
import { isAbsolute, normalize } from 'node:path'
import { z } from 'zod'
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import {
agentSessionProviderHandleChainHead,
agentSessionProviderHandleKey,
type AgentSessionProviderHandleLink
} from '../../shared/agent-session-provider-handle'
import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe'
import {
resolveStructuredAgentCommand,
type StructuredAgentCommandSettings
} from '../native-chat/structured-agent-command-resolution'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import type { PiRpcLaunchOptions } from './rpc-launch'
export type PiRpcResolvedLaunch = PiRpcLaunchOptions & {
previous: AgentSessionProviderHandleLink | null
replacement?: 'unsaved' | 'restore-failed'
}
export type PiRpcLaunchResolverDeps = {
store: AgentSessionRecordStore
resolveWorkspacePath: (id: string) => Promise<string>
resolveEnvironment: () => Promise<NodeJS.ProcessEnv>
/** The user's Pi Command setting, re-read per acquisition; none runs the stock binary. */
resolveCommandSettings?: () => StructuredAgentCommandSettings
resolveCommand?: typeof resolveCliCommand
probeVersion?: typeof probeAgentCliVersion
resolveFullAccess?: () => boolean
}
// Stable releases from 0.84.0 emit agent_settled after retries and detached compaction; older
// Pi (the 0.73.x package included) keeps the terminal chat.
const PI_RPC_RELEASE_LINES = [
{ major: 0, floor: '0.84.0' },
{ major: 1, floor: '1.0.0' }
] as const
function supportsPiRpcVersion(version: string): boolean {
return PI_RPC_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line))
}
/** The binary a Pi launch with `env` spawns; create support resolves through here too. */
export function resolvePiRpcCommand(
env: Readonly<Record<string, string>>,
commandSettings: StructuredAgentCommandSettings = {},
resolve?: typeof resolveCliCommand
): string {
const homePath = env.HOME ?? env.USERPROFILE
return resolveStructuredAgentCommand(
'pi',
commandSettings,
{ pathEnv: env.PATH ?? env.Path ?? null, ...(homePath ? { homePath } : {}) },
resolve ? { resolve } : {}
)
}
export function piRpcVersionSupported(
launch: { command: string; cwd: string; env: Record<string, string> },
probe: typeof probeAgentCliVersion = probeAgentCliVersion
): Promise<boolean> {
return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion)
}
const headerSchema = z.object({ type: z.literal('session'), cwd: z.string().min(1) })
async function sessionDirectory(file: string): Promise<string | null> {
let descriptor
try {
descriptor = await open(file, 'r')
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null
}
throw error
}
try {
const buffer = Buffer.alloc(64 * 1024)
const { bytesRead } = await descriptor.read(buffer, 0, buffer.length, 0)
const data = buffer.subarray(0, bytesRead).toString('utf8')
const lf = data.indexOf('\n')
if (lf === -1) {
throw new Error('Pi session header is incomplete or oversized')
}
return headerSchema.parse(JSON.parse(data.slice(0, lf))).cwd
} finally {
await descriptor.close()
}
}
async function sameDirectory(left: string, right: string): Promise<boolean> {
const canonical = async (value: string): Promise<string> =>
realpath(value).catch(() => normalize(value))
const [a, b] = await Promise.all([canonical(left), canonical(right)])
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
}
/** Reads the execution host's durable record; never resolves another host's files or account. */
export function createPiRpcLaunchResolver(
deps: PiRpcLaunchResolverDeps
): (identity: AgentSessionJournalIdentity) => Promise<PiRpcResolvedLaunch> {
return async (identity) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record || record.provider !== 'pi') {
throw new Error('Pi session record unavailable')
}
if (
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
record.location.wslDistro !== null
) {
throw new Error('Pi structured session belongs to another execution host')
}
if (process.platform === 'win32' && !isWindowsProcessStartTimeAvailable()) {
throw new Error('Pi structured sessions require Windows process creation-time proof')
}
const accountHome = record.accountHome
if (
!isLegacyAgentSessionAccountHome(accountHome) ||
accountHome.variable !== 'PI_CODING_AGENT_DIR'
) {
throw new Error('Pi account home does not match its binary')
}
const cwd = await deps.resolveWorkspacePath(record.location.workspaceId)
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(await deps.resolveEnvironment())) {
if (value !== undefined) {
env[key] = value
}
}
env.PI_CODING_AGENT_DIR = accountHome.path
const previous = agentSessionProviderHandleChainHead(record.providerHandleChain)
let sessionFile: string | undefined
let forkFile: string | undefined
let replacement: PiRpcResolvedLaunch['replacement']
if (previous) {
if (
previous.handle.transport !== 'jsonl-rpc' ||
previous.handle.agent !== 'pi' ||
!isAbsolute(previous.handle.nativeId)
) {
throw new Error('Pi resume handle is not a host session file')
}
const source = previous.handle.nativeId
const directory = await sessionDirectory(source)
if (directory === null) {
replacement = previous.origin === 'created' ? 'unsaved' : 'restore-failed'
} else if (await sameDirectory(directory, cwd)) {
sessionFile = source
} else {
forkFile = source
}
}
const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand)
// Again at every launch: the binary on PATH may have changed since the chat was created.
if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) {
throw agentSessionRefusalError('structured_agent_session_unsupported', {
reason: 'hostUnsupported'
})
}
return {
command,
cwd,
env,
fullAccess: deps.resolveFullAccess?.() ?? true,
previous,
...(sessionFile ? { sessionFile } : {}),
...(forkFile ? { forkFile } : {}),
...(replacement ? { replacement } : {})
}
}
}
export function piRpcProviderLink(
launch: PiRpcResolvedLaunch,
file: string,
fence: number,
linkId: string,
at: number
): AgentSessionProviderHandleLink {
if (!isAbsolute(file)) {
throw new Error('Pi did not return an absolute session file')
}
const previous = launch.previous
if (launch.sessionFile && file !== launch.sessionFile) {
throw new Error('Pi resumed a different session file')
}
if (launch.forkFile && file === launch.forkFile) {
throw new Error('Pi did not fork the session for its new directory')
}
const key = previous ? agentSessionProviderHandleKey(previous.handle) : undefined
return {
linkId,
handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file },
origin: launch.forkFile ? 'forked' : launch.sessionFile ? 'resumed' : 'created',
mintedAtFence: fence,
observedAt: at,
...(launch.forkFile && key ? { forkedFromKey: key } : {}),
...(launch.replacement === 'unsaved' && key
? { supersedesKey: key, ...(previous?.replaces ? { replaces: previous.replaces } : {}) }
: {}),
...(launch.replacement === 'restore-failed' && key
? { replaces: { key, reason: 'restore-failed', replacedAt: at } }
: {})
}
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { resolveProviderChildEnv } from '../provider-process/provider-process-launch'
import { buildPiRpcLaunch } from './rpc-launch'
const options = { command: 'host-resolved-pi', cwd: '/host/folder', fullAccess: true }
describe('Pi RPC launch', () => {
it('uses the supplied host binary and folder, and lets Pi choose its own session store', () => {
expect(buildPiRpcLaunch(options)).toMatchObject({
command: options.command,
cwd: options.cwd,
args: ['--mode', 'rpc']
})
})
it.each([
'/host/.pi/agent/sessions/session.jsonl',
'C:\\Users\\dev\\.pi\\agent\\sessions\\session.jsonl'
])('passes the native session file unchanged: %s', (sessionFile) => {
expect(buildPiRpcLaunch({ ...options, sessionFile }).args).toEqual([
'--mode',
'rpc',
'--session',
sessionFile
])
})
it('retains selected account and user extension args, but strips inherited pane hook identities after overlays', () => {
const launch = buildPiRpcLaunch({
...options,
extraArgs: ['--extension', '/host/my extension.ts'],
env: {
PI_CODING_AGENT_DIR: '/host/account',
ORCA_PANE_KEY: 'other-pane',
ORCA_AGENT_PANE: 'alias'
}
})
const env = resolveProviderChildEnv(launch, {
ORCA_AGENT_HOOK_TOKEN: 'inherited',
API_KEY: 'retained'
})
expect(env).toEqual({ API_KEY: 'retained', PI_CODING_AGENT_DIR: '/host/account' })
expect(launch.args).toEqual(['--mode', 'rpc', '--extension', '/host/my extension.ts'])
})
it.each([
['--provider', 'anthropic', '--model', 'model-id'],
['--provider=anthropic', '--model=model-id'],
['--provider=anthropic', '-m', 'model-id'],
['--model', 'anthropic/model-id']
])('accepts paired model/provider selections: %j', (...extraArgs) => {
expect(buildPiRpcLaunch({ ...options, extraArgs }).args).toEqual([
'--mode',
'rpc',
...extraArgs
])
})
it.each([['--provider', 'anthropic'], ['--provider=anthropic'], ['--model'], ['--model=']])(
'rejects incomplete provider/model args: %j',
(...extraArgs) => {
expect(() => buildPiRpcLaunch({ ...options, extraArgs })).toThrow(/requires/)
}
)
it.each([
'--mode=json',
'--print',
'-p',
'--no-session',
'--session=other',
'--resume',
'--continue'
])('rejects arguments that bypass the chat mode or its session handle: %s', (arg) => {
expect(() => buildPiRpcLaunch({ ...options, extraArgs: [arg] })).toThrow('conflicts')
})
it('rejects a supervised permissions request instead of silently running in full access', () => {
expect(() => buildPiRpcLaunch({ ...options, fullAccess: false })).toThrow('full access only')
})
it('rejects an empty supplied resume handle', () => {
expect(() => buildPiRpcLaunch({ ...options, sessionFile: '' })).toThrow(
'requires a session file'
)
})
it('scrubs inherited structured identity and injects only this acquisition identity', () => {
const inherited = {
ORCA_AGENT_SESSION_ID: 'stale',
ORCA_STRUCTURED_SESSION: '1',
ORCA_TERMINAL_HANDLE: 'structworker_stale',
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'old-token'
}
expect(resolveProviderChildEnv(buildPiRpcLaunch(options), inherited)).toEqual({})
const childEnv = resolveProviderChildEnv(
buildPiRpcLaunch({
...options,
env: inherited,
structuredSession: { id: 'this-session', spawnToken: 'this-token' }
}),
inherited
)
expect(childEnv).toMatchObject({
ORCA_AGENT_SESSION_ID: 'this-session',
ORCA_STRUCTURED_SESSION: '1',
ORCA_AGENT_SESSION_SPAWN_TOKEN: 'this-token'
})
expect(childEnv).not.toHaveProperty('ORCA_TERMINAL_HANDLE')
})
})
+119
View File
@@ -0,0 +1,119 @@
import {
ORCA_SCRUB_SAFE_LAUNCH_ENV,
ORCA_SCRUB_SAFE_PANE_ENV
} from '../../shared/agent-hook-scrub-safe-env'
import {
AGENT_HOOK_RUNTIME_ENV_KEYS,
ORCA_AGENT_SESSION_CALLER_ENV_KEYS
} from '../ipc/pty/host-env/spawn-env-keys'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env'
export type PiRpcLaunchOptions = {
/** Binary and paths are resolved by the execution host before building the launch. */
command: string
cwd: string
fullAccess: boolean
extraArgs?: readonly string[]
env?: Record<string, string>
sessionFile?: string
forkFile?: string
structuredSession?: { id: string; spawnToken: string }
}
const CHILD_ENV_TO_DELETE: readonly string[] = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN',
ORCA_SCRUB_SAFE_PANE_ENV,
ORCA_SCRUB_SAFE_LAUNCH_ENV,
...AGENT_HOOK_RUNTIME_ENV_KEYS
]
const CALLER_ENV_TO_DELETE = [
...ORCA_AGENT_SESSION_CALLER_ENV_KEYS,
'ORCA_TERMINAL_HANDLE',
'ORCA_AGENT_SESSION_SPAWN_TOKEN'
]
/** Pi stores its own sessions; an explicit session file is shared with terminal resumes. */
export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLaunch {
if (!options.fullAccess) {
throw new Error('Pi structured chat supports full access only')
}
if (options.sessionFile !== undefined && !options.sessionFile.trim()) {
throw new Error('Pi resume requires a session file')
}
if (
options.forkFile !== undefined &&
(!options.forkFile.trim() || options.sessionFile !== undefined)
) {
throw new Error('Pi fork requires one source session file')
}
const env = { ...options.env }
for (const key of CALLER_ENV_TO_DELETE) {
delete env[key]
}
const childEnv = options.structuredSession
? {
...structuredSessionChildIdentityEnv(options.structuredSession.id, env),
ORCA_AGENT_SESSION_SPAWN_TOKEN: options.structuredSession.spawnToken
}
: env
const args = [...(options.extraArgs ?? [])]
let provider = false
let model = false
for (let index = 0; index < args.length; index++) {
const arg = args[index]
const flag = arg.split('=')[0]
if (flag === '--provider' || flag === '--model' || flag === '-m') {
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++index]
if (!value || value.startsWith('-')) {
throw new Error(`Pi ${flag} requires a value`)
}
if (flag === '--provider') {
provider = true
} else {
model = true
}
} else if (
[
'--mode',
'--print',
'-p',
'--no-session',
'--session',
'-r',
'--resume',
'-c',
'--continue',
'--fork'
].includes(flag)
) {
throw new Error(`Pi ${flag} conflicts with the structured chat launch`)
}
}
if (provider && !model) {
throw new Error('Pi --provider requires --model')
}
return {
command: options.command,
cwd: options.cwd,
args: [
'--mode',
'rpc',
...args,
...(options.sessionFile ? ['--session', options.sessionFile] : []),
...(options.forkFile ? ['--fork', options.forkFile] : [])
],
env: childEnv,
envToDelete: [
...CHILD_ENV_TO_DELETE,
...(options.structuredSession
? Object.hasOwn(childEnv, 'ORCA_TERMINAL_HANDLE')
? []
: ['ORCA_TERMINAL_HANDLE']
: CALLER_ENV_TO_DELETE)
]
}
}
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { structuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import { PiRpcSessionAdapter } from './rpc-session-adapter'
vi.mock('../windows/windows-process-table', () => ({ isWindowsProcessStartTimeAvailable: vi.fn() }))
const originalPlatform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
vi.resetAllMocks()
})
const local: AgentSessionExecutionLocation = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder-1',
workspaceKind: 'folder'
}
describe('Pi execution host support', () => {
it.each([false, true])(
'requires Windows process identity proof at both support seams (%s)',
(proof) => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.mocked(isWindowsProcessStartTimeAvailable).mockReturnValue(proof)
const adapter = new PiRpcSessionAdapter({
resolveLaunch: async () => {
throw new Error('A location check must not acquire a process')
},
onLifecycle: vi.fn(),
onSettled: vi.fn(),
onIdle: vi.fn(),
logger: { warn: vi.fn(), error: vi.fn() }
})
expect(structuredAgentRuntimeRegistration('pi')?.supportsLocation(local)).toBe(proof)
expect(adapter.supportsCreate(local, 'pi')).toBe(proof)
}
)
it('refuses another execution host and WSL without probing local process identity', () => {
const registration = structuredAgentRuntimeRegistration('pi')
expect(registration?.supportsLocation({ ...local, executionHostId: 'runtime:remote' })).toBe(
false
)
expect(registration?.supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false)
expect(isWindowsProcessStartTimeAvailable).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,80 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { PiRpcMessages } from './rpc-messages'
import { PiRpcContextUsage } from './rpc-context-usage'
import { piRpcDialogPresentation } from './rpc-extension-dialogs'
describe('recorded Pi message and extension shapes', () => {
it('publishes top-level live usage before the assistant message ends', () => {
const frameSchema = z.looseObject({
type: z.string(),
usage: z
.object({
input: z.number(),
output: z.number(),
cacheRead: z.number(),
cacheWrite: z.number()
})
.optional()
})
const frames = readFileSync(
join(import.meta.dirname, '__fixtures__', 'steer-followup.jsonl'),
'utf8'
)
.split('\n')
.filter(Boolean)
.map((line) => {
const envelope = z.object({ raw: z.string() }).parse(JSON.parse(line))
return frameSchema.parse(JSON.parse(envelope.raw))
})
const update = frames.find(
(frame) =>
frame.type === 'message_update' &&
frame.usage &&
Object.values(frame.usage).some((tokens) => tokens > 0)
)
expect(update).toBeDefined()
const messages = new PiRpcMessages('generation', new PiRpcContextUsage())
expect(messages.message(update, 123)).toContainEqual({
type: 'context.usage',
usage: {
used: {
kind: 'estimate',
usage: {
inputTokens: update?.usage?.input,
outputTokens: update?.usage?.output,
cacheCreationInputTokens: update?.usage?.cacheWrite,
cacheReadInputTokens: update?.usage?.cacheRead
},
capturedAt: 123
}
}
})
})
it.each(['input', 'editor', 'select'])('preserves the provider message for %s', (method) => {
const dialog = piRpcDialogPresentation({
id: 'dialog',
method,
title: 'Choose',
message: 'Use staging',
options: ['value']
})
expect(dialog?.body).toMatchObject({ kind: 'question', question: 'Choose\n\nUse staging' })
})
it('labels an empty select value while delivering its original string', () => {
const dialog = piRpcDialogPresentation({
id: 'select',
method: 'select',
title: 'Choose',
options: ['']
})
expect(dialog?.body).toMatchObject({ options: [{ id: 'option-0', label: 'Empty value' }] })
expect(
dialog?.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['option-0'] }] })
).toEqual({ value: '' })
})
})
+162
View File
@@ -0,0 +1,162 @@
import { z } from 'zod'
import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types'
import {
boundPayload,
boundToolInput,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { piRpcMessageEventSchema, piRpcToolEventSchema } from './rpc-protocol'
import type { PiRpcContextUsage } from './rpc-context-usage'
const resultSchema = z.object({
content: z.array(z.object({ type: z.string(), text: z.string().optional() }))
})
/** Pi does not name assistant messages; ordinals are local to this exact child. */
export class PiRpcMessages {
private ordinal = 0
private active?: number
private readonly text = new Map<string, { index: number; channel: 'assistant' | 'reasoning' }>()
private readonly tools = new Map<string, AgentJournalToolCallItem>()
constructor(
private readonly generation: string,
private readonly context: PiRpcContextUsage
) {}
reset(): void {
this.active = undefined
this.text.clear()
this.tools.clear()
}
message(value: unknown, at: number): ProviderTimelineEvent[] {
const parsed = piRpcMessageEventSchema.safeParse(value)
if (!parsed.success) {
throw new Error('Invalid Pi message event')
}
const event = parsed.data
const message = event.message ?? event.assistantMessageEvent?.partial
if (message && message.role !== 'assistant') {
return []
}
if (event.type === 'message_start') {
this.active = ++this.ordinal
this.text.clear()
return []
}
const events = this.context.live(event.usage ?? message?.usage, at)
this.active ??= ++this.ordinal
const update = event.assistantMessageEvent
if (
event.type === 'message_update' &&
update?.delta !== undefined &&
['text_delta', 'thinking_delta'].includes(update.type)
) {
const channel = update.type === 'text_delta' ? 'assistant' : 'reasoning'
const index = update.contentIndex ?? 0
const item = this.key(index, channel)
if (this.text.size >= 128 && !this.text.has(item)) {
throw new Error('Pi message stream capacity exceeded')
}
this.text.set(item, { index, channel })
events.push({ type: 'text.delta', item: { id: item }, channel, text: update.delta })
}
if (event.type === 'message_end' && message) {
const content =
typeof message.content === 'string'
? [{ type: 'text', text: message.content }]
: message.content
content.forEach((block, index) => {
const channel =
block.type === 'text' ? 'assistant' : block.type === 'thinking' ? 'reasoning' : undefined
if (!channel) {
return
}
const item = this.key(index, channel)
const text = channel === 'assistant' ? block.text : block.thinking
if (text !== undefined) {
// A snapshot without deltas still opens the same message row.
if (!this.text.has(item) && text) {
events.push({ type: 'text.delta', item: { id: item }, channel, text })
}
events.push({ type: 'text.close', item: { id: item }, text })
this.text.delete(item)
}
})
for (const item of this.text.keys()) {
events.push({ type: 'text.close', item: { id: item } })
}
this.text.clear()
this.active = undefined
}
return events
}
tool(value: unknown): ProviderTimelineEvent[] {
const parsed = piRpcToolEventSchema.safeParse(value)
if (!parsed.success) {
throw new Error('Invalid Pi tool event')
}
const event = parsed.data
const previous = this.tools.get(event.toolCallId)
const result = event.result ?? event.partialResult
const content = resultSchema.safeParse(result)
const output =
result === undefined
? undefined
: content.success
? content.data.content
.flatMap((block) => (block.text === undefined ? [] : [block.text]))
.join('\n')
: JSON.stringify(result)
const state =
event.type === 'tool_execution_end' ? (event.isError ? 'failed' : 'completed') : 'running'
const body: AgentJournalToolCallItem = {
kind: 'tool-call',
name: event.toolName,
callId: event.toolCallId,
input:
event.args === undefined
? (previous?.input ?? null)
: boundToolInput(event.args, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state,
...(output === undefined
? previous?.output
? { output: previous.output }
: {}
: {
output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
})
}
if (state === 'running') {
if (this.tools.size >= 128 && !previous) {
throw new Error('Pi tool stream capacity exceeded')
}
this.tools.set(event.toolCallId, body)
if (
[...this.tools.values()].reduce(
(bytes, tool) => bytes + Buffer.byteLength(JSON.stringify(tool)),
0
) >
1024 * 1024
) {
throw new Error('Pi tool stream byte capacity exceeded')
}
} else {
this.tools.delete(event.toolCallId)
}
return [
{
type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close',
item: `tool:${event.toolCallId}`,
body
}
]
}
private key(index: number, channel: string): string {
return `message:${this.generation}:${this.active}:${index}:${channel}`
}
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyPiRpcSessionOption,
parsePiModelOptionId,
readPiRpcCommands,
readPiRpcSessionOptions,
type PiRpcRequester
} from './rpc-options'
function requester(
answers: Record<string, unknown>
): PiRpcRequester & { request: ReturnType<typeof vi.fn> } {
return { request: vi.fn(async (command: string) => answers[command]) }
}
describe('Pi RPC session options', () => {
it('reads provider-qualified models and the reported thinking level', async () => {
const rpc = requester({
get_available_models: {
models: [
{
provider: 'openai-codex',
id: 'gpt-6.1-sol',
name: 'GPT-6.1 Sol',
reasoning: true,
thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }
},
{ provider: 'other', id: 'gpt-6.1-sol', name: 'Other Sol', reasoning: false }
]
},
get_state: {
model: { provider: 'openai-codex', id: 'gpt-6.1-sol' },
thinkingLevel: 'medium',
sessionFile: '/sessions/current.jsonl',
isStreaming: false,
isCompacting: false
}
})
const result = await readPiRpcSessionOptions(rpc)
expect(rpc.request.mock.calls.map((call) => call[0])).toEqual([
'get_available_models',
'get_state'
])
expect(result.current).toEqual({
model: 'openai-codex/gpt-6.1-sol',
effort: 'medium',
confirmed: ['model', 'effort']
})
expect(result.models.map((model) => [model.id, model.isDefault])).toEqual([
['openai-codex/gpt-6.1-sol', false],
['other/gpt-6.1-sol', false]
])
expect(result.models[0]?.efforts.map((effort) => effort.value)).toEqual([
'minimal',
'low',
'medium',
'high'
])
expect(result.models[1]?.efforts).toEqual([])
})
it('sends both provider and model ID and stores only successful explicit selections', async () => {
const rpc = requester({ set_model: {}, set_thinking_level: undefined })
const selected = new Map<string, string>()
await expect(
applyPiRpcSessionOption(rpc, selected, 'model', 'openai-codex/gpt-6.1-sol')
).resolves.toEqual({ model: 'openai-codex/gpt-6.1-sol' })
await expect(applyPiRpcSessionOption(rpc, selected, 'effort', 'high')).resolves.toEqual({
model: 'openai-codex/gpt-6.1-sol',
effort: 'high'
})
expect(rpc.request.mock.calls).toEqual([
['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }],
['set_thinking_level', { level: 'high' }]
])
expect(() => parsePiModelOptionId('openai-codex')).toThrow()
await expect(applyPiRpcSessionOption(rpc, selected, 'model', '/gpt-6.1-sol')).rejects.toThrow()
expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol')
const rejected = requester({})
rejected.request.mockRejectedValueOnce(new Error('provider refused model'))
await expect(
applyPiRpcSessionOption(rejected, selected, 'model', 'other/new-model')
).rejects.toThrow('provider refused model')
expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol')
})
it('rejects malformed provider data and reports only Pi-listed commands', async () => {
const malformed = requester({ get_available_models: { models: [{ id: 'missing-provider' }] } })
await expect(readPiRpcSessionOptions(malformed)).rejects.toThrow()
const rpc = requester({
get_commands: {
commands: [
{ name: 'review', description: 'Review changes', source: 'extension' },
{ name: 'search', source: 'skill' }
]
}
})
expect(await readPiRpcCommands(rpc)).toEqual({
commands: [
{ name: 'review', kind: 'command', description: 'Review changes' },
{ name: 'search', kind: 'skill' }
]
})
})
it('handles a signed-out state with no model', async () => {
const rpc = requester({
get_available_models: { models: [] },
get_state: {
model: null,
thinkingLevel: 'off',
sessionFile: '/sessions/current.jsonl',
isStreaming: false,
isCompacting: false
}
})
expect((await readPiRpcSessionOptions(rpc)).current).toEqual({
model: '',
effort: 'off',
confirmed: ['effort']
})
})
it('keeps ordinary thinking levels when the map only supplies overrides', async () => {
const rpc = requester({
get_available_models: {
models: [
{
provider: 'openai-codex',
id: 'gpt-5.5',
reasoning: true,
thinkingLevelMap: { minimal: 'low', xhigh: 'xhigh' }
}
]
},
get_state: { sessionFile: '/sessions/current.jsonl', isStreaming: false, isCompacting: false }
})
expect(
(await readPiRpcSessionOptions(rpc)).models[0]?.efforts.map(({ value }) => value)
).toEqual(['off', 'minimal', 'low', 'medium', 'high', 'xhigh'])
})
})
+119
View File
@@ -0,0 +1,119 @@
import { z } from 'zod'
import type {
AgentSessionCommandsResult,
AgentSessionOptionsResult
} from '../../shared/agent-session-wire'
import { piRpcModelSchema, piRpcStateSchema } from './rpc-protocol'
export type PiRpcRequester = {
request(
command: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number | null }
): Promise<unknown>
}
const modelSchema = piRpcModelSchema.safeExtend({
provider: z.string().min(1),
id: z.string().min(1),
thinkingLevelMap: z.record(z.string(), z.string().nullable()).optional()
})
const modelsSchema = z.object({ models: z.array(modelSchema) })
const stateSchema = piRpcStateSchema.safeExtend({
model: modelSchema.nullish()
})
const commandsSchema = z.object({
commands: z.array(
z.object({
name: z.string().min(1),
description: z.string().optional(),
source: z.string().optional()
})
)
})
const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
export function piModelOptionId(provider: string, modelId: string): string {
if (!provider || !modelId || provider.includes('/')) {
throw new Error('Pi model requires an explicit provider and model ID')
}
return `${provider}/${modelId}`
}
export function parsePiModelOptionId(value: string): { provider: string; modelId: string } {
const slash = value.indexOf('/')
if (slash < 1 || slash === value.length - 1) {
throw new Error('Pi model requires an explicit provider and model ID')
}
return { provider: value.slice(0, slash), modelId: value.slice(slash + 1) }
}
export async function readPiRpcSessionOptions(
rpc: PiRpcRequester
): Promise<AgentSessionOptionsResult> {
const models = modelsSchema.parse(await rpc.request('get_available_models')).models
const state = stateSchema.parse(await rpc.request('get_state'))
const currentModel = state.model
const currentId = currentModel ? piModelOptionId(currentModel.provider, currentModel.id) : ''
const listed = models.map((model) => {
const id = piModelOptionId(model.provider, model.id)
const efforts =
model.reasoning !== true
? []
: THINKING_LEVELS.filter((level) => {
const mapped = model.thinkingLevelMap?.[level]
return (
mapped !== null && ((level !== 'xhigh' && level !== 'max') || mapped !== undefined)
)
}).map((level) => ({ value: level, label: level }))
return {
id,
label: model.name || id,
isDefault: false,
efforts
}
})
return {
models: listed,
current: {
model: currentId,
...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}),
confirmed: [...(currentId ? ['model'] : []), ...(state.thinkingLevel ? ['effort'] : [])]
}
}
}
export async function applyPiRpcSessionOption(
rpc: PiRpcRequester,
selected: Map<string, string>,
key: string,
value: string
): Promise<Readonly<Record<string, string>>> {
if (key === 'model') {
const model = parsePiModelOptionId(value)
modelSchema.parse({ provider: model.provider, id: model.modelId })
await rpc.request('set_model', model)
} else if (key === 'effort') {
if (!THINKING_LEVELS.some((level) => level === value)) {
throw new Error(`Unsupported Pi thinking level: ${value}`)
}
await rpc.request('set_thinking_level', { level: value })
} else {
throw new Error(`Unsupported Pi option: ${key}`)
}
selected.set(key, value)
return Object.fromEntries(selected)
}
export async function readPiRpcCommands(rpc: PiRpcRequester): Promise<AgentSessionCommandsResult> {
const { commands } = commandsSchema.parse(await rpc.request('get_commands'))
return {
commands: commands.map((command) => ({
name: command.name,
kind: command.source === 'skill' ? 'skill' : 'command',
...(command.source === undefined ? { kindUnspecified: true as const } : {}),
...(command.description ? { description: command.description } : {})
}))
}
}
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PiRpcPromptDelivery } from './rpc-prompt-delivery'
const deliveries: PiRpcPromptDelivery[] = []
afterEach(() => {
for (const delivery of deliveries.splice(0)) {
delivery.end()
}
vi.useRealTimers()
})
function rig() {
const send = vi.fn(async () => {}),
accepted = vi.fn(),
settled = vi.fn(),
beforeWrite = vi.fn()
const delivery = new PiRpcPromptDelivery({
send,
accepted,
settled,
beforeWrite,
commandOnly: vi.fn(),
rejectedAfterAcceptance: vi.fn(),
failed: vi.fn()
})
deliveries.push(delivery)
return { delivery, send, accepted, settled, beforeWrite }
}
const queued = {
type: 'response',
command: 'prompt',
success: true,
data: { disposition: 'queued' }
}
describe('Pi pending prompt delivery bounds and ownership', () => {
it('bounds queued prompts even after their acknowledgements arrive', async () => {
const h = rig()
for (let i = 0; i < 128; i++) {
await h.delivery.submit(`prompt:${i}`, i, { type: 'prompt', message: 'queued' })
h.delivery.reply(queued)
}
const before = vi.fn(async () => {})
expect(
await h.delivery.submit('overflow', 129, { type: 'prompt', message: 'overflow' }, before)
).toMatchObject({ state: 'rejected', rejection: { kind: 'queueFull' } })
expect(before).not.toHaveBeenCalled()
expect(h.send).toHaveBeenCalledTimes(128)
h.delivery.consumeNext()
await expect(
h.delivery.submit('after-consumption', 130, { type: 'prompt', message: 'next' })
).resolves.toEqual({ state: 'admitted' })
})
it('bounds retained retry payload bytes before journal dispatch admission', async () => {
const h = rig()
const frame = { type: 'prompt', message: 'x'.repeat(8 * 1024 * 1024 - 128) }
for (let i = 0; i < 4; i++) {
await h.delivery.submit(`large:${i}`, i, frame)
}
const before = vi.fn(async () => {})
expect(await h.delivery.submit('overflow', 5, frame, before)).toMatchObject({
state: 'rejected',
rejection: { kind: 'queueFull' }
})
expect(before).not.toHaveBeenCalled()
h.delivery.consumeNext()
await expect(h.delivery.submit('fits-after-consumption', 6, frame)).resolves.toEqual({
state: 'admitted'
})
})
it('does not consume an input whose dispatch commit has not finished', async () => {
const h = rig()
const pending = Promise.withResolvers<void>()
const submitted = h.delivery.submit(
'pending',
1,
{ type: 'prompt', message: 'pending' },
() => pending.promise
)
expect(h.delivery.consumeNext()).toBe(false)
expect(h.beforeWrite).not.toHaveBeenCalled()
pending.resolve()
await submitted
expect(h.delivery.consumeNext()).toBe(true)
expect(h.accepted).toHaveBeenCalledWith('pending', 1)
})
it('associates a later run with the prompt actually written while an auth retry waits', async () => {
vi.useFakeTimers()
const h = rig()
await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' })
h.delivery.reply({
type: 'response',
command: 'prompt',
success: false,
error: 'No API key found for provider'
})
await h.delivery.submit('second', 2, { type: 'prompt', message: 'second' })
h.delivery.consumeNext()
expect(h.accepted).toHaveBeenCalledExactlyOnceWith('second', 2)
h.delivery.reply(queued)
await vi.advanceTimersByTimeAsync(250)
h.delivery.consumeNext()
expect(h.accepted).toHaveBeenLastCalledWith('retrying', 1)
})
it('settles a pending auth retry as unknown on process teardown', async () => {
vi.useFakeTimers()
const h = rig()
await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' })
h.delivery.reply({
type: 'response',
command: 'prompt',
success: false,
error: 'No API key found for provider'
})
h.delivery.end()
expect(h.settled).toHaveBeenCalledWith(
'retrying',
expect.objectContaining({ state: 'unknown' })
)
expect(vi.getTimerCount()).toBe(0)
})
})
+203
View File
@@ -0,0 +1,203 @@
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { JsonlRpcRecord } from '../jsonl-rpc/peer'
import { piRpcPromptReplySchema } from './rpc-protocol'
type Submission = {
id: string
at: number
frame?: JsonlRpcRecord
bytes: number
accepted: boolean
retries: number
}
type DeliveryDeps = {
send: (frame: JsonlRpcRecord) => Promise<void>
accepted: (id: string, at: number) => void
commandOnly: () => void
rejectedAfterAcceptance: (error: string) => void
settled: (id: string, outcome: AgentSessionDispatchOutcome) => void
failed: (error: Error) => void
beforeWrite?: () => void
refused?: () => void
}
/** Idless prompt acknowledgements are FIFO and may wait indefinitely on extension dialogs. */
export class PiRpcPromptDelivery {
private readonly acknowledgements: Submission[] = []
private readonly waiting: Submission[] = []
private readonly retries = new Set<ReturnType<typeof setTimeout>>()
private readonly pending = new Set<Submission>()
private heldBytes = 0
private ended = false
constructor(private readonly deps: DeliveryDeps) {}
get holdsDispatch(): boolean {
return this.waiting.length > 0 || this.retries.size > 0
}
async submit(
id: string,
at: number,
frame: JsonlRpcRecord,
before?: () => Promise<void>
): Promise<AgentSessionDispatchOutcome> {
if (this.ended) {
throw new Error('Pi prompt queue unavailable')
}
const bytes = Buffer.byteLength(JSON.stringify(frame))
if (this.pending.size >= 128 || this.heldBytes + bytes > 32 * 1024 * 1024) {
return {
state: 'rejected',
...agentSessionFailureWords(agentSessionFailureFact('queueFull'), {
agentName: 'Pi',
surface: 'rejection'
})
}
}
const submission: Submission = { id, at, frame, bytes, accepted: false, retries: 0 }
this.pending.add(submission)
this.heldBytes += bytes
try {
await before?.()
if (this.ended) {
throw new Error('Pi ended before writing the prompt')
}
this.deps.beforeWrite?.()
this.acknowledgements.push(submission)
this.waiting.push(submission)
await this.deps.send(frame)
return { state: 'admitted' }
} catch {
this.remove(submission)
this.deps.refused?.()
return { state: 'unknown', reason: 'Pi prompt write did not settle' }
}
}
consumeNext(): boolean {
const submission = this.waiting[0]
if (!submission) {
return false
}
this.accept(submission)
return true
}
reply(frame: JsonlRpcRecord): void {
const reply = piRpcPromptReplySchema.parse(frame)
const submission = this.acknowledgements.shift()
if (!submission) {
throw new Error('Pi prompt reply has no request')
}
if (!reply.success) {
if (
!submission.accepted &&
reply.error?.startsWith('No API key found for ') &&
submission.retries++ < 8
) {
this.removeWaiting(submission)
const timer = setTimeout(() => {
this.retries.delete(timer)
if (this.ended) {
return
}
const frame = submission.frame
if (!frame) {
this.deps.failed(new Error('Pi retry lost its unaccepted prompt'))
return
}
this.acknowledgements.push(submission)
this.waiting.push(submission)
void this.deps
.send(frame)
.catch((error: unknown) =>
this.deps.failed(error instanceof Error ? error : new Error(String(error)))
)
}, 250)
timer.unref()
this.retries.add(timer)
return
}
this.remove(submission)
const fact = agentSessionFailureFact(
reply.error?.startsWith('No API key found for ') ? 'notSignedIn' : 'providerRejected',
{ detail: providerDiagnostic(reply.error ?? 'Pi rejected the prompt', 'person') }
)
this.deps.settled(submission.id, {
state: 'rejected',
...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'rejection' })
})
if (submission.accepted) {
this.deps.rejectedAfterAcceptance(reply.error ?? 'Pi rejected the prompt')
} else {
this.deps.refused?.()
}
return
}
this.releaseFrame(submission)
if (reply.data?.disposition === 'handled' || reply.data?.agentInvoked === false) {
this.accept(submission)
this.deps.commandOnly()
}
this.finishIfConsumed(submission)
}
end(): void {
this.ended = true
for (const timer of this.retries) {
clearTimeout(timer)
}
this.retries.clear()
for (const submission of this.pending) {
if (!submission.accepted) {
this.deps.settled(submission.id, {
state: 'unknown',
reason: 'Pi ended before confirming delivery'
})
}
this.releaseFrame(submission)
}
this.waiting.length = 0
this.acknowledgements.length = 0
this.pending.clear()
}
private accept(submission: Submission): void {
if (submission.accepted) {
return
}
submission.accepted = true
this.removeWaiting(submission)
this.releaseFrame(submission)
this.finishIfConsumed(submission)
this.deps.accepted(submission.id, submission.at)
}
private remove(submission: Submission): void {
for (const list of [this.waiting, this.acknowledgements]) {
const index = list.indexOf(submission)
if (index !== -1) {
list.splice(index, 1)
}
}
this.releaseFrame(submission)
this.pending.delete(submission)
}
private removeWaiting(submission: Submission): void {
const index = this.waiting.indexOf(submission)
if (index !== -1) {
this.waiting.splice(index, 1)
}
}
private releaseFrame(submission: Submission): void {
if (submission.frame) {
this.heldBytes -= submission.bytes
submission.frame = undefined
}
}
private finishIfConsumed(submission: Submission): void {
if (!this.waiting.includes(submission) && !this.acknowledgements.includes(submission)) {
this.pending.delete(submission)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt'
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXcAAAAASUVORK5CYII=',
'base64'
)
function message(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks }
}
describe('Pi RPC prompt preparation', () => {
it('encodes host-read image bytes and sends steer/followUp as prompt behavior', async () => {
const readLocal = vi.fn(async () => PNG)
const body = message([
{ type: 'text', text: 'Look at this' },
{ type: 'image-ref', path: '/workspace/screenshot.png' },
{ type: 'text', text: 'then reply' }
])
expect(await preparePiRpcPrompt(body, 'steer', { readLocal })).toEqual({
type: 'prompt',
message: 'Look at this\nthen reply',
streamingBehavior: 'steer',
images: [{ type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' }]
})
expect(readLocal).toHaveBeenCalledWith('/workspace/screenshot.png')
expect(
(await preparePiRpcPrompt(message([{ type: 'text', text: 'next' }]), 'followUp'))
.streamingBehavior
).toBe('followUp')
})
it('rejects URL refs with a typed unsupported attachment failure', async () => {
const readLocal = vi.fn(async () => PNG)
const rejected = await preparePiRpcPrompt(
message([{ type: 'image-ref', url: 'https://example.test/image.png' }]),
undefined,
{ readLocal }
).catch((error: unknown) => error)
expect(rejected).toBeInstanceOf(PiRpcPromptError)
expect(rejected).toMatchObject({
failure: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } }
})
expect(readLocal).not.toHaveBeenCalled()
})
it('checks local image MIME against bytes and rejects empty prompts', async () => {
const readLocal = vi.fn(async () => PNG)
await expect(
preparePiRpcPrompt(message([{ type: 'image-ref', path: '/a.jpg' }]), undefined, { readLocal })
).rejects.toThrow()
await expect(preparePiRpcPrompt(message([{ type: 'text', text: '' }]))).rejects.toThrow()
})
it('rejects a prompt beyond the RPC writer budget', async () => {
const oversizedText = 'x'.repeat(32 * 1024 * 1024)
await expect(
preparePiRpcPrompt(message([{ type: 'text', text: oversizedText }]))
).rejects.toThrow('RPC write limit')
})
})
+174
View File
@@ -0,0 +1,174 @@
import { extname } from 'node:path'
import { resolveJsonlRpcPeerOptions } from '../jsonl-rpc/peer-limits'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatImageRefBlock } from '../../shared/native-chat-types'
import {
agentSessionFailureFact,
type AgentSessionAttachmentProblem,
type SubmissionRejectionFact
} from '../../shared/agent-session-failure'
import {
ClaudeDispatchContentError,
readClaudeImage
} from '../claude/claude-structured-dispatch-content'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_COUNT = 20
const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_PROMPT_WIRE_BYTES = resolveJsonlRpcPeerOptions().maxLineBytes - 1
const MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp'
}
export type PiRpcPrompt = Record<string, unknown> & {
type: 'prompt'
message: string
images: { type: 'image'; data: string; mimeType: string }[]
streamingBehavior?: 'steer' | 'followUp'
}
type ImageReads = {
readLocal(path: string): Promise<Buffer>
}
export class PiRpcPromptError extends Error {
constructor(
message: string,
readonly failure: SubmissionRejectionFact
) {
super(message)
this.name = 'PiRpcPromptError'
}
}
function attachmentError(
message: string,
attachment: AgentSessionAttachmentProblem
): PiRpcPromptError {
return new PiRpcPromptError(message, agentSessionFailureFact('attachmentInvalid', { attachment }))
}
function imageMime(bytes: Uint8Array): string | null {
if (
bytes.length >= 8 &&
Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
) {
return 'image/png'
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg'
}
if (
bytes.length >= 6 &&
Buffer.from(bytes.subarray(0, 6))
.toString('ascii')
.match(/^GIF8[79]a$/)
) {
return 'image/gif'
}
if (
bytes.length >= 12 &&
Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' &&
Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP'
) {
return 'image/webp'
}
return null
}
async function readImage(
block: NativeChatImageRefBlock,
reads: ImageReads
): Promise<{ bytes: Buffer; mimeType: string }> {
if (block.url) {
throw attachmentError('Pi does not accept image URLs', { reason: 'unsupportedType' })
}
if (!block.path) {
throw attachmentError('Pi image needs a local path', { reason: 'noSource' })
}
const mimeType = MIME_BY_EXTENSION[extname(block.path).toLowerCase()]
if (!mimeType) {
throw attachmentError('Pi image file type is unsupported', { reason: 'unsupportedType' })
}
let bytes: Buffer
try {
bytes = await reads.readLocal(block.path)
} catch (error) {
if (error instanceof ClaudeDispatchContentError) {
throw new PiRpcPromptError('Pi image could not be read', error.failure)
}
throw new PiRpcPromptError(
'Pi image could not be read',
agentSessionFailureFact('attachmentUnreadable')
)
}
if (bytes.byteLength === 0) {
throw attachmentError('Pi image is empty', { reason: 'empty' })
}
if (bytes.byteLength > MAX_IMAGE_BYTES) {
throw attachmentError('Pi image exceeds the size limit', {
reason: 'tooLarge',
limit: MAX_IMAGE_BYTES
})
}
if (imageMime(bytes) !== mimeType) {
throw attachmentError('Pi image file did not contain a supported image', {
reason: 'unsupportedType'
})
}
return { bytes, mimeType }
}
export async function preparePiRpcPrompt(
body: AgentJournalMessageItem,
streamingBehavior?: PiRpcPrompt['streamingBehavior'],
reads: ImageReads = { readLocal: readClaudeImage }
): Promise<PiRpcPrompt> {
if (body.role !== 'user') {
throw new Error('Pi prompt must be a user message')
}
const texts: string[] = []
const images: PiRpcPrompt['images'] = []
let totalImageBytes = 0
for (const block of body.blocks) {
if (block.type === 'text' && block.text.length > 0) {
texts.push(block.text)
} else if (block.type === 'image-ref') {
if (images.length >= MAX_IMAGE_COUNT) {
throw attachmentError('Pi prompt has too many images', {
reason: 'tooMany',
limit: MAX_IMAGE_COUNT
})
}
const { bytes, mimeType } = await readImage(block, reads)
totalImageBytes += bytes.byteLength
if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) {
throw attachmentError('Pi images exceed the total size limit', {
reason: 'totalTooLarge',
limit: MAX_TOTAL_IMAGE_BYTES
})
}
images.push({ type: 'image', data: bytes.toString('base64'), mimeType })
}
}
if (texts.length === 0 && images.length === 0) {
throw new PiRpcPromptError('Pi prompt is empty', agentSessionFailureFact('emptyMessage'))
}
const prompt: PiRpcPrompt = {
type: 'prompt',
message: texts.join('\n'),
images,
...(streamingBehavior ? { streamingBehavior } : {})
}
if (Buffer.byteLength(JSON.stringify(prompt), 'utf8') > MAX_PROMPT_WIRE_BYTES) {
throw new PiRpcPromptError(
'Pi prompt exceeds the RPC write limit',
agentSessionFailureFact('historyTooLarge')
)
}
return prompt
}
+109
View File
@@ -0,0 +1,109 @@
import { z } from 'zod'
export const piRpcModelSchema = z.looseObject({
id: z.string(),
provider: z.string(),
name: z.string().optional(),
contextWindow: z.number().nonnegative().optional(),
reasoning: z.boolean().optional(),
input: z.array(z.string()).optional()
})
export type PiRpcModel = z.infer<typeof piRpcModelSchema>
export const piRpcStateSchema = z.looseObject({
model: piRpcModelSchema.nullish(),
thinkingLevel: z.string().optional(),
sessionFile: z.string().min(1),
sessionId: z.string().optional(),
isStreaming: z.boolean(),
isCompacting: z.boolean(),
pendingMessageCount: z.number().int().nonnegative().optional()
})
export type PiRpcState = z.infer<typeof piRpcStateSchema>
export function piRpcIdle(state: PiRpcState): boolean {
return !state.isStreaming && !state.isCompacting && (state.pendingMessageCount ?? 0) === 0
}
export const piRpcContentSchema = z.looseObject({
type: z.string(),
text: z.string().optional(),
thinking: z.string().optional()
})
export const piRpcUsageSchema = z.object({
input: z.number().nonnegative(),
output: z.number().nonnegative(),
cacheRead: z.number().nonnegative(),
cacheWrite: z.number().nonnegative()
})
export const piRpcMessageSchema = z.looseObject({
role: z.string(),
content: z.union([z.string(), z.array(piRpcContentSchema)]),
stopReason: z.string().optional(),
errorMessage: z.string().optional(),
usage: piRpcUsageSchema.optional(),
timestamp: z.number().optional()
})
export const piRpcMessageEventSchema = z.object({
type: z.enum(['message_start', 'message_update', 'message_end']),
message: piRpcMessageSchema.optional(),
usage: piRpcUsageSchema.optional(),
assistantMessageEvent: z
.looseObject({
type: z.string(),
delta: z.string().optional(),
contentIndex: z.number().int().nonnegative().optional(),
partial: piRpcMessageSchema.optional()
})
.optional()
})
export type PiRpcMessageEvent = z.infer<typeof piRpcMessageEventSchema>
export const piRpcToolEventSchema = z.object({
type: z.enum(['tool_execution_start', 'tool_execution_update', 'tool_execution_end']),
toolCallId: z.string(),
toolName: z.string(),
args: z.unknown().optional(),
partialResult: z.unknown().optional(),
result: z.unknown().optional(),
isError: z.boolean().optional()
})
export type PiRpcToolEvent = z.infer<typeof piRpcToolEventSchema>
export const piRpcPromptReplySchema = z.object({
type: z.literal('response'),
command: z.literal('prompt'),
success: z.boolean(),
error: z.string().optional(),
data: z
.object({
disposition: z.enum(['started', 'queued', 'handled']).optional(),
agentInvoked: z.boolean().optional()
})
.optional()
})
export const piRpcDialogSchema = z.discriminatedUnion('method', [
z.object({
id: z.union([z.string(), z.number()]),
method: z.literal('confirm'),
title: z.string(),
message: z.string().optional()
}),
z.object({
id: z.union([z.string(), z.number()]),
method: z.literal('select'),
title: z.string(),
message: z.string().optional(),
options: z.array(z.string()).min(1).max(64)
}),
z.object({
id: z.union([z.string(), z.number()]),
method: z.enum(['input', 'editor']),
title: z.string(),
message: z.string().optional(),
placeholder: z.string().optional(),
prefill: z.string().optional()
})
])
export type PiRpcDialog = z.infer<typeof piRpcDialogSchema>
+23
View File
@@ -0,0 +1,23 @@
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type { JsonlRpcRecord } from '../jsonl-rpc/peer'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
/** Retries occupy the existing activity surface until the provider resumes or exhausts them. */
export function piRpcRetryActivity(frame: JsonlRpcRecord): ProviderTimelineEvent {
return {
type: 'activity',
text:
frame.type === 'auto_retry_start'
? agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
detail:
typeof frame.errorMessage === 'string'
? providerDiagnostic(frame.errorMessage, 'person')
: undefined
}),
{ agentName: 'Pi', surface: 'row' }
).text
: null
}
}
@@ -0,0 +1,107 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runProcess } from '../../shared/child-process/run-process'
import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration'
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() }))
type LaunchInput = Parameters<NonNullable<typeof PI_RPC_RUNTIME_REGISTRATION.supportsLaunch>>[0]
function supportsLaunch(launch: LaunchInput): Promise<boolean> {
const check = PI_RPC_RUNTIME_REGISTRATION.supportsLaunch
if (!check) {
throw new Error('Pi registers a launch check')
}
return check(launch)
}
let root: string
let stockPi: string
function prints(stdout: string, result: Partial<Awaited<ReturnType<typeof runProcess>>> = {}) {
vi.mocked(runProcess).mockResolvedValue({
code: 0,
signal: null,
timedOut: false,
stdout,
stderr: '',
...result
})
}
beforeEach(async () => {
vi.mocked(runProcess).mockReset()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'info').mockImplementation(() => {})
root = await mkdtemp(join(tmpdir(), 'orca-pi-support-'))
stockPi = join(root, process.platform === 'win32' ? 'pi.cmd' : 'pi')
await writeFile(stockPi, '')
await chmod(stockPi, 0o755)
})
afterEach(async () => {
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
})
const input = (commandSettings: LaunchInput['commandSettings'] = {}): LaunchInput => ({
cwd: '/host/workspace',
env: { PATH: root, HOME: '/host/home' },
commandSettings
})
describe('Pi launch support before session creation', () => {
it.each(['0.84.0', '0.99.0', '1.0.0', '1.0.4\n', 'pi v1.12.3'])(
'accepts stable Pi from 0.84.0: %s',
async (out) => {
prints(out)
await expect(supportsLaunch(input())).resolves.toBe(true)
expect(runProcess).toHaveBeenCalledWith({
program: stockPi,
cwd: '/host/workspace',
env: { PATH: root, HOME: '/host/home' },
args: ['--version'],
timeoutMs: 5_000,
maxOutputBytes: 4_096,
killOnOutputLimit: true
})
}
)
it.each([
'0.73.1',
'0.83.9',
'0.84.0-rc.1',
'1.0.0-rc.1',
'1.1.0-preview.1',
'2.0.0',
'unknown',
''
])('keeps the terminal chat for an older, prerelease or unknown version: %s', async (out) => {
prints(out)
await expect(supportsLaunch(input())).resolves.toBe(false)
})
it.each([
{ code: 1, timedOut: false, outputTruncated: false },
{ code: 0, timedOut: true, outputTruncated: false },
{ code: 0, timedOut: false, outputTruncated: true }
])('does not infer support from incomplete or failed output: %j', async (result) => {
prints('1.0.4', result)
await expect(supportsLaunch(input())).resolves.toBe(false)
})
it('probes the binary the Command setting names, not the stock one', async () => {
prints('1.0.4')
await expect(
supportsLaunch(input({ agentCmdOverrides: { pi: `"${process.execPath}"` } }))
).resolves.toBe(true)
expect(runProcess).toHaveBeenCalledWith(expect.objectContaining({ program: process.execPath }))
})
it('leaves an unrunnable Command setting to the launch to refuse', async () => {
await expect(supportsLaunch(input({ agentCmdOverrides: { pi: '/missing/pi' } }))).resolves.toBe(
true
)
expect(runProcess).not.toHaveBeenCalled()
})
})
+73
View File
@@ -0,0 +1,73 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { agentSessionAccountHome } from '../../shared/agent-session-account-home'
import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location'
import type {
StructuredAgentAdapterContext,
StructuredAgentRuntimeAdapter,
StructuredAgentRuntimeRegistration
} from '../runtime/structured-agent-runtime-registrations'
import { PI_RPC_AGENT } from './rpc-agent-definition'
import {
createPiRpcLaunchResolver,
piRpcVersionSupported,
resolvePiRpcCommand
} from './rpc-launch-resolution'
import { PiRpcSessionAdapter } from './rpc-session-adapter'
function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter {
const { deps } = context
return new PiRpcSessionAdapter({
resolveLaunch: createPiRpcLaunchResolver({
store: context.store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment: async () => ({
...(await context.environment.resolveBaseEnvironment()),
...deps.resolveAgentLaunchEnv?.('pi')
}),
...(deps.resolveAgentCommandSettings
? { resolveCommandSettings: deps.resolveAgentCommandSettings }
: {})
}),
...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
onLifecycle: context.deliverLifecycle,
onSettled: ({ sessionId, clientMessageId, outcome }) => {
if (outcome.state === 'admitted') {
return
}
context.followUps.onDispatchSettledLate({
sessionId,
clientMessageId,
...(outcome.state === 'accepted' ? { providerIdentity: outcome.providerIdentity } : outcome)
})
},
onIdle: context.followUps.releaseUnansweredDispatches,
logger: deps.logger
})
}
export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = {
definition: PI_RPC_AGENT,
createAdapter: createPiRpcAdapter,
supportsLocation: supportsSupervisedProviderChildLocation,
supportsLaunch: async ({ cwd, env, commandSettings }) => {
let command: string
try {
command = resolvePiRpcCommand(env, commandSettings)
} catch (error) {
// An unrunnable Command setting is the launch's refusal to state, not a terminal.
if (isAgentSessionPreSpawnError(error)) {
return true
}
throw error
}
return piRpcVersionSupported({ command, cwd, env })
},
resolveAccountHome: async ({ launchEnv }) =>
agentSessionAccountHome(
PI_RPC_AGENT,
launchEnv.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent')
)
}
+562
View File
@@ -0,0 +1,562 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { agentJournalTurnBody } from '../../shared/agent-session-turn-record'
import {
AgentSessionAcquisitionRootExitObservedError,
type StructuredAgentSessionAcquireInput
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
closeProviderTimelineRigs,
openProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection'
import type { JsonlRpcRecord } from '../jsonl-rpc/peer'
import { JsonlRpcResponseError } from '../jsonl-rpc/peer'
import type { PiRpcConnection } from './rpc-session'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { PiRpcSessionAdapter } from './rpc-session-adapter'
const sessionId = 'session-timeline'
const file = '/host/account/sessions/session.jsonl'
const state = {
sessionFile: file,
isStreaming: false,
isCompacting: false,
model: { provider: 'anthropic', id: 'model-1' }
}
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) {
await cleanup()
}
await closeProviderTimelineRigs()
})
class FakeConnection implements PiRpcConnection {
pid = 4123
closed = false
rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live'
processless = false
deferExit = false
lastCloseResult: PiRpcConnection['lastCloseResult'] = null
closeResult: Awaited<ReturnType<PiRpcConnection['close']>> = { root: 'exited', tree: 'exited' }
readonly sent: JsonlRpcRecord[] = []
readonly requests: string[] = []
private readonly exitListeners: (() => void)[] = []
requestOverride?: (command: string) => Promise<unknown> | undefined
abortRequest?: () => void
constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {}
async request(command: string): Promise<unknown> {
this.requests.push(command)
const override = this.requestOverride?.(command)
if (override) {
return override
}
if (command === 'get_state') {
return state
}
if (command === 'get_available_models') {
return { models: [state.model] }
}
if (command === 'get_commands') {
return { commands: [{ name: 'help' }] }
}
if (command === 'set_thinking_level') {
throw new Error('saved effort unavailable')
}
return {}
}
async send(frame: JsonlRpcRecord): Promise<void> {
this.sent.push(frame)
}
async close(): Promise<Awaited<ReturnType<PiRpcConnection['close']>>> {
this.abortRequest?.()
this.closed = true
this.rootVerdict = this.closeResult.root
this.lastCloseResult = this.closeResult
if (this.closeResult.root === 'exited' && !this.deferExit) {
this.exit()
}
return this.closeResult
}
pauseReading(): void {}
resumeReading(): void {}
onExit(listener: () => void): void {
if (this.rootVerdict === 'exited') {
listener()
} else {
this.exitListeners.push(listener)
}
}
receive(frame: JsonlRpcRecord): void {
this.handlers.onRecord?.(frame)
}
exit(error = new Error('child exited')): void {
this.closed = true
this.rootVerdict = 'exited'
for (const listener of this.exitListeners.splice(0)) {
listener()
}
this.handlers.onExit?.(error, {
expected: false,
exit: { code: 1, signal: null, processless: false }
})
}
}
async function setup(
options: Readonly<Record<string, string>> = {},
eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink
) {
const rig = await openProviderTimelineRig({ agent: 'pi', sessionId })
const connections: FakeConnection[] = []
const lifecycle = vi.fn(),
settled = vi.fn(),
idle = vi.fn()
const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => {
expect(connections.at(-1)?.requests).toEqual([])
})
const input: StructuredAgentSessionAcquireInput = {
identity: {
sessionId,
workspaceId: 'folder-1',
hostId: 'local',
agent: 'pi',
providerHandle: null
},
fence: 7,
spawnToken: 'spawn-token',
options,
events: eventSink?.(rig.eventSink) ?? rig.eventSink,
onSpawned
}
const resolveLaunch = vi.fn(async () => ({
command: '/host/bin/pi',
cwd: '/host/folder',
fullAccess: true,
previous: null
}))
const openConnection = vi.fn(
(_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => {
const connection = new FakeConnection(handlers)
connections.push(connection)
return connection
}
)
const adapter = new PiRpcSessionAdapter({
resolveLaunch,
readProcessStartTime: async () => 12345,
openConnection,
onLifecycle: lifecycle,
onSettled: settled,
onIdle: idle,
logger: { warn: vi.fn(), error: vi.fn() }
})
const acquired = await adapter.acquire(input)
const connection = connections[0]
if (!connection) {
throw new Error('connection missing')
}
cleanups.push(async () => {
await adapter.closeAll().catch(() => {})
await adapter.drainObservedExits()
adapter.acknowledgeSessionRelease(sessionId)
})
return {
adapter,
connection,
rig,
acquired,
onSpawned,
lifecycle,
settled,
idle,
input,
resolveLaunch,
openConnection,
connections
}
}
describe('Pi RPC session ownership and delivery', () => {
it('does not spawn a start cancelled while resolving its workspace', async () => {
const h = await setup()
await h.adapter.closeSession(sessionId)
const launch = await h.resolveLaunch.mock.results[0]?.value
const resolving = Promise.withResolvers<NonNullable<typeof launch>>()
h.resolveLaunch.mockImplementationOnce(() => resolving.promise)
const controller = new AbortController()
const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal })
await Promise.resolve()
controller.abort(new Error('Pi closed while starting'))
await expect(started).rejects.toThrow('closed while starting')
expect(h.connections).toHaveLength(1)
await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({
link: { origin: 'created' }
})
resolving.resolve(launch!)
await Promise.resolve()
expect(h.connections).toHaveLength(2)
})
it('kills the child and rejects a stalled startup when the host aborts it', async () => {
const h = await setup()
await h.adapter.closeSession(sessionId)
const controller = new AbortController()
const opened = Promise.withResolvers<FakeConnection>()
const reply = Promise.withResolvers<unknown>()
h.openConnection.mockImplementationOnce((_launch, handlers) => {
const connection = new FakeConnection(handlers)
connection.requestOverride = (command) =>
command === 'get_state' ? reply.promise : undefined
connection.abortRequest = () => reply.reject(new Error('closed while starting'))
h.connections.push(connection)
opened.resolve(connection)
return connection
})
const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal })
const child = await opened.promise
await Promise.resolve()
controller.abort(new Error('Pi closed while starting'))
await expect(started).rejects.toThrow('closed while starting')
expect(child.rootVerdict).toBe('exited')
})
it('retains final events when exit publication times out under journal backpressure', async () => {
let blocked = true
const h = await setup({}, (sink) => ({
...sink,
tryAppendTransition: (transition) =>
blocked
? { accepted: false, reason: 'backpressure' }
: sink.tryAppendTransition!(transition)
}))
vi.useFakeTimers()
h.connection.receive({
type: 'extension_ui_request',
id: 'last-dialog',
method: 'input',
title: 'Final tail'
})
h.connection.exit()
await vi.advanceTimersByTimeAsync(2_001)
expect(h.lifecycle).toHaveBeenCalledOnce()
h.adapter.acknowledgeSessionRelease(sessionId)
let completed = false
const drained = h.adapter.drainObservedExits().then(() => {
completed = true
})
await Promise.resolve()
expect(completed).toBe(false)
blocked = false
await vi.advanceTimersByTimeAsync(250)
await drained
expect(
(await h.rig.rows()).some(
(row) => row.body.kind === 'question' && row.body.question === 'Final tail'
)
).toBe(true)
vi.useRealTimers()
})
it('lets a new acquisition proceed while the released child drains final stdout', async () => {
const h = await setup()
h.connection.deferExit = true
await h.adapter.closeSession(sessionId)
h.adapter.acknowledgeSessionRelease(sessionId)
await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({
link: { origin: 'created' }
})
let drained = false
const delivery = h.adapter.drainObservedExits().then(() => {
drained = true
})
await Promise.resolve()
expect(drained).toBe(false)
h.connection.exit()
await delivery
expect(h.lifecycle).toHaveBeenCalledWith(
expect.objectContaining({ fence: 7, cause: 'requested-close' })
)
})
it('preserves an unexpected failure cause during forced cleanup', async () => {
const h = await setup()
await h.adapter.forceCloseSession(sessionId)
await h.adapter.drainObservedExits()
expect(h.lifecycle).toHaveBeenCalledWith(
expect.objectContaining({ cause: 'unexpected-exit', reason: 'Pi event sink failed' })
)
})
it('records the spawned child before the first startup RPC and links the native file', async () => {
const h = await setup({ effort: 'high', unknown: 'old-value' })
expect(h.onSpawned).toHaveBeenCalledWith({
hostId: 'local',
pid: 4123,
processStartTimeMs: 12345,
spawnToken: 'spawn-token'
})
expect(h.acquired.process).toEqual(h.onSpawned.mock.calls[0]?.[0])
expect(h.acquired.link).toMatchObject({
handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file },
origin: 'created',
mintedAtFence: 7
})
expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['effort', 'unknown'])
expect(h.connection.requests).toContain('get_commands')
})
it('admits a send before Pi confirms it and settles only after journal acceptance', async () => {
const h = await setup()
const result = await h.adapter.dispatch({
sessionId,
fence: 7,
clientMessageId: 'send-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
})
expect(result).toEqual({ state: 'admitted' })
expect(h.settled).not.toHaveBeenCalled()
h.connection.receive({ type: 'agent_start' })
expect(h.settled).toHaveBeenCalledWith({
sessionId,
clientMessageId: 'send-1',
fence: 7,
outcome: {
state: 'accepted',
providerIdentity: { provider: 'orca', clientMessageId: 'send-1' }
}
})
expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' })
})
it('rejects stale fences and a Stop for a different turn before sending abort', async () => {
const h = await setup()
await expect(
h.adapter.dispatch({
sessionId,
fence: 6,
clientMessageId: 'stale',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] }
})
).rejects.toThrow('not live under this fence')
await h.adapter.dispatch({
sessionId,
fence: 7,
clientMessageId: 'send-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] }
})
h.connection.receive({ type: 'agent_start' })
expect(
await h.adapter.cancelTurn({
sessionId,
fence: 7,
turnId: 'other',
resolveLiveTurnId: () => h.rig.assembler.openTurnId
})
).toEqual({ cancelled: false })
expect(h.connection.requests).not.toContain('abort')
expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true)
expect(h.connection.requests).toContain('abort')
expect(await h.adapter.closeSession(sessionId)).toBe(true)
expect(h.connection.closed).toBe(true)
})
it('keeps a pending manual compaction cancellable and reports root/tree proof honestly', async () => {
const h = await setup()
const turnId = 'manual-compact'
const command = {
clientMessageId: 'compact-1',
turnId,
identity: { provider: 'orca' as const, clientMessageId: 'compact-1' },
resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' },
running: { kind: 'turn' as const, turnId, state: 'running' as const }
}
let release: (() => void) | undefined
h.connection.requestOverride = (name) =>
name === 'compact'
? new Promise<void>((resolve) => {
release = resolve
})
: undefined
const compact = h.adapter.compact({ sessionId, fence: 7, command })
expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true)
expect(h.connection.requests).toContain('abort')
release?.()
await compact
h.connection.closeResult = { root: 'exited', tree: 'unverifiable' }
await expect(h.adapter.closeSession(sessionId)).rejects.toBeInstanceOf(
AgentSessionAcquisitionRootExitObservedError
)
})
it('ends the host command row under its original identity after a no-op compaction', async () => {
const h = await setup()
const command = {
clientMessageId: 'compact-1',
turnId: 'manual-compact',
identity: { provider: 'orca' as const, clientMessageId: 'compact-turn' },
resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' },
running: { kind: 'turn' as const, turnId: 'manual-compact', state: 'running' as const }
}
h.rig.journal.appendItem(command.identity, agentJournalTurnBody(command.running), {
fence: 1,
turnScope: { kind: 'thread' }
})
h.connection.requestOverride = (name) =>
name === 'compact'
? Promise.reject(new JsonlRpcResponseError(name, 'Nothing to compact (session too small)'))
: undefined
expect(await h.adapter.compact({ sessionId, fence: 7, command })).toEqual({
state: 'accepted',
providerIdentity: null
})
await Promise.resolve()
await Promise.resolve()
expect(
(await h.rig.rows()).find((row) => row.itemId === agentJournalItemKey(command.identity))?.body
).toMatchObject({ kind: 'turn', state: 'completed', outcome: 'success' })
expect(
(await h.rig.rows()).some((row) => row.body.kind === 'status' && row.body.tone === 'warning')
).toBe(true)
})
it('answers a live dialog once after commit and does not claim it after dismissal', async () => {
const h = await setup()
h.connection.receive({
type: 'extension_ui_request',
id: 'input-1',
method: 'editor',
title: 'Edit',
prefill: 'draft'
})
const row = (await h.rig.rows()).find((item) => item.body.kind === 'question')
expect(row?.body).toMatchObject({
kind: 'question',
freeTextInput: { allowEmpty: true, initialValue: 'draft' }
})
if (!row) {
throw new Error('dialog row missing')
}
let release: (() => void) | undefined
const commit = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const response = {
kind: 'answers' as const,
answers: [{ questionId: 'input-1', optionIds: [], other: '' }]
}
const first = h.adapter.answerPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
kind: 'question',
response,
commit
})
await expect(
h.adapter.answerPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
kind: 'question',
response,
commit: vi.fn()
})
).rejects.toThrow('no longer waiting')
release?.()
await first
expect(commit).toHaveBeenCalledTimes(1)
expect(h.connection.sent).toContainEqual({
type: 'extension_ui_response',
id: 'input-1',
value: ''
})
await expect(
h.adapter.dismissPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
answer: true,
commit: vi.fn()
})
).rejects.toThrow('no longer waiting')
})
it('releases a failed dialog commit for a later dismissal, with one provider reply', async () => {
const h = await setup()
h.connection.receive({
type: 'extension_ui_request',
id: 2,
method: 'confirm',
title: 'Continue?'
})
const row = (await h.rig.rows()).find((item) => item.body.kind === 'approval')
if (!row) {
throw new Error('approval row missing')
}
await expect(
h.adapter.answerPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
kind: 'approval',
response: { kind: 'option', optionId: 'yes' },
commit: async () => {
throw new Error('CAS lost')
}
})
).rejects.toThrow('CAS lost')
expect(h.connection.sent).toEqual([])
let release: (() => void) | undefined
const dismiss = h.adapter.dismissPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
answer: true,
commit: () =>
new Promise<void>((resolve) => {
release = resolve
})
})
await expect(
h.adapter.answerPrompt({
sessionId,
fence: 7,
itemId: row.itemId,
kind: 'approval',
response: { kind: 'option', optionId: 'no' },
commit: vi.fn()
})
).rejects.toThrow('no longer waiting')
release?.()
await dismiss
expect(h.connection.sent).toEqual([{ type: 'extension_ui_response', id: 2, cancelled: true }])
})
it('reports unexpected child exit once and preserves an unproven root on close', async () => {
const h = await setup()
h.connection.exit(new Error('provider died'))
await h.adapter.drainObservedExits()
h.connection.exit(new Error('duplicate exit'))
await h.adapter.drainObservedExits()
expect(h.lifecycle).toHaveBeenCalledTimes(1)
expect(h.lifecycle).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ended',
sessionId,
fence: 7,
acquisitionGeneration: h.acquired.acquisitionGeneration,
cause: 'unexpected-exit',
reason: 'provider died'
})
)
const other = await setup()
other.connection.closeResult = { root: 'unverifiable', tree: null }
expect(await other.adapter.closeSession(sessionId)).toBe(false)
})
})
+287
View File
@@ -0,0 +1,287 @@
import { randomUUID } from 'node:crypto'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { agentSessionFailureFact } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import {
AgentSessionPreSpawnError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionExitProvenError,
type AgentSessionAcquisition,
type StructuredAgentSessionAcquireInput,
type StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity'
import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts'
import { compactPiRpcSession } from './rpc-compaction'
import { buildPiRpcLaunch } from './rpc-launch'
import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution'
import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session'
import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt'
import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options'
import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location'
import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content'
export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & {
resolveLaunch: (
identity: StructuredAgentSessionAcquireInput['identity']
) => Promise<PiRpcResolvedLaunch>
readProcessStartTime?: (pid: number) => Promise<number | null>
}
export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, PiRpcSession>()
private readonly acquiring = new Set<string>()
private readonly starts = new ProviderAcquisitionStarts<PiRpcConnection>()
private readonly retiring = new Set<Promise<void>>()
constructor(private readonly deps: PiRpcSessionAdapterDeps) {}
supportsLocation = supportsSupervisedProviderChildLocation
supportsCreate: NonNullable<StructuredAgentSessionAdapter['supportsCreate']> = (
location,
agent
) => agent === 'pi' && this.supportsLocation(location)
async acquire(input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> {
const id = input.identity.sessionId
if (this.acquiring.has(id)) {
throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child'))
}
this.acquiring.add(id)
const attempt = this.starts.begin(input.signal)
let session: PiRpcSession | undefined
try {
if (attempt.signal.aborted) {
throw new AgentSessionPreSpawnError(new Error('Pi closed while starting'))
}
if (!(await this.starts.stopFailed(id))) {
throw new AgentSessionAcquisitionExitUnprovenError(
new Error('Previous Pi start has not exited')
)
}
const previous = this.sessions.get(id)
if (previous?.connection.closed && previous.connection.rootVerdict === 'exited') {
this.acknowledgeSessionRelease(id)
} else if (previous) {
throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child'))
}
let launch: PiRpcResolvedLaunch
try {
launch = await waitForPromiseWithSignal(
this.deps.resolveLaunch(input.identity),
attempt.signal
)
} catch (error) {
throw new AgentSessionPreSpawnError(error)
}
if (attempt.signal.aborted) {
throw new AgentSessionPreSpawnError(new Error('Pi closed while starting'))
}
const spec = buildPiRpcLaunch({
...launch,
structuredSession: { id, spawnToken: input.spawnToken }
})
session = new PiRpcSession(input, randomUUID(), spec, this.deps)
this.sessions.set(id, session)
this.starts.track(attempt, session.connection)
const spawned = providerSpawnedProcessIdentity(
input,
'Pi RPC',
this.deps.readProcessStartTime
)
if (session.connection.pid !== undefined) {
await spawned.onSpawned(session.connection.pid)
}
const process = await spawned.read(session.connection.pid)
const file = await waitForPromiseWithSignal(session.start(), attempt.signal)
if (session.connection.closed || attempt.signal.aborted) {
throw new Error('Pi exited while starting')
}
return {
process,
acquisitionGeneration: session.generation,
link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now())
}
} catch (error) {
if (!session) {
throw error instanceof AgentSessionPreSpawnError ||
error instanceof AgentSessionAcquisitionExitUnprovenError
? error
: new AgentSessionPreSpawnError(error)
}
const result = await session.close(false).catch(() => null)
if (result?.root === 'exited') {
if (session.connection.processless) {
throw new AgentSessionAcquisitionExitProvenError(error)
}
throw new AgentSessionAcquisitionRootExitObservedError(error)
}
this.starts.retainFailed(id, session.connection)
throw new AgentSessionAcquisitionExitUnprovenError(error)
} finally {
this.starts.end(attempt)
this.acquiring.delete(id)
}
}
dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => {
const session = this.session(input.sessionId, input.fence)
let prompt
try {
prompt = await preparePiRpcPrompt(input.body)
} catch (error) {
this.deps.logger.warn('Pi prompt could not be prepared', {
scope: 'pi-prompt',
sessionId: input.sessionId,
error
})
return {
state: 'rejected',
...agentSessionFailureWords(
error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError
? error.failure
: agentSessionFailureFact('attachmentUnreadable'),
{ agentName: 'Pi', surface: 'rejection' }
)
}
}
return session.turns.submit(
input.clientMessageId,
input.requestedAt ?? Date.now(),
{ ...prompt },
input.beforeDispatch
)
}
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = async (input) =>
compactPiRpcSession(this.session(input.sessionId, input.fence), input.command)
cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => {
const session = this.session(input.sessionId, input.fence)
const turnId = input.resolveLiveTurnId ? input.resolveLiveTurnId() : session.lane.openTurnId
if (input.turnId !== undefined && input.turnId !== turnId) {
return { cancelled: false }
}
if (!turnId && !session.turns.holdsDispatch) {
return { cancelled: false }
}
session.turns.stop()
session.dialogs.cancelAll()
await session.connection.request('abort', {}, { timeoutMs: 2_000 })
return { cancelled: true, ...(turnId ? { turnId } : {}) }
}
stopEndsSession(): boolean {
return true
}
awaitStoppedRequestEnd: NonNullable<StructuredAgentSessionAdapter['awaitStoppedRequestEnd']> =
async (id, at) => {
const session = this.sessions.get(id)
const turn = session?.lane.openTurnId
if (!session) {
return
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
if (turn) {
await Promise.race([
session.lane.whenTurnLeaves(turn),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, Math.max(0, at + 2_000 - Date.now()))
timer.unref()
})
])
}
} finally {
clearTimeout(timer)
}
try {
await session.connection.request('get_state', {}, { timeoutMs: 1_000 })
} catch (error) {
this.deps.logger.warn('Pi checkpoint could not be read before stopping', {
scope: 'pi-stop-checkpoint',
sessionId: id,
error
})
}
}
routePromptCancel(): { kind: 'dismiss' } {
return { kind: 'dismiss' }
}
dismissPrompt: NonNullable<StructuredAgentSessionAdapter['dismissPrompt']> = (input) =>
this.session(input.sessionId, input.fence).dialogs.respond(
input.itemId,
null,
input.commit,
input.answer
)
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
this.session(input.sessionId, input.fence).dialogs.respond(
input.itemId,
input.response,
input.commit
)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) => {
const session = this.session(input.sessionId, input.fence)
return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value)
}
readOptions: NonNullable<StructuredAgentSessionAdapter['readOptions']> = (input) =>
readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection)
readCommands = (id: string) => this.sessions.get(id)?.commands
readOptionRestoreFailures(id: string): readonly string[] {
return this.sessions.get(id)?.skipped ?? []
}
holdsDispatch(id: string): boolean {
return this.sessions.get(id)?.turns.holdsDispatch ?? false
}
async closeSession(id: string, requested = true): Promise<boolean> {
const session = this.sessions.get(id)
if (!session) {
return true
}
const result = await session.close(requested)
if (result.root !== 'exited') {
return false
}
if (result.tree !== 'exited') {
throw new AgentSessionAcquisitionRootExitObservedError(new Error('Pi root exit observed'))
}
return true
}
releaseAcquisition(input: { sessionId: string }): Promise<boolean> {
return this.closeSession(input.sessionId)
}
forceCloseSession(id: string): Promise<boolean> {
this.sessions.get(id)?.fail(new Error('Pi event sink failed'))
return this.closeSession(id, false)
}
disposeSession(id: string): Promise<boolean> {
return this.closeSession(id)
}
acknowledgeSessionRelease(id: string): void {
const session = this.sessions.get(id)
if (!session?.connection.closed || session.connection.rootVerdict !== 'exited') {
return
}
this.sessions.delete(id)
const retirement = session.retire()
this.retiring.add(retirement)
void retirement.then(() => this.retiring.delete(retirement))
}
async closeAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id)))
}
async drainObservedExits(): Promise<void> {
await Promise.all([
...this.retiring,
...[...this.sessions.values()].map((session) => session.drainObservedExit())
])
}
private session(id: string, fence: number): PiRpcSession {
const session = this.sessions.get(id)
if (!session || session.input.fence !== fence || session.connection.closed) {
throw new Error('Pi session is not live under this fence')
}
return session
}
}
+273
View File
@@ -0,0 +1,273 @@
import type {
AgentSessionOptionsResult,
AgentSessionSlashCommand
} from '../../shared/agent-session-wire'
import type {
StructuredAgentSessionAcquireInput,
StructuredAgentSessionLifecycleEvent,
AgentSessionDispatchOutcome
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane'
import {
JsonlRpcAgentConnection,
type JsonlRpcAgentConnectionOptions
} from '../jsonl-rpc/agent-connection'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import { PiRpcTurns } from './rpc-turns'
import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks'
import { piRpcStateSchema } from './rpc-protocol'
import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options'
export type PiRpcConnection = Pick<
JsonlRpcAgentConnection,
| 'request'
| 'send'
| 'close'
| 'pauseReading'
| 'resumeReading'
| 'pid'
| 'closed'
| 'rootVerdict'
| 'processless'
| 'lastCloseResult'
| 'onExit'
>
export type PiRpcSessionDeps = {
openConnection?: (
launch: ProviderProcessLaunch,
handlers: JsonlRpcAgentConnectionOptions
) => PiRpcConnection
onLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void
onSettled: (input: {
sessionId: string
clientMessageId: string
fence: number
outcome: AgentSessionDispatchOutcome
}) => void
onIdle: (input: { sessionId: string }) => void
logger: StructuredAgentSessionLogger
}
/** One child owns its transport, dialect, dialogs and timeline, including shutdown retries. */
export class PiRpcSession {
readonly connection: PiRpcConnection
readonly lane: JsonlRpcTimelineLane
readonly turns: PiRpcTurns
readonly dialogs: PiRpcDialogCallbacks
readonly selected = new Map<string, string>()
readonly skipped: string[] = []
commands?: AgentSessionSlashCommand[]
options?: AgentSessionOptionsResult
requestedClose = false
private publishedExit = false
private failedCause?: Error
private exitDelivery: Promise<void> = Promise.resolve()
private releaseAfterExit?: () => void
constructor(
readonly input: StructuredAgentSessionAcquireInput,
readonly generation: string,
launch: ProviderProcessLaunch,
private readonly deps: PiRpcSessionDeps
) {
const sink = input.events && providerTimelineSink(input.events)
if (!sink) {
throw new Error('Pi structured chat requires the shared timeline sink')
}
this.lane = new JsonlRpcTimelineLane({
sink,
sessionId: input.identity.sessionId,
agent: 'pi',
generation,
namespace: input.identity.sessionId,
pauseReading: () => this.connection?.pauseReading(),
resumeReading: () => this.connection?.resumeReading(),
onFailed: (reason) => this.fail(new Error(reason)),
onInputAccepted: (clientMessageId) =>
this.settle(clientMessageId, {
state: 'accepted',
providerIdentity: { provider: 'orca', clientMessageId }
})
})
this.turns = new PiRpcTurns({
lane: this.lane,
generation,
send: (frame) => this.connection.send(frame),
request: (command, params, options) => this.connection.request(command, params, options),
settled: (id, outcome) => this.settle(id, outcome),
idle: () => {
this.dialogs.cancelAll()
deps.onIdle({ sessionId: input.identity.sessionId })
},
failed: (error) => this.fail(error),
diagnostic: (error) =>
deps.logger.warn('Pi context usage could not be read', {
scope: 'pi-context-usage',
sessionId: input.identity.sessionId,
error
})
})
this.dialogs = new PiRpcDialogCallbacks(
this.lane,
(frame) => this.connection.send(frame),
(error) => this.fail(error)
)
this.connection = (
deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers))
)(launch, {
onRecord: (frame) =>
frame.type === 'extension_ui_request'
? this.dialogs.receive(frame)
: this.turns.receive(frame),
onClose: (error) => this.fail(error),
onDiagnostic: (message) =>
deps.logger.warn(message, { scope: 'pi-rpc', sessionId: input.identity.sessionId }),
onExit: (error) => {
this.exitDelivery = this.exit(error)
.catch((cause: unknown) =>
deps.logger.error('Pi exit publication failed', {
scope: 'pi-exit',
sessionId: input.identity.sessionId,
error: cause
})
)
.finally(() => this.releaseAfterExit?.())
}
})
}
async start(): Promise<string> {
const state = piRpcStateSchema.parse(await this.connection.request('get_state'))
this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now()))
for (const [key, value] of Object.entries(this.input.options ?? {})) {
if (!['model', 'effort'].includes(key)) {
this.skipped.push(key)
continue
}
try {
await applyPiRpcSessionOption(this.connection, this.selected, key, value)
} catch (error) {
this.skipped.push(key)
this.deps.logger.warn('Pi rejected a saved option', {
scope: 'pi-option-restore',
sessionId: this.input.identity.sessionId,
key,
error
})
}
}
this.options = await readPiRpcSessionOptions(this.connection)
try {
this.commands = (await readPiRpcCommands(this.connection)).commands
} catch (error) {
this.deps.logger.warn('Pi commands could not be read', {
scope: 'pi-commands',
sessionId: this.input.identity.sessionId,
error
})
}
return state.sessionFile
}
async close(requested = true): ReturnType<PiRpcConnection['close']> {
this.requestedClose ||= requested
this.dialogs.cancelAll()
return this.connection.close()
}
fail(error: Error): void {
this.failedCause ??= error
this.deps.logger.error('Pi RPC session failed', {
scope: 'pi-rpc',
sessionId: this.input.identity.sessionId,
error
})
void this.close(false)
.then((result) => {
if (result.root !== 'exited') {
this.deps.logger.warn('Pi process exit is unverifiable', {
scope: 'pi-stop',
sessionId: this.input.identity.sessionId
})
}
})
.catch((cause: unknown) =>
this.deps.logger.error('Pi process cleanup failed', {
scope: 'pi-stop',
sessionId: this.input.identity.sessionId,
error: cause
})
)
}
drainObservedExit(): Promise<void> {
return this.exitDelivery
}
retire(): Promise<void> {
return new Promise((resolve) => {
this.releaseAfterExit = () => {
this.releaseAfterExit = undefined
void this.lane.drained().then(() => {
this.lane.dispose()
resolve()
})
}
if (this.publishedExit) {
void this.exitDelivery.then(() => this.releaseAfterExit?.())
}
})
}
private async exit(error: Error): Promise<void> {
if (this.publishedExit) {
return
}
this.publishedExit = true
this.dialogs.cancelAll()
this.turns.end()
this.lane.finalize()
this.lane.apply([
{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: Date.now() } }
])
this.lane.flush()
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
this.lane.drained(),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
this.deps.logger.warn('Pi final timeline did not drain before exit publication', {
scope: 'pi-exit-drain',
sessionId: this.input.identity.sessionId
})
resolve()
}, 2_000)
timer.unref()
})
])
} finally {
clearTimeout(timer)
}
this.deps.onLifecycle({
type: 'ended',
sessionId: this.input.identity.sessionId,
fence: this.input.fence,
acquisitionGeneration: this.generation,
observedAt: Date.now(),
cause: this.requestedClose ? 'requested-close' : 'unexpected-exit',
reason: (this.failedCause ?? error).message
})
}
private settle(clientMessageId: string, outcome: AgentSessionDispatchOutcome): void {
this.deps.onSettled({
sessionId: this.input.identity.sessionId,
clientMessageId,
fence: this.input.fence,
outcome
})
}
}
+245
View File
@@ -0,0 +1,245 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane'
import {
closeProviderTimelineRigs,
openProviderTimelineRig
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { PiRpcTurns } from './rpc-turns'
import { PiRpcPromptDelivery } from './rpc-prompt-delivery'
const state = (overrides: Record<string, unknown> = {}) => ({
sessionFile: '/host/session.jsonl',
isStreaming: false,
isCompacting: false,
pendingMessageCount: 0,
...overrides
})
const pending: (() => void)[] = []
afterEach(async () => {
pending.splice(0).forEach((end) => end())
await closeProviderTimelineRigs()
vi.useRealTimers()
})
async function setup(request = vi.fn(async (_command: string): Promise<unknown> => state())) {
const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' })
const accepted = vi.fn(),
failed = vi.fn(),
idle = vi.fn(),
settled = vi.fn()
const lane = new JsonlRpcTimelineLane({
sink: rig.sink,
sessionId: 'session-timeline',
agent: 'pi',
generation: 'generation-1',
namespace: 'session-timeline',
pauseReading: vi.fn(),
resumeReading: vi.fn(),
onInputAccepted: accepted,
onFailed: failed
})
const send = vi.fn(async (_frame: Record<string, unknown>) => {})
const turns = new PiRpcTurns({
lane,
generation: 'generation-1',
send,
request,
settled,
failed,
idle
})
pending.push(() => {
turns.end()
lane.dispose()
})
const start = async () => {
await turns.submit('send-1', 100, { type: 'prompt', message: 'hello' })
turns.receive({ type: 'agent_start' })
}
const flush = async () => {
await Promise.resolve()
await Promise.resolve()
lane.flush()
}
return { rig, lane, turns, request, accepted, failed, idle, settled, start, flush, send }
}
describe('Pi turn settlement races', () => {
it('chooses steering after asynchronous dispatch admission finishes', async () => {
const h = await setup()
await h.turns.submit('first', 1, { type: 'prompt', message: 'first' })
const gate = Promise.withResolvers<void>()
const second = h.turns.submit(
'second',
2,
{ type: 'prompt', message: 'second', streamingBehavior: 'followUp' },
() => gate.promise
)
h.turns.receive({ type: 'agent_start' })
gate.resolve()
await second
expect(h.send.mock.calls[1]?.[0]).toMatchObject({
message: 'second',
streamingBehavior: 'steer'
})
})
it('waits for agent_settled and a host idle probe after agent_end', async () => {
const h = await setup()
await h.start()
h.turns.receive({ type: 'agent_end' })
await h.flush()
expect(h.request).not.toHaveBeenCalled()
expect(h.idle).not.toHaveBeenCalled()
expect(await h.rig.turns()).toHaveLength(1)
expect((await h.rig.turns())[0]?.state).toBe('running')
h.turns.receive({ type: 'agent_settled' })
await h.flush()
expect(h.request).toHaveBeenCalledWith('get_state')
expect((await h.rig.turns())[0]?.outcome).toBe('success')
expect(h.idle).toHaveBeenCalledTimes(1)
})
it('invalidates an old idle probe when detached compaction starts after settlement', async () => {
let release: ((value: unknown) => void) | undefined
const request = vi.fn(
(_command: string) =>
new Promise<unknown>((resolve) => {
release = resolve
})
)
const h = await setup(request)
await h.start()
h.turns.receive({ type: 'agent_settled' })
await Promise.resolve()
expect(request).toHaveBeenCalledTimes(1)
h.turns.receive({ type: 'auto_compaction_start' })
release?.(state())
await h.flush()
expect(h.idle).not.toHaveBeenCalled()
expect((await h.rig.turns())[0]?.state).toBe('running')
h.turns.receive({ type: 'auto_compaction_end' })
await Promise.resolve()
expect(request.mock.calls.filter(([command]) => command === 'get_state')).toHaveLength(2)
release?.(state())
await h.flush()
expect((await h.rig.turns())[0]?.outcome).toBe('success')
})
it('keeps a busy generation open and rejects a probe made stale by new activity', async () => {
let release: ((value: unknown) => void) | undefined
const request = vi.fn(
(_command: string) =>
new Promise<unknown>((resolve) => {
release = resolve
})
)
const h = await setup(request)
await h.start()
h.turns.receive({ type: 'agent_settled' })
await Promise.resolve()
release?.(state({ isStreaming: true }))
await h.flush()
expect(h.idle).not.toHaveBeenCalled()
h.turns.receive({ type: 'agent_settled' })
await Promise.resolve()
h.turns.receive({ type: 'message_update', message: { role: 'assistant', content: [] } })
release?.(state())
await h.flush()
expect(h.idle).not.toHaveBeenCalled()
h.turns.receive({ type: 'agent_settled' })
await Promise.resolve()
release?.(state())
await h.flush()
expect((await h.rig.turns())[0]?.outcome).toBe('success')
})
it('settles a provider-handled local command without agent events', async () => {
const h = await setup()
await h.turns.submit('local-command', 100, { type: 'prompt', message: '/help' })
h.turns.receive({
type: 'response',
command: 'prompt',
success: true,
data: { disposition: 'handled', agentInvoked: false }
})
await h.flush()
expect(h.accepted).toHaveBeenCalledWith('local-command')
expect((await h.rig.turns())[0]?.outcome).toBe('success')
expect(h.idle).toHaveBeenCalledTimes(1)
})
it('keeps exhausted auto retry as failure, while successful retry clears it', async () => {
for (const succeeded of [false, true]) {
const h = await setup()
await h.start()
h.turns.receive({
type: 'message_end',
message: {
role: 'assistant',
content: [],
stopReason: 'error',
errorMessage: 'provider failed'
}
})
h.turns.receive({ type: 'auto_retry_start' })
h.turns.receive({ type: 'auto_retry_end', success: succeeded })
if (succeeded) {
h.turns.receive({
type: 'message_end',
message: { role: 'assistant', content: [], stopReason: 'stop' }
})
}
h.turns.receive({ type: 'agent_settled' })
await h.flush()
expect((await h.rig.turns())[0]?.outcome).toBe(succeeded ? 'success' : 'failure')
}
})
})
describe('Pi prompt acknowledgement retries', () => {
it('retries only the exact transient auth prefix eight times at 250 ms', async () => {
vi.useFakeTimers()
const send = vi.fn(async () => {})
const settled = vi.fn()
const delivery = new PiRpcPromptDelivery({
send,
settled,
accepted: vi.fn(),
commandOnly: vi.fn(),
rejectedAfterAcceptance: vi.fn(),
failed: vi.fn()
})
await delivery.submit('send-1', 100, { type: 'prompt', message: 'hello' })
for (let attempt = 0; attempt < 8; attempt++) {
delivery.reply({
type: 'response',
command: 'prompt',
success: false,
error: 'No API key found for provider'
})
await vi.advanceTimersByTimeAsync(249)
expect(send).toHaveBeenCalledTimes(attempt + 1)
await vi.advanceTimersByTimeAsync(1)
expect(send).toHaveBeenCalledTimes(attempt + 2)
}
delivery.reply({
type: 'response',
command: 'prompt',
success: false,
error: 'No API key found for provider'
})
await vi.advanceTimersByTimeAsync(250)
expect(send).toHaveBeenCalledTimes(9)
expect(settled).toHaveBeenCalledWith('send-1', expect.objectContaining({ state: 'rejected' }))
await delivery.submit('send-2', 101, { type: 'prompt', message: 'again' })
delivery.reply({
type: 'response',
command: 'prompt',
success: false,
error: 'No API key found elsewhere'
})
await vi.advanceTimersByTimeAsync(250)
expect(send).toHaveBeenCalledTimes(10)
delivery.end()
})
})
+306
View File
@@ -0,0 +1,306 @@
import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type {
AgentSessionDispatchOutcome,
StructuredAgentSessionCommandRun
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import type { JsonlRpcRecord } from '../jsonl-rpc/peer'
import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane'
import { PiRpcContextUsage } from './rpc-context-usage'
import { PiRpcMessages } from './rpc-messages'
import { PiRpcPromptDelivery } from './rpc-prompt-delivery'
import { PiRpcIdleCheck } from './rpc-idle-check'
import { piRpcRetryActivity } from './rpc-retry-activity'
import { piRpcMessageSchema } from './rpc-protocol'
export type PiRpcTurnDeps = {
lane: JsonlRpcTimelineLane
generation: string
send: (frame: JsonlRpcRecord) => Promise<void>
request: (
command: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number | null }
) => Promise<unknown>
settled: (id: string, outcome: AgentSessionDispatchOutcome) => void
idle: () => void
failed: (error: Error) => void
diagnostic?: (error: unknown) => void
}
/** Prompt replies can be idless and arrive after dialogs; they have no elapsed-time deadline. */
export class PiRpcTurns {
readonly context = new PiRpcContextUsage()
private readonly messages: PiRpcMessages
private readonly delivery: PiRpcPromptDelivery
private readonly idleCheck: PiRpcIdleCheck
private revision = 0
private turn = 0
private active?: string
private settling = false
private compacting = false
private stopped = false
private ended = false
private failure?: string
private lastStopReason?: string
constructor(private readonly deps: PiRpcTurnDeps) {
this.messages = new PiRpcMessages(deps.generation, this.context)
this.idleCheck = new PiRpcIdleCheck({
request: () => deps.request('get_state'),
current: (revision) =>
!this.ended && this.settling && !this.compacting && revision === this.revision,
settled: (state) => {
this.deps.lane.apply(this.context.setModel(state.model ?? undefined, Date.now()))
this.finish()
},
failed: deps.failed
})
this.delivery = new PiRpcPromptDelivery({
send: (frame) =>
deps.send({ ...frame, streamingBehavior: this.working ? 'steer' : 'followUp' }),
settled: deps.settled,
failed: deps.failed,
accepted: (id, at) => {
this.open(at)
this.deps.lane.apply([{ type: 'input.accepted', clientMessageId: id, requestedAt: at }])
},
commandOnly: () => {
this.skipInitialUserEcho = false
this.settling = true
this.probe()
},
rejectedAfterAcceptance: (error) => {
this.failure = error
},
beforeWrite: () => this.touch(),
refused: () => {
if (this.active) {
this.settling = true
this.probe()
}
}
})
}
get holdsDispatch(): boolean {
return this.delivery.holdsDispatch
}
get working(): boolean {
return this.active !== undefined
}
async submit(
id: string,
at: number,
frame: JsonlRpcRecord,
before?: () => Promise<void>
): Promise<AgentSessionDispatchOutcome> {
if (this.ended) {
throw new Error('Pi session ended before dispatch')
}
return this.delivery.submit(id, at, frame, before)
}
receive(frame: JsonlRpcRecord): void {
if (this.ended) {
return
}
const at = Date.now()
switch (frame.type) {
case 'response':
if (frame.command === 'prompt') {
this.delivery.reply(frame)
}
return
case 'agent_start':
if (!this.active && !this.delivery.holdsDispatch) {
throw new Error('Pi started an unsolicited agent run')
}
this.touch()
this.open(at)
this.skipInitialUserEcho = this.delivery.consumeNext()
return
case 'message_start':
case 'message_update':
case 'message_end': {
this.touch()
const message = piRpcMessageSchema.safeParse(frame.message)
if (message.success && message.data.role === 'user' && frame.type === 'message_end') {
// agent_start already accepted the first prompt; user echoes consume queued inputs.
if (this.skipInitialUserEcho) {
this.skipInitialUserEcho = false
} else {
this.delivery.consumeNext()
}
}
if (message.success && message.data.role === 'assistant' && frame.type === 'message_end') {
this.lastStopReason = message.data.stopReason
this.failure =
message.data.stopReason === 'error'
? (message.data.errorMessage ?? 'Pi request failed')
: undefined
}
this.deps.lane.apply(this.messages.message(frame, at))
return
}
case 'tool_execution_start':
case 'tool_execution_update':
case 'tool_execution_end':
this.touch()
this.deps.lane.apply(this.messages.tool(frame))
return
case 'turn_end':
this.refreshUsage()
break
case 'agent_end':
// This can precede automatic retry or detached compaction.
break
case 'agent_settled':
this.settling = true
this.probe()
return
case 'compaction_start':
case 'auto_compaction_start':
this.revision++
this.compacting = true
this.deps.lane.apply(this.context.compacting())
return
case 'compaction_end':
case 'auto_compaction_end':
this.compacting = false
this.context.compacted(frame.result)
if (frame.willRetry === true) {
this.failure = undefined
this.lastStopReason = undefined
}
this.refreshUsage()
if (this.settling) {
this.probe()
}
return
case 'auto_retry_start':
this.touch()
this.deps.lane.apply([piRpcRetryActivity(frame)])
return
case 'auto_retry_end':
this.deps.lane.apply([piRpcRetryActivity(frame)])
if (frame.success === true) {
this.failure = undefined
this.lastStopReason = undefined
} else {
this.failure =
typeof frame.finalError === 'string' ? frame.finalError : 'Pi auto-retry failed'
}
break
}
}
stop(): void {
this.stopped = true
}
beginCommand(command: StructuredAgentSessionCommandRun): void {
if (this.active) {
throw new Error('Pi is still working')
}
this.deps.lane.beginCommand(command)
this.active = command.turnId
this.failure = undefined
this.lastStopReason = undefined
this.stopped = false
this.revision++
}
commandCompleted(): void {
this.settling = true
this.probe()
}
commandRejected(): void {
if (this.active) {
this.deps.lane.forgetCommand(this.active)
}
this.active = undefined
this.settling = false
this.revision++
}
end(): void {
this.ended = true
this.revision++
this.idleCheck.dispose()
this.delivery.end()
}
private skipInitialUserEcho = false
private open(at: number): void {
if (this.active) {
return
}
this.active = `run:${this.deps.generation}:${++this.turn}`
this.failure = undefined
this.lastStopReason = undefined
this.stopped = false
this.skipInitialUserEcho = true
this.deps.lane.apply([{ type: 'turn.open', turn: this.active, at }])
}
private probe(): void {
this.idleCheck.schedule(this.revision)
}
private finish(): void {
if (!this.active) {
this.settling = false
return
}
const outcome = this.stopped
? 'cancellation'
: this.failure || this.lastStopReason === 'error'
? 'failure'
: 'success'
const events: ProviderTimelineEvent[] = []
if (outcome === 'failure' && this.failure) {
const fact = agentSessionFailureFact('providerRejected', {
detail: providerDiagnostic(this.failure, 'person')
})
events.push({
type: 'item.close',
item: `error:${this.active}`,
body: {
kind: 'status',
tone: 'error',
...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'row' })
}
})
}
events.push({
type: 'turn.end',
at: Date.now(),
state: this.stopped ? 'interrupted' : 'completed',
outcome
})
this.deps.lane.apply(events)
this.active = undefined
this.settling = false
this.messages.reset()
this.deps.idle()
}
private refreshUsage(): void {
void this.deps
.request('get_session_stats')
.then((value) => {
if (!this.ended) {
this.deps.lane.apply(this.context.stats(value, Date.now()))
}
})
.catch((error: unknown) => this.deps.diagnostic?.(error))
}
private touch(): void {
this.revision++
this.settling = false
}
}
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { ProviderAcquisitionStarts } from './provider-acquisition-starts'
import type { ProviderProcessCloseResult } from './provider-process-close'
function connection(result: boolean | ProviderProcessCloseResult = false) {
const exits: (() => void)[] = []
return {
close: vi.fn(async (): Promise<boolean | ProviderProcessCloseResult> => result),
onExit: (listener: () => void) => {
exits.push(listener)
},
exit: () => {
for (const listener of exits.splice(0)) {
listener()
}
}
}
}
describe('provider acquisition ownership', () => {
it('stops a connection created after the host already aborted its start', async () => {
const starts = new ProviderAcquisitionStarts<ReturnType<typeof connection>>()
const controller = new AbortController()
const attempt = starts.begin(controller.signal)
controller.abort()
const child = connection(true)
starts.track(attempt, child)
expect(child.close).toHaveBeenCalledOnce()
starts.end(attempt)
})
it('detaches startup cancellation when ownership transfers to the session', () => {
const starts = new ProviderAcquisitionStarts<ReturnType<typeof connection>>()
const controller = new AbortController()
const attempt = starts.begin(controller.signal)
const child = connection(true)
starts.track(attempt, child)
starts.end(attempt)
controller.abort()
expect(child.close).not.toHaveBeenCalled()
})
it('retains a failed start after refused cleanup and retries that same child', async () => {
const starts = new ProviderAcquisitionStarts<ReturnType<typeof connection>>()
const child = connection()
child.close
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error('unreachable'))
.mockResolvedValueOnce(true)
starts.retainFailed('session', child)
expect(await starts.stopFailed('session')).toBe(false)
expect(await starts.stopFailed('session')).toBe(false)
expect(starts.failedSessionIds()).toEqual(['session'])
expect(await starts.stopFailed('session')).toBe(true)
expect(starts.failedSessionIds()).toEqual([])
expect(child.close).toHaveBeenCalledTimes(3)
})
it('releases ownership on root proof while descendant cleanup remains reported by the connection', async () => {
const starts = new ProviderAcquisitionStarts<ReturnType<typeof connection>>()
const child = connection({ root: 'unverifiable', tree: 'unverifiable' })
child.close
.mockResolvedValueOnce({ root: 'unverifiable', tree: 'exited' })
.mockResolvedValueOnce({ root: 'exited', tree: 'unverifiable' })
starts.retainFailed('session', child)
expect(await starts.stopFailed('session')).toBe(false)
expect(await starts.stopFailed('session')).toBe(true)
expect(starts.failedSessionIds()).toEqual([])
})
it('does not let a previous child exit erase a newer failed owner', () => {
const starts = new ProviderAcquisitionStarts<ReturnType<typeof connection>>()
const old = connection(),
current = connection()
starts.retainFailed('session', old)
starts.retainFailed('session', current)
old.exit()
expect(starts.failedSessionIds()).toEqual(['session'])
current.exit()
expect(starts.failedSessionIds()).toEqual([])
})
})
@@ -3,22 +3,27 @@
// process's exit is proven, so the next start or quit retries that same connection's close instead
// of answering for a process it no longer knows (and never spawns a second one meanwhile).
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { ProviderProcessCloseResult } from './provider-process-close'
export type AcpStartAttempt = {
export type ProviderAcquisitionConnection = {
close(): Promise<boolean | ProviderProcessCloseResult>
onExit(listener: () => void): void
}
export type ProviderStartAttempt<Connection extends ProviderAcquisitionConnection> = {
/** The host's: the start's one canceller. */
readonly signal: AbortSignal
connection: AcpStructuredConnection | null
connection: Connection | null
/** What the signal's abort does while the start runs; detached once it ends. */
readonly stopConnection: () => void
}
export class AcpStructuredStarts {
private readonly failed = new Map<string, AcpStructuredConnection>()
export class ProviderAcquisitionStarts<Connection extends ProviderAcquisitionConnection> {
private readonly failed = new Map<string, Connection>()
/** Registered before anything awaits, so an abort from here on stops this start. */
begin(signal: AbortSignal | undefined): AcpStartAttempt {
const attempt: AcpStartAttempt = {
begin(signal: AbortSignal | undefined): ProviderStartAttempt<Connection> {
const attempt: ProviderStartAttempt<Connection> = {
signal: signal ?? new AbortController().signal,
connection: null,
stopConnection: () => void attempt.connection?.close().catch(() => false)
@@ -28,7 +33,7 @@ export class AcpStructuredStarts {
}
/** The start has its connection; one already aborted goes as soon as it exists. */
track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void {
track(attempt: ProviderStartAttempt<Connection>, connection: Connection): void {
attempt.connection = connection
if (attempt.signal.aborted) {
void connection.close().catch(() => false)
@@ -37,12 +42,12 @@ export class AcpStructuredStarts {
/** A connection the start handed over is the session's: an abort after this goes through its
* stop, which knows the close was asked for, not this listener, which would read as a crash. */
end(attempt: AcpStartAttempt): void {
end(attempt: ProviderStartAttempt<Connection>): void {
attempt.signal.removeEventListener('abort', attempt.stopConnection)
}
/** A failed start whose process is not proven gone keeps its connection until its exit is. */
retainFailed(sessionId: string, connection: AcpStructuredConnection): void {
retainFailed(sessionId: string, connection: Connection): void {
this.failed.set(sessionId, connection)
connection.onExit(() => {
if (this.failed.get(sessionId) === connection) {
@@ -57,7 +62,8 @@ export class AcpStructuredStarts {
if (!connection) {
return true
}
const proven = await connection.close().catch(() => false)
const result = await connection.close().catch(() => false)
const proven = typeof result === 'boolean' ? result : result.root === 'exited'
if (proven && this.failed.get(sessionId) === connection) {
this.failed.delete(sessionId)
}
@@ -0,0 +1,16 @@
import type { Readable, Writable } from 'node:stream'
function ignoreLateError(): void {}
export function detachProviderStreamErrorHandler(
stream: Readable | Writable,
handler: (error: Error) => void
): void {
stream.removeListener('error', handler)
if (stream.closed) {
return
}
// Node may emit the write error after its callback has already closed the peer.
stream.on('error', ignoreLateError)
stream.once('close', () => stream.removeListener('error', ignoreLateError))
}
@@ -0,0 +1,39 @@
import { Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { ProviderStdioWriteQueue } from './provider-stdio-write-queue'
// An agent that stopped reading its stdin: the first line is handed over and never completes.
function stalledQueue() {
const output = new Writable({ write() {} })
const onFailure = vi.fn()
const queue = new ProviderStdioWriteQueue(output, 1024, onFailure, {
capacity: () => new Error('capacity'),
closed: () => new Error('closed')
})
return { queue, onFailure }
}
describe('ProviderStdioWriteQueue aborts', () => {
it('drops a line still waiting in the queue and rejects only its request', async () => {
const { queue, onFailure } = stalledQueue()
void queue.write('active\n')
const waiting = new AbortController()
const dropped = queue.write('waiting\n', waiting.signal)
waiting.abort(new Error('timed out'))
await expect(dropped).rejects.toThrow('timed out')
// Nothing of it reached the agent, so the connection stays up for the next request.
expect(onFailure).not.toHaveBeenCalled()
void queue.write('next\n')
expect(onFailure).not.toHaveBeenCalled()
})
it('closes the connection when the line being written times out, since it cannot be taken back', () => {
const { queue, onFailure } = stalledQueue()
const active = new AbortController()
void queue.write('active\n', active.signal)
active.abort(new Error('timed out'))
// The stream owns the line, possibly partly on the pipe; leaving it would hold every later
// write, a Stop included, behind a reader that stopped.
expect(onFailure).toHaveBeenCalledWith(new Error('timed out'))
})
})
@@ -0,0 +1,127 @@
import type { Writable } from 'node:stream'
type Write = {
line: string
resolve: () => void
reject: (error: Error) => void
detachAbort?: () => void
}
export class ProviderStdioWriteQueue {
private readonly queue: Write[] = []
private bytes = 0
private active?: Write
private terminalError?: Error
private detachDrain?: () => void
constructor(
private readonly output: Writable,
private readonly maxBytes: number,
private readonly onFailure: (error: Error) => void,
private readonly errors: { capacity: () => Error; closed: () => Error }
) {}
write(line: string, signal?: AbortSignal): Promise<void> {
if (this.terminalError) {
return Promise.reject(this.terminalError)
}
if (signal?.aborted) {
return Promise.reject(signal.reason)
}
const bytes = Buffer.byteLength(line)
if (this.bytes + bytes > this.maxBytes) {
return Promise.reject(this.errors.capacity())
}
this.bytes += bytes
return new Promise((resolve, reject) => {
const write: Write = { line, resolve, reject }
const abort = (): void => {
// Why: a line already handed to the stream can't be taken back, and later writes wait on it.
if (this.active === write) {
this.onFailure(
signal?.reason instanceof Error ? signal.reason : new Error('Agent write aborted')
)
return
}
const index = this.queue.indexOf(write)
if (index === -1) {
return
}
this.queue.splice(index, 1)
this.bytes -= bytes
write.detachAbort?.()
reject(signal?.reason)
}
signal?.addEventListener('abort', abort, { once: true })
write.detachAbort = () => signal?.removeEventListener('abort', abort)
this.queue.push(write)
this.flush()
})
}
close(error: Error): void {
if (this.terminalError) {
return
}
this.terminalError = error
this.detachDrain?.()
this.active?.detachAbort?.()
this.active?.reject(error)
this.active = undefined
for (const write of this.queue.splice(0)) {
write.detachAbort?.()
write.reject(error)
}
this.bytes = 0
}
private flush(): void {
if (this.active || this.terminalError) {
return
}
const write = this.queue.shift()
if (!write) {
return
}
this.active = write
if (this.output.destroyed || !this.output.writable) {
this.onFailure(this.errors.closed())
return
}
let completed = false
let drained = false
let returned = false
const finish = (): void => {
if (!returned || !completed || !drained || this.terminalError) {
return
}
this.detachDrain?.()
write.detachAbort?.()
this.active = undefined
this.bytes -= Buffer.byteLength(write.line)
write.resolve()
this.flush()
}
const onDrain = (): void => {
drained = true
finish()
}
this.output.once('drain', onDrain)
this.detachDrain = () => this.output.removeListener('drain', onDrain)
try {
const accepted = this.output.write(write.line, (error) => {
if (error) {
this.onFailure(error)
return
}
completed = true
finish()
})
drained ||= accepted
returned = true
finish()
} catch (error) {
this.onFailure(error instanceof Error ? error : new Error(String(error)))
}
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
@@ -70,6 +71,24 @@ describe('a tab of an agent beyond Claude and Codex', () => {
expect(projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)).toBe(payload)
})
it('holds a Pi tab for clients that understand registered agents but cannot show Pi dialogs', () => {
const payload = {
...snapshot(),
tabs: [...snapshot().tabs, chatTab('pi', false)]
}
const old = projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)
expect(old.tabs.map((tab) => tab.id)).not.toContain('agent-session:pi-session')
expect(
projectSessionTabAgentStatus(payload, 'runtime', [
...REGISTERED_AGENTS_CLIENT,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY
])
).toBe(payload)
expect(
projectSessionTabAgentStatus(payload, 'mobile', REGISTERED_AGENTS_CLIENT).tabs.at(-1)
).toMatchObject({ title: STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE })
})
it('stays listed on a phone under the title that names the fix', () => {
const projected = projectSessionTabAgentStatus(snapshot(), 'mobile', TODAYS_CLIENT)
expect(projected.tabs.map((tab) => tab.title)).toEqual([
@@ -10,6 +10,7 @@ import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-r
import { defineMethod } from '../core'
import { requireStructuredCapability } from './structured-agent-session-gate'
import { AgentsParams } from './structured-agent-session-schemas'
import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy'
export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [
defineMethod({
@@ -20,7 +21,9 @@ export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [
handler: async (_params, ctx): Promise<AgentSessionAgentsResult> => {
requireStructuredCapability(ctx)
return {
agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => ({
agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter(({ definition }) =>
clientReadsStructuredSessionAgent(ctx, definition.agent)
).map(({ definition }) => ({
agent: definition.agent,
capabilities: { ...definition.capabilities }
}))
@@ -16,7 +16,9 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [
params: OptionsParams,
handler: async (params, ctx) =>
readAgentSessionConversationOutline(
await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId)
await (
await requireInstalledStructuredHost(ctx, params.sessionId)
).journalSnapshot(params.sessionId)
)
})
]
@@ -16,6 +16,7 @@ import type { RpcContext } from '../core'
import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal'
import {
createSupportFollowsHostSetting,
clientReadsStructuredSessionAgent,
isStructuredNativeChatEnabled,
supportsStructuredAgentSessions
} from './structured-agent-session-policy'
@@ -36,12 +37,21 @@ export function requireStructuredCapability(ctx: RpcContext): void {
}
}
export function requireStructuredAgentAudience(ctx: RpcContext, agent: string): void {
requireStructuredCapability(ctx)
if (!clientReadsStructuredSessionAgent(ctx, agent)) {
throw agentSessionRefusalError('structured_agent_session_unsupported', {
reason: 'clientCapabilityMissing'
})
}
}
/**
* `agentSession.createSupport` alone also reads the host setting, for a client that leaves the
* launch mode to the host; it gets the refusal it got before, which it reads as "open a terminal".
*/
export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void {
requireStructuredCapability(ctx)
export function requireStructuredCreateSupportAdmission(ctx: RpcContext, agent?: string): void {
requireStructuredAgentAudience(ctx, agent ?? '')
if (createSupportFollowsHostSetting(ctx) && !isStructuredNativeChatEnabled(ctx.runtime)) {
throw agentSessionRefusalError('structured_agent_session_unsupported', {
reason: 'clientCapabilityMissing'
@@ -49,11 +59,20 @@ export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void {
}
}
export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost {
requireStructuredCapability(ctx)
export function requireStructuredHost(ctx: RpcContext, agent?: string): StructuredAgentSessionHost {
requireStructuredAgentAudience(ctx, agent ?? '')
return requireHostOrRefusal()
}
export function requireStructuredSessionHost(
ctx: RpcContext,
sessionId: string
): StructuredAgentSessionHost {
const host = requireStructuredHost(ctx)
requireStructuredAgentAudience(ctx, host.sessionAgent(sessionId) ?? '')
return host
}
/**
* The gate for methods that stop or retire work the caller already owns: close, cancel,
* unsubscribe and release. It asks only what no caller can do without (the wire capability and a
@@ -84,7 +103,13 @@ function requireHostOrRefusal(): StructuredAgentSessionHost {
/** Builds the host for a call that may be the first this process sees. Every session is addressed
* by its durable record — a read opens a conversation at rest — so each call that reaches for one
* may meet a host nothing has built yet. */
export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise<void> {
export async function ensureStructuredHostInstalled(
ctx: RpcContext,
agent?: string
): Promise<void> {
if (agent) {
requireStructuredAgentAudience(ctx, agent)
}
// Gated first: a client that cannot read structured sessions must not be able
// to make the host exist, which is an observable side effect of the surface.
if (!supportsStructuredSessions(ctx)) {
@@ -98,10 +123,11 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise<vo
/** The host for a read, built first when this process has none: the read RPCs share this one. */
export async function requireInstalledStructuredHost(
ctx: RpcContext
ctx: RpcContext,
sessionId?: string
): Promise<StructuredAgentSessionHost> {
await ensureStructuredHostInstalled(ctx)
return requireStructuredHost(ctx)
return sessionId ? requireStructuredSessionHost(ctx, sessionId) : requireStructuredHost(ctx)
}
/** Mirrors the existing agent-session host-authority derivation so one client
@@ -16,8 +16,8 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [
defineMethod({
name: 'agentSession.hold',
params: HoldParams,
handler: async (_params, ctx) => {
await requireInstalledStructuredHost(ctx)
handler: async (params, ctx) => {
await requireInstalledStructuredHost(ctx, params.sessionId)
return { held: true as const }
}
}),
@@ -11,17 +11,34 @@
import { defineMethod } from '../core'
import {
requireInstalledStructuredHost,
requireStructuredAgentAudience,
requireStructuredHost
} from './structured-agent-session-gate'
import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas'
import {
HandoffStatusParams,
ModelCatalogParams,
OptionsParams
} from './structured-agent-session-schemas'
import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory'
export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
defineMethod({
name: 'agentSession.handoffStatus',
params: HandoffStatusParams,
handler: async (params, ctx) =>
(await requireInstalledStructuredHost(ctx, params.sessionId)).handoffStatus(params.sessionId)
}),
defineMethod({
name: 'agentSession.commands',
params: OptionsParams,
handler: async (params, ctx) =>
(await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId)
}),
defineMethod({
name: 'agentSession.options',
params: OptionsParams,
handler: async (params, ctx) =>
(await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId)
(await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId)
}),
defineMethod({
name: 'agentSession.modelCatalog',
@@ -29,10 +46,11 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
// A structured chat's read names its session and builds the host, since it may come first;
// terminal-backed chat's session-less read must not open the journal where none runs.
handler: async ({ worktree, ...params }, ctx) => {
requireStructuredAgentAudience(ctx, params.agent)
const host =
params.sessionId === undefined
? requireStructuredHost(ctx)
: await requireInstalledStructuredHost(ctx)
: await requireInstalledStructuredHost(ctx, params.sessionId)
const catalog = host.deps.modelCatalog
if (!catalog) {
return { origin: 'unknown' as const }
@@ -0,0 +1,386 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations'
import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents'
import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities'
import type {
AgentSessionStatusSummary,
AgentSessionTurnCompletion
} from '../../../../shared/agent-session-wire'
import type { AgentSessionPromptAttention } from '../../../../shared/agent-session-turn-completion-wire'
import type { StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { StructuredAgentSessionTurnCompletionSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-turn-completion-feed'
import {
call,
clearStructuredHostStub,
dispatcher,
envelope,
hostCalls,
installStructuredHostStub,
runtimeCalls,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
import {
CLEANUP_METHODS,
WORK_METHODS
} from './structured-agent-session-gate-classification.test-fixture'
const OLD_CLIENT = {
...STRUCTURED_CLIENT,
clientCapabilities: [
...STRUCTURED_CLIENT.clientCapabilities,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY
]
}
const PI_CLIENT = {
...OLD_CLIENT,
clientCapabilities: [...OLD_CLIENT.clientCapabilities, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY]
}
const MOBILE_CLIENT = {
clientKind: 'mobile' as const,
// Mirrors the mobile transport list without importing its Expo project into this Node suite.
clientCapabilities: remoteRuntimeClientCapabilities([
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
AGENT_LAUNCH_RUNTIME_CAPABILITY
])
}
const DESKTOP_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES]
}
const UNSUPPORTED = { message: expect.stringContaining('structured_agent_session_unsupported') }
beforeEach(installStructuredHostStub)
afterEach(clearStructuredHostStub)
describe('Pi dialog-shape client capability', () => {
it('filters only Pi from the registered agent list of an older client', async () => {
const registered = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(
({ definition }) => definition.agent
)
const listing = (agents: string[]) => ({
ok: true,
result: { agents: agents.map((agent) => ({ agent })) }
})
expect(registered).toContain('pi')
expect(
await call('agentSession.agents', {}, OLD_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS)
).toMatchObject(listing(registered.filter((agent) => agent !== 'pi')))
expect(
await call('agentSession.agents', {}, PI_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS)
).toMatchObject(listing(registered))
})
it('refuses Pi create support before asking the runtime, while other agents keep their path', async () => {
expect(
await call(
'agentSession.createSupport',
{ worktree: 'id:workspace-1', agent: 'pi' },
OLD_CLIENT
)
).toMatchObject({ ok: false, error: UNSUPPORTED })
expect(runtimeCalls.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
expect(
await call(
'agentSession.createSupport',
{ worktree: 'id:workspace-1', agent: 'grok' },
OLD_CLIENT
)
).toMatchObject({ ok: true })
expect(
await call(
'agentSession.createSupport',
{ worktree: 'id:workspace-1', agent: 'pi' },
PI_CLIENT
)
).toMatchObject({ ok: true })
})
it('refuses Pi create before resolution or attachment', async () => {
const params = {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree: 'id:workspace-1', agent: 'pi' }
})
}),
worktree: 'id:workspace-1',
agent: 'pi'
}
expect(await call('agentSession.create', params, OLD_CLIENT)).toMatchObject({
ok: false,
error: UNSUPPORTED
})
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled()
expect(hostCalls.attach).not.toHaveBeenCalled()
expect(await call('agentSession.create', params, PI_CLIENT)).toMatchObject({ ok: true })
})
it('withholds Pi journal history from a client that cannot show its dialogs', async () => {
hostCalls.sessionAgent.mockReturnValue('pi')
expect(
await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, OLD_CLIENT)
).toMatchObject({ ok: false, error: UNSUPPORTED })
expect(hostCalls.history).not.toHaveBeenCalled()
expect(
await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, PI_CLIENT)
).toMatchObject({ ok: true })
})
it.each([
['old D3 desktop', OLD_CLIENT],
['current mobile', MOBILE_CLIENT]
] as const)(
'refuses Pi session reads and mutations by ID for %s before host work',
async (_label, client) => {
hostCalls.sessionAgent.mockReturnValue('pi')
for (const { method, params } of WORK_METHODS) {
if (
method === 'agentSession.createSupport' ||
method === 'agentSession.create' ||
method === 'agentSession.ensure' ||
method === 'agentSession.subscribeStatus' ||
method === 'agentSession.reveal'
) {
continue
}
expect(await call(method, params, client), method).toMatchObject({
ok: false,
error: UNSUPPORTED
})
}
expect(hostCalls.send).not.toHaveBeenCalled()
expect(hostCalls.readOptions).not.toHaveBeenCalled()
expect(hostCalls.rewind).not.toHaveBeenCalled()
expect(hostCalls.respondToPrompt).not.toHaveBeenCalled()
expect(hostCalls.setOption).not.toHaveBeenCalled()
expect(hostCalls.history).not.toHaveBeenCalled()
expect(hostCalls.revealSession).not.toHaveBeenCalled()
expect(await call('agentSession.modelCatalog', { agent: 'pi' }, client)).toMatchObject({
ok: false,
error: UNSUPPORTED
})
hostCalls.sessionAgent.mockReturnValue('codex')
expect(
await call('agentSession.modelCatalog', { agent: 'pi', sessionId: SESSION }, client)
).toMatchObject({ ok: false, error: UNSUPPORTED })
hostCalls.sessionAgent.mockReturnValue('pi')
expect(await call('agentSession.reveal', { sessionId: SESSION }, client)).toMatchObject({
ok: true,
result: { ok: false, refusal: { code: 'structured_agent_session_unsupported' } }
})
for (const [method, params] of [
['agentSession.commands', { sessionId: SESSION }],
['agentSession.conversationCommand', { envelope: envelope(), command: 'compact' }],
['agentSession.modelCatalog', { agent: 'codex', sessionId: SESSION }]
] as const) {
expect(await call(method, params, client), method).toMatchObject({
ok: false,
error: UNSUPPORTED
})
}
expect(hostCalls.revealSession).not.toHaveBeenCalled()
}
)
it.each([
['old D3 desktop', OLD_CLIENT],
['current mobile', MOBILE_CLIENT]
] as const)('keeps Pi cleanup callable for %s', async (_label, client) => {
hostCalls.sessionAgent.mockReturnValue('pi')
for (const { method, params } of CLEANUP_METHODS) {
expect(await call(method, params, client), method).toMatchObject({ ok: true })
}
expect(hostCalls.cancel).toHaveBeenCalledOnce()
expect(hostCalls.close).toHaveBeenCalledOnce()
})
it('admits current desktop Pi send, options and commands, and preserves other agents', async () => {
hostCalls.sessionAgent.mockReturnValue('pi')
hostCalls.readCommands = vi.fn(async () => [])
for (const method of [
'agentSession.send',
'agentSession.options',
'agentSession.commands'
] as const) {
const params =
method === 'agentSession.send'
? WORK_METHODS.find((entry) => entry.method === method)?.params
: { sessionId: SESSION }
expect(await call(method, params, DESKTOP_CLIENT), method).toMatchObject({ ok: true })
}
expect(hostCalls.send).toHaveBeenCalledOnce()
expect(hostCalls.readOptions).toHaveBeenCalledOnce()
expect(hostCalls.readCommands).toHaveBeenCalledOnce()
hostCalls.sessionAgent.mockReturnValue('codex')
expect(
await call(
'agentSession.send',
WORK_METHODS.find((entry) => entry.method === 'agentSession.send')?.params,
OLD_CLIENT
)
).toMatchObject({ ok: true })
expect(await call('agentSession.options', { sessionId: SESSION }, MOBILE_CLIENT)).toMatchObject(
{ ok: true }
)
})
it('filters Pi status from the opening snapshot and live updates', async () => {
const codex: AgentSessionStatusSummary = {
sessionId: 'codex-session',
workspaceId: 'workspace-1',
agent: 'codex',
status: 'idle',
latestPrompt: 'hello',
updatedAt: 1
}
const pi: AgentSessionStatusSummary = { ...codex, sessionId: SESSION, agent: 'pi' }
hostCalls.subscribeStatus.mockImplementation(
(subscriber: StructuredAgentSessionStatusSubscriber) => {
subscriber.emit({ type: 'snapshot', sessions: [codex, pi] })
subscriber.emit({ type: 'status', session: pi })
subscriber.emit({ type: 'status', session: codex })
subscriber.emit({ type: 'end' })
return () => undefined
}
)
for (const [client, seesPi] of [
[OLD_CLIENT, false],
[MOBILE_CLIENT, false],
[DESKTOP_CLIENT, true]
] as const) {
const replies: unknown[] = []
await dispatcher().dispatchStreaming(
{
id: 'status-test',
authToken: 'token',
method: 'agentSession.subscribeStatus',
params: null
},
(raw) => replies.push(JSON.parse(raw)),
client
)
const serialized = JSON.stringify(replies)
expect(serialized).toContain('codex-session')
expect(serialized.includes(SESSION)).toBe(seesPi)
expect(replies).toHaveLength(seesPi ? 4 : 3)
}
})
it('filters Pi turn completions from the global live stream', async () => {
hostCalls.sessionAgent.mockImplementation((sessionId: string) =>
sessionId === SESSION ? 'pi' : 'codex'
)
hostCalls.subscribeTurnCompletions = vi.fn(
(subscriber: StructuredAgentSessionTurnCompletionSubscriber) => {
const completion: AgentSessionTurnCompletion = {
scope: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree' as const
},
sessionId: SESSION,
turnId: 'turn-1',
outcome: 'success' as const,
completedAt: 1
}
subscriber.emit({ type: 'completion', completion })
subscriber.emit({
type: 'completion',
completion: { ...completion, sessionId: 'codex-session' }
})
subscriber.emit({ type: 'end' })
return () => undefined
}
)
for (const [client, seesPi] of [
[OLD_CLIENT, false],
[DESKTOP_CLIENT, true]
] as const) {
const replies: unknown[] = []
await dispatcher().dispatchStreaming(
{
id: 'completion-test',
authToken: 'token',
method: 'agentSession.subscribeTurnCompletions',
params: null
},
(raw) => replies.push(JSON.parse(raw)),
client
)
const serialized = JSON.stringify(replies)
expect(serialized).toContain('codex-session')
expect(serialized.includes(SESSION)).toBe(seesPi)
expect(replies).toHaveLength(seesPi ? 3 : 2)
}
})
it('filters Pi prompt alerts from a client that asked for prompts', async () => {
hostCalls.sessionAgent.mockImplementation((sessionId: string) =>
sessionId === SESSION ? 'pi' : 'codex'
)
hostCalls.subscribeTurnCompletions = vi.fn(
(subscriber: StructuredAgentSessionTurnCompletionSubscriber) => {
const prompt: AgentSessionPromptAttention = {
scope: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree' as const
},
sessionId: SESSION,
promptId: 'prompt-1',
raisedAt: 1
}
subscriber.emit({ type: 'prompt', prompt })
subscriber.emit({ type: 'prompt', prompt: { ...prompt, sessionId: 'codex-session' } })
subscriber.emit({ type: 'end' })
return () => undefined
}
)
for (const [client, seesPi] of [
[OLD_CLIENT, false],
[DESKTOP_CLIENT, true]
] as const) {
const replies: unknown[] = []
await dispatcher().dispatchStreaming(
{
id: 'prompt-test',
authToken: 'token',
method: 'agentSession.subscribeTurnCompletions',
params: { includePrompts: true }
},
(raw) => replies.push(JSON.parse(raw)),
client
)
const serialized = JSON.stringify(replies)
expect(serialized).toContain('codex-session')
expect(serialized.includes(SESSION)).toBe(seesPi)
expect(replies).toHaveLength(seesPi ? 3 : 2)
}
})
})
@@ -1,15 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import {
createSupportFollowsHostSetting,
structuredAgentsReadBy,
clientRendersStructuredAgent,
supportsStructuredAgentSessions
} from './structured-agent-session-policy'
const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
describe('Pi audience in a registered-agents client', () => {
const registered = [...CAPABLE, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY]
it('keeps Pi out of tab and restart audiences until the dialog shape is advertised', () => {
expect(clientRendersStructuredAgent(registered, 'pi')).toBe(false)
expect(clientRendersStructuredAgent(registered, 'grok')).toBe(true)
const audience = structuredAgentsReadBy(
{ clientKind: 'runtime', clientCapabilities: registered },
['codex', 'grok', 'pi']
)
expect(audience?.('pi')).toBe(false)
expect(audience?.('grok')).toBe(true)
expect(
structuredAgentsReadBy(
{
clientKind: 'runtime',
clientCapabilities: [...registered, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY]
},
['codex', 'grok', 'pi']
)
).toBeUndefined()
})
})
describe('supportsStructuredAgentSessions', () => {
it.each(['runtime', 'mobile'] as const)(
'admits a %s client that advertises the capability',
@@ -1,5 +1,6 @@
import {
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
@@ -24,9 +25,7 @@ export function supportsStructuredAgentSessions(
)
}
/** Whether a remote client renders `agent`'s chat: the one rule for every surface that withholds
* an agent's rows (tabs, restart offers). Codex needs structured support; Claude also its own
* capability; any other agent a client that renders the host's registered agents. */
/** One audience rule for tabs and restart offers. Pi also needs dialog-shape support. */
export function clientRendersStructuredAgent(
clientCapabilities: readonly RuntimeCapability[] | undefined,
agent: string
@@ -37,6 +36,9 @@ export function clientRendersStructuredAgent(
if (agent === 'codex') {
return true
}
if (agent === 'pi' && !clientCapabilities.includes(PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY)) {
return false
}
return clientCapabilities.includes(
agent === 'claude'
? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
@@ -44,6 +46,18 @@ export function clientRendersStructuredAgent(
)
}
/** Preserve the existing session audience for every agent except Pi. */
export function clientReadsStructuredSessionAgent(
context: Pick<RpcContext, 'clientCapabilities' | 'clientKind'>,
agent: string
): boolean {
return (
agent !== 'pi' ||
context.clientKind === undefined ||
clientRendersStructuredAgent(context.clientCapabilities, agent)
)
}
/** The agents this client reads rows of, among those registered or saved here; undefined when it
* reads every one, so an action for it is exactly the unscoped one (one fence for every offer). */
export function structuredAgentsReadBy(
@@ -4,7 +4,7 @@
import { defineMethod } from '../core'
import {
requireStructuredHost as requireHost,
requireStructuredSessionHost as requireSessionHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import {
@@ -16,16 +16,22 @@ export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [
defineMethod({
name: 'agentSession.queuedMessageSend',
params: QueuedMessageActionParams,
handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params)
handler: async (params, ctx) =>
requireSessionHost(ctx, params.envelope.sessionId).queuedMessageSend(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.queuedMessageDelete',
params: QueuedMessageActionParams,
handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params)
handler: async (params, ctx) =>
requireSessionHost(ctx, params.envelope.sessionId).queuedMessageDelete(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.queuedMessagesResume',
params: QueuedMessagesResumeParams,
handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params)
handler: async (params, ctx) =>
requireSessionHost(ctx, params.envelope.sessionId).queuedMessagesResume(
callerFor(ctx),
params
)
})
]
@@ -20,6 +20,7 @@ import { defineMethod } from '../core'
import {
ensureStructuredHostInstalled,
requireStructuredCapability,
requireStructuredAgentAudience,
requireStructuredHost
} from './structured-agent-session-gate'
import { OptionsParams } from './structured-agent-session-schemas'
@@ -33,7 +34,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [
await ensureStructuredHostInstalled(ctx)
let revealed: StructuredAgentSessionReveal
try {
revealed = await requireStructuredHost(ctx).revealSession(params.sessionId)
const host = requireStructuredHost(ctx)
requireStructuredAgentAudience(ctx, host.sessionAgent(params.sessionId) ?? '')
revealed = await host.revealSession(params.sessionId)
} catch (error) {
// The host raises its refusal as the code itself; anything else is a genuine fault and
// must not be laundered into a tidy "no such chat".
@@ -6,7 +6,7 @@ import { agentSessionSendSubmission } from '../../../../shared/agent-session-wir
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement'
import type { RpcContext } from '../core'
import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate'
import { requireStructuredSessionHost, structuredCallerFor } from './structured-agent-session-gate'
/**
* A send answers once the host accepts it. A client that predates that answer cannot show a
@@ -19,7 +19,7 @@ export async function sendStructuredAgentSessionForClient(
params: Parameters<StructuredAgentSessionHost['send']>[1],
context: RpcContext
) {
const host = requireStructuredHost(context)
const host = requireStructuredSessionHost(context, params.envelope.sessionId)
// A client's own send: a restart or a close keeps it as a card if it never reached the agent.
const result = await host.send(structuredCallerFor(context), { ...params, userSend: true })
const capabilities = context.clientCapabilities ?? []
@@ -6,6 +6,7 @@
import { defineStreamingMethod, type RpcContext } from '../core'
import { requireStructuredHost as requireHost } from './structured-agent-session-gate'
import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id'
import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy'
/** Ties a stream to both ends that can close it — the runtime's subscription registry and the
* transport abort — so either one runs `onClose` exactly once. */
@@ -53,7 +54,24 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [
if (stream.isClosed()) {
return
}
dispose = host.subscribeStatus({ id: subscriptionId, emit })
dispose = host.subscribeStatus({
id: subscriptionId,
emit: (event) => {
if (event.type === 'snapshot') {
emit({
...event,
sessions: event.sessions.filter((session) =>
clientReadsStructuredSessionAgent(ctx, session.agent)
)
})
} else if (
event.type !== 'status' ||
clientReadsStructuredSessionAgent(ctx, event.session.agent)
) {
emit(event)
}
}
})
if (stream.isClosed()) {
dispose()
}
@@ -5,7 +5,7 @@
import { defineMethod } from '../core'
import {
requireStructuredHost as requireHost,
requireStructuredSessionHost as requireSessionHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import { ThreadGoalParams } from './structured-agent-session-schemas'
@@ -14,6 +14,7 @@ export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [
defineMethod({
name: 'agentSession.threadGoal',
params: ThreadGoalParams,
handler: async (params, ctx) => requireHost(ctx).changeThreadGoal(callerFor(ctx), params)
handler: async (params, ctx) =>
requireSessionHost(ctx, params.envelope.sessionId).changeThreadGoal(callerFor(ctx), params)
})
]
@@ -17,6 +17,7 @@ import {
} from './structured-agent-session-schemas'
import { structuredAgentSessionTurnCompletionSubscriptionId } from './structured-agent-session-subscription-id'
import { bindStructuredAgentSessionStream } from './structured-agent-session-status-stream'
import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy'
export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [
// Retire only deliveries whose journal cause the client's accepted read covered: the session and
@@ -46,7 +47,21 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [
}
dispose = host.subscribeTurnCompletions({
id: subscriptionId,
emit,
// A client that cannot read an agent's rows is not told that agent's edges either.
emit: (event) => {
const sessionId =
event.type === 'completion'
? event.completion.sessionId
: event.type === 'prompt'
? event.prompt.sessionId
: undefined
if (
sessionId === undefined ||
clientReadsStructuredSessionAgent(ctx, host.sessionAgent(sessionId) ?? '')
) {
emit(event)
}
},
includePrompts: params.includePrompts === true
})
if (stream.isClosed()) {
@@ -21,9 +21,11 @@ import {
ensureStructuredHostInstalled as ensureHostInstalled,
requireInstalledStructuredHost as requireInstalledHost,
requireStructuredCapability,
requireStructuredAgentAudience,
requireStructuredCleanupHost,
requireStructuredCreateSupportAdmission,
requireStructuredHost as requireHost,
requireStructuredSessionHost as requireSessionHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
@@ -56,7 +58,6 @@ import {
CreateParams,
CreateSupportParams,
HistoryParams,
HandoffStatusParams,
OptionsParams,
RespondParams,
RespondToQuestionParams,
@@ -75,8 +76,8 @@ import { sendStructuredAgentSessionForClient } from './structured-agent-session-
* cannot forge that answer.
*/
async function resolveClientSuppliedAttach(params: z.infer<typeof AttachParams>, ctx: RpcContext) {
await ensureHostInstalled(ctx)
const host = requireHost(ctx)
await ensureHostInstalled(ctx, params.agent)
const host = requireHost(ctx, params.agent)
if (!host.supportsCreate(params.location, params.agent)) {
throw agentSessionRefusalError('structured_agent_session_unsupported', {
reason: 'hostUnsupported'
@@ -106,7 +107,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
handler: async (params, ctx) => {
requireStructuredCapability(ctx)
await ensureHostInstalled(ctx)
return requireHost(ctx).rewind(callerFor(ctx), params)
return requireSessionHost(ctx, params.envelope.sessionId).rewind(callerFor(ctx), params)
}
}),
defineMethod({
@@ -115,7 +116,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
handler: async (params, ctx) => {
requireStructuredCapability(ctx)
await ensureHostInstalled(ctx)
const host = requireHost(ctx)
const host = requireSessionHost(ctx, params.envelope.sessionId)
await host.revealSession(params.envelope.sessionId)
const result = await host.conversationCommand(callerFor(ctx), params)
if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) {
@@ -134,7 +135,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
name: 'agentSession.createSupport',
params: CreateSupportParams,
handler: async (params, ctx) => {
requireStructuredCreateSupportAdmission(ctx)
requireStructuredCreateSupportAdmission(ctx, params.agent)
const support = await ctx.runtime.getStructuredAgentSessionCreateSupport(
params.worktree,
params.agent
@@ -150,7 +151,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
name: 'agentSession.create',
params: CreateParams,
handler: async (params, ctx) => {
requireStructuredCapability(ctx)
requireStructuredAgentAudience(ctx, params.agent)
if (params.envelope.expectedRuntimeFence !== null) {
throw agentSessionRefusalError('agent_session_operation_invalid', {
reason: 'requestMalformed'
@@ -231,35 +232,31 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
name: 'agentSession.respondToApproval',
params: RespondParams,
handler: async (params, ctx) =>
requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' })
requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), {
...params,
kind: 'approval'
})
}),
defineMethod({
name: 'agentSession.respondToQuestion',
params: RespondToQuestionParams,
handler: async (params, ctx) =>
requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' })
requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), {
...params,
kind: 'question'
})
}),
defineMethod({
name: 'agentSession.setOption',
params: SetOptionParams,
handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.handoffStatus',
params: HandoffStatusParams,
handler: async (params, ctx) =>
(await requireInstalledHost(ctx)).handoffStatus(params.sessionId)
}),
defineMethod({
name: 'agentSession.commands',
params: OptionsParams,
handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId)
requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.history',
params: HistoryParams,
handler: async (params, ctx) => {
const host = await requireInstalledHost(ctx)
const host = await requireInstalledHost(ctx, params.sessionId)
return projectTurnItemHistory(
projectBackgroundTaskHistory(await host.history(params), ctx),
ctx,
@@ -271,7 +268,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
name: 'agentSession.subscribe',
params: SubscribeParams,
handler: async (params, ctx, emit) => {
const host = await requireInstalledHost(ctx)
const host = await requireInstalledHost(ctx, params.sessionId)
const subscriptionId = subscriptionIdFor(ctx, params.sessionId)
// A stream reads; it never keeps an agent alive or starts one.
let dispose = (): void => {}
@@ -9,7 +9,7 @@ describe('ACP agents in the runtime registrations', () => {
it('registers Grok beside Claude and Codex with its declared capabilities', () => {
expect(
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent)
).toEqual(['codex', 'claude', 'grok', 'opencode'])
).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode'])
expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({
handleTransport: 'acp',
accountHomeVariable: 'GROK_HOME',
@@ -50,6 +50,7 @@ import {
resolveAcpLaunchCommand
} from '../acp/acp-structured-launch-resolution'
import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter'
import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration'
/** What an agent's adapter is built from: the open store and the runtime around it. */
export type StructuredAgentAdapterContext = {
@@ -266,6 +267,7 @@ async function resolveCodexAccountHomePath(
export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] =
[
PI_RPC_RUNTIME_REGISTRATION,
{
definition: CODEX_STRUCTURED_AGENT,
createAdapter: createCodexAdapter,
@@ -10,6 +10,7 @@
// A process whose journal will not open installs none and answers every
// structured request with the refusal that says why.
import type { PiRpcSessionDeps } from '../pi/rpc-session'
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import { existsSync } from 'node:fs'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
@@ -100,6 +101,7 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveClaudeCommand?: () => string
openPiConnection?: PiRpcSessionDeps['openConnection']
/** Which version-gated flags a Claude CLI takes; absent never passes one. */
claudeCliFlags?: ClaudeCliFlagSupport
/** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat
@@ -1,4 +1,5 @@
import { ImeInput } from '@/lib/ime-text-field'
import { Textarea } from '@/components/ui/textarea'
import { useRef, useState, type RefObject } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -7,6 +8,7 @@ import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-pr
import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse'
import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus'
import { isEditableTarget } from '@/lib/editable-target'
import type { AgentJournalFreeTextInput } from '../../../../shared/agent-session-journal-types'
export type NativeChatQuestionCardProps = {
prompt: AskPrompt
@@ -16,6 +18,7 @@ export type NativeChatQuestionCardProps = {
/** Deliver the chosen answer (per-question option indices + free text). */
onAnswer: (selections: AskAnswerSelection[]) => void
allowOther?: boolean | readonly boolean[]
freeTextInputs?: readonly (AgentJournalFreeTextInput | undefined)[]
/** Dismiss the prompt (sends Escape to the agent). */
onCancel: () => void
/** Fold the card to a strip and give the input back, writing nothing; Escape does too. */
@@ -45,6 +48,7 @@ export function NativeChatQuestionCard({
isCancelling = false,
onAnswer,
allowOther = true,
freeTextInputs,
onCancel,
onCollapse,
shouldFocus = false,
@@ -56,17 +60,24 @@ export function NativeChatQuestionCard({
// Keep option identity by index: labels are display text and are not guaranteed
// unique, while Claude's selector commits the numbered row (STA-1860).
const [selections, setSelections] = useState<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const [otherText, setOtherText] = useState<string[]>(() =>
prompt.questions.map((_, qi) => freeTextInputs?.[qi]?.initialValue ?? '')
)
const total = prompt.questions.length
const isLast = index === total - 1
const q = prompt.questions[index]!
const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther
const freeTextInput = freeTextInputs?.[index]
const acceptsEmpty = (qi: number): boolean =>
freeTextInputs?.[qi]?.allowEmpty === true && prompt.questions[qi]?.options.length === 0
const typedText = (qi: number, oth = otherText): string =>
freeTextInputs?.[qi]?.allowEmpty ? (oth[qi] ?? '') : (oth[qi] ?? '').trim()
// Picking an option replaces a chosen typed answer on single-select; the text stays in
// the field, unsent, until the user types or clicks there again.
const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean =>
(sel[qi] ?? []).includes(TYPED_ANSWER) && (oth[qi] ?? '').trim().length > 0
acceptsEmpty(qi) || ((sel[qi] ?? []).includes(TYPED_ANSWER) && typedText(qi, oth).length > 0)
const chooseTypedAnswer = (qi: number): void => {
setSelections((prev) => {
@@ -99,21 +110,23 @@ export function NativeChatQuestionCard({
const picked = pickedOptions(qi, sel)
.map((optionIndex) => question?.options[optionIndex]?.label ?? '')
.filter((label) => label.length > 0)
const other = typedAnswerChosen(qi, sel, oth) ? (oth[qi] ?? '').trim() : ''
const other = typedAnswerChosen(qi, sel, oth) ? typedText(qi, oth) : ''
return [...picked, ...(other ? [other] : [])].join(', ')
}
const currentAnswered = answerFor(index).length > 0
const currentAnswered = answerFor(index).length > 0 || acceptsEmpty(index)
const currentTypedAnswerChosen = typedAnswerChosen(index)
const submitAll = (sel: number[][], oth: string[]): void => {
const resolved: AskAnswerSelection[] = prompt.questions.map((_, i) => {
return {
indices: pickedOptions(i, sel),
other: typedAnswerChosen(i, sel, oth) ? (oth[i] ?? '').trim() : ''
other: typedAnswerChosen(i, sel, oth) ? typedText(i, oth) : ''
}
})
const anyAnswered = resolved.some((s) => s.indices.length > 0 || (s.other ?? '').length > 0)
const anyAnswered = resolved.some(
(s, i) => s.indices.length > 0 || (s.other ?? '').length > 0 || acceptsEmpty(i)
)
if (anyAnswered) {
onAnswer(resolved)
}
@@ -159,7 +172,7 @@ export function NativeChatQuestionCard({
advanceOrSubmit(selections, otherText)
return
}
const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0)
const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0 || acceptsEmpty(i))
if (anyAnswered) {
submitAll(selections, otherText)
} else if (!fromKeyboard) {
@@ -285,35 +298,47 @@ export function NativeChatQuestionCard({
AskUserQuestion tool result: it reaches the model but never the command
parser, so `/compact` and friends are inert, while a skill name can
still be acted on. */}
<ImeInput
ref={answerInputRef}
disabled={isSubmitting}
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
// Click, not focus: tabbing through the field toward Submit must not
// replace the option the user just picked.
onClick={() => {
if ((otherText[index] ?? '').trim().length > 0) {
chooseTypedAnswer(index)
{freeTextInput?.multiline ? (
<Textarea
disabled={isSubmitting}
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
placeholder={freeTextInput.placeholder}
/>
) : (
<ImeInput
ref={answerInputRef}
disabled={isSubmitting}
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
// Click, not focus: tabbing through the field toward Submit must not
// replace the option the user just picked.
onClick={() => {
if ((otherText[index] ?? '').trim().length > 0) {
chooseTypedAnswer(index)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirm(true)
}
}}
placeholder={
freeTextInput?.placeholder ??
translate(
'components.native-chat.question.otherPlaceholder',
'Type your answer'
)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirm(true)
}
}}
placeholder={translate(
'components.native-chat.question.otherPlaceholder',
'Type your answer'
)}
className={cn(
'min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50',
currentTypedAnswerChosen || !otherText[index]
? 'text-foreground'
: 'text-muted-foreground'
)}
/>
className={cn(
'min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50',
currentTypedAnswerChosen || !otherText[index]
? 'text-foreground'
: 'text-muted-foreground'
)}
/>
)}
</>
) : (
<span className="flex-1" />
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types'
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
import { nativeChatReceiptAnswers } from './native-chat-resolution-receipt'
afterEach(cleanup)
// A Pi input dialog the user answered with nothing: the provider accepts an empty answer.
const emptyInput: AgentJournalQuestionItem = {
kind: 'question',
question: 'Enter text',
options: [],
freeTextQuestionId: 'text',
freeTextInput: { allowEmpty: true },
resolution: {
state: 'resolved',
selectedOptionId: 'text:',
answers: [{ questionId: 'text', optionIds: [], other: '' }],
resolvedBy: null,
resolvedAt: 1000
}
}
describe('an explicitly empty text answer', () => {
it('reads as answered with nothing, not as an answer Orca cannot read', () => {
render(<NativeChatResolutionReceipt body={emptyInput} />)
expect(screen.getByText('Empty answer')).toBeInTheDocument()
expect(screen.queryByText('Selected answer unavailable')).toBeNull()
expect(nativeChatReceiptAnswers(emptyInput)).toEqual([{ question: null, answer: '' }])
})
it('counts whitespace alone as empty', () => {
const blank = {
...emptyInput,
resolution: {
...emptyInput.resolution,
answers: [{ questionId: 'text', optionIds: [], other: ' \n ' }]
}
}
expect(nativeChatReceiptAnswers(blank)).toEqual([{ question: null, answer: '' }])
})
it('still says unavailable when the answer names no text and no option', () => {
const missing = {
...emptyInput,
resolution: {
...emptyInput.resolution,
answers: [{ questionId: 'text', optionIds: [] }]
}
}
render(<NativeChatResolutionReceipt body={missing} />)
expect(screen.getByText('Selected answer unavailable')).toBeInTheDocument()
})
})
@@ -72,11 +72,13 @@ export function NativeChatResolutionReceipt({
<p>{answer.question}</p>
) : null}
<p className="line-clamp-3 whitespace-pre-wrap break-words">
{answer.answer ??
translate(
'components.native-chat.receipt.unavailable',
'Selected answer unavailable'
)}
{answer.answer === ''
? translate('components.native-chat.receipt.emptyAnswer', 'Empty answer')
: (answer.answer ??
translate(
'components.native-chat.receipt.unavailable',
'Selected answer unavailable'
))}
</p>
</div>
))}
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NativeChatStructuredQuestionCard } from './NativeChatStructuredQuestionCard'
import type { AgentJournalQuestion } from '../../../../shared/agent-session-journal-types'
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function render(question: AgentJournalQuestion) {
const onAnswer = vi.fn()
const onCancel = vi.fn()
act(() =>
root.render(
<NativeChatStructuredQuestionCard
questions={[question]}
onAnswer={onAnswer}
onCancel={onCancel}
/>
)
)
return { onAnswer, onCancel }
}
function submit(): void {
const button = [...container.querySelectorAll('button')].find(
(node) => node.textContent?.trim() === 'Submit'
)
if (!button) {
throw new Error('Missing Submit button')
}
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })))
}
const input: AgentJournalQuestion = {
id: 'text',
question: 'Enter text',
options: [],
multiSelect: false,
freeTextQuestionId: 'text',
freeTextInput: { allowEmpty: true, placeholder: 'Provider placeholder' }
}
describe('structured extension question cards', () => {
it('submits an empty input as an answer', () => {
const { onAnswer } = render(input)
expect(container.querySelector('input')?.placeholder).toBe('Provider placeholder')
submit()
expect(onAnswer).toHaveBeenCalledWith({
kind: 'answers',
answers: [{ questionId: 'text', optionIds: [], other: '' }]
})
})
it('renders the editor prefill and preserves whitespace, newlines and Unicode separators', () => {
const text = ' first\nsecond\u2028third\u2029fourth '
const { onAnswer } = render({
...input,
freeTextInput: { allowEmpty: true, multiline: true, initialValue: text }
})
expect(container.querySelector('textarea')?.value).toBe(text)
expect(container.querySelector('input')).toBeNull()
submit()
expect(onAnswer).toHaveBeenCalledWith({
kind: 'answers',
answers: [{ questionId: 'text', optionIds: [], other: text }]
})
})
it('submits the text the user edited in the editor, not its prefill', () => {
const { onAnswer } = render({
...input,
freeTextInput: { allowEmpty: true, multiline: true, initialValue: 'prefill' }
})
const textarea = container.querySelector('textarea')
if (!textarea) {
throw new Error('Missing editor textarea')
}
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
act(() => {
setValue?.call(textarea, 'prefill, then edited\nsecond line')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
})
submit()
expect(onAnswer).toHaveBeenCalledWith({
kind: 'answers',
answers: [{ questionId: 'text', optionIds: [], other: 'prefill, then edited\nsecond line' }]
})
})
it('keeps legacy empty input disabled', () => {
const { onAnswer } = render({ ...input, freeTextInput: undefined })
expect(
[...container.querySelectorAll('button')].some(
(button) => button.textContent?.trim() === 'Submit'
)
).toBe(false)
expect(onAnswer).not.toHaveBeenCalled()
})
it('keeps select-only questions limited to their offered values', () => {
const { onAnswer } = render({
id: 'q1',
question: 'Select',
options: [{ id: 'opaque-index-0', label: 'Choice' }],
multiSelect: false
})
expect(container.querySelector('input')).toBeNull()
const button = container.querySelector('button[aria-pressed]')
if (!button) {
throw new Error('Missing choice button')
}
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })))
submit()
expect(onAnswer).toHaveBeenCalledWith({
kind: 'answers',
answers: [{ questionId: 'q1', optionIds: ['opaque-index-0'] }]
})
})
})
@@ -0,0 +1,55 @@
import type { RefObject } from 'react'
import type { AgentJournalQuestion } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionPromptResponse } from '../../../../shared/agent-session-question-answer'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
export function NativeChatStructuredQuestionCard({
questions,
onAnswer,
onCancel,
answerInputRef
}: {
questions: readonly AgentJournalQuestion[]
onAnswer: (response: AgentSessionPromptResponse) => void
onCancel: () => void
answerInputRef?: RefObject<HTMLInputElement | null>
}): React.JSX.Element {
return (
<NativeChatQuestionCard
prompt={{
questions: questions.map((question) => ({
question: question.question,
...(question.header ? { header: question.header } : {}),
multiSelect: question.multiSelect,
options: question.options.map((option) => ({
label: option.label,
...(option.description ? { description: option.description } : {})
}))
}))
}}
allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))}
freeTextInputs={questions.map((question) => question.freeTextInput)}
onAnswer={(answers) => {
const chosen = questions.map((question, index) => {
const answer = answers[index]
const other = question.freeTextInput?.allowEmpty ? answer?.other : answer?.other?.trim()
return {
questionId: question.id,
optionIds: (answer?.indices ?? []).flatMap((optionIndex) => {
const id = question.options[optionIndex]?.id
return id ? [id] : []
}),
...(other !== undefined && (other.length > 0 || question.freeTextInput?.allowEmpty)
? { other }
: {})
}
})
if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other !== undefined)) {
onAnswer({ kind: 'answers', answers: chosen })
}
}}
onCancel={onCancel}
answerInputRef={answerInputRef}
/>
)
}
@@ -88,6 +88,25 @@ it('sends nothing for the card when no turn is running', () => {
expect(mocks.cancel).not.toHaveBeenCalled()
})
it('cancels a Pi extension dialog outside a model turn', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="pi-dialog-tab"
sessionId="pi-dialog-session"
target={{ kind: 'local' }}
agent="pi"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).toHaveBeenCalledWith(undefined, {
itemId: 'approval-item',
expectedRevision: 3
})
})
function renderSession() {
render(
<NativeChatStructuredSession
@@ -13,7 +13,7 @@ import { NativeChatEmptyState } from './NativeChatEmptyState'
import { NativeChatLoadingCue } from './NativeChatLoadingCue'
import { NativeChatMessageList } from './NativeChatMessageList'
import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import { NativeChatStructuredQuestionCard } from './NativeChatStructuredQuestionCard'
import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state'
import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus'
import { useNativeChatFontSize } from './use-native-chat-font-size'
@@ -162,8 +162,8 @@ export function NativeChatStructuredSession(
const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally
const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null
const cancelPrompt = () => {
if (controller.turnId && prompt) {
void controller.cancel(controller.turnId, {
if (prompt && (controller.turnId || props.agent === 'pi')) {
void controller.cancel(controller.turnId ?? undefined, {
itemId: prompt.itemId,
expectedRevision: prompt.revision
})
@@ -305,34 +305,10 @@ export function NativeChatStructuredSession(
/>
) : null}
{prompt && questionBody ? (
<NativeChatQuestionCard
<NativeChatStructuredQuestionCard
key={`${prompt.itemId}:${prompt.revision}`}
prompt={{
questions: questions.map((question) => ({
question: question.question,
...(question.header ? { header: question.header } : {}),
multiSelect: question.multiSelect,
options: question.options.map((option) => ({
label: option.label,
...(option.description ? { description: option.description } : {})
}))
}))
}}
allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))}
onAnswer={(answers) => {
const chosen = questions.map((question, questionIndex) => {
const answer = answers[questionIndex]
const other = answer?.other?.trim()
const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => {
const optionId = question.options[optionIndex]?.id
return optionId ? [optionId] : []
})
return { questionId: question.id, optionIds, ...(other ? { other } : {}) }
})
if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) {
void submits.respond(prompt, { kind: 'answers', answers: chosen })
}
}}
questions={questions}
onAnswer={(response) => void submits.respond(prompt, response)}
onCancel={cancelPrompt}
answerInputRef={questionAnswerInputRef}
/>
@@ -8,6 +8,7 @@ import {
} from '../../../../shared/agent-session-question-answer'
export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem
/** `answer` is '' for an explicitly empty text answer and null when the answer cannot be read. */
export type NativeChatReceiptAnswer = { question: string | null; answer: string | null }
export function nativeChatReceiptAnswers(
@@ -28,11 +29,16 @@ export function nativeChatReceiptAnswers(
(id) => question.options.find((option) => option.id === id)?.label
)
const valid = labels?.every((label) => label !== undefined)
const parts = [...(labels ?? []), ...(answer?.other?.trim() ? [answer.other] : [])]
return {
question: body.questions ? question.question : null,
answer: valid
? [...(labels ?? []), ...(answer?.other ? [answer.other] : [])].join(' · ') || null
: null
answer: !valid
? null
: parts.length > 0
? parts.join(' · ')
: answer?.other !== undefined
? ''
: null
}
})
}

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